diff --git a/ApiCore-Backend/prisma/migrations/20260927031004_idempotency_and_automation_task_source/migration.sql b/ApiCore-Backend/prisma/migrations/20260927031004_idempotency_and_automation_task_source/migration.sql new file mode 100644 index 0000000..c0c2727 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260927031004_idempotency_and_automation_task_source/migration.sql @@ -0,0 +1,29 @@ +-- CreateEnum +CREATE TYPE "IdempotencyStatus" AS ENUM ('PENDING', 'COMPLETED'); + +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "sourceAutomationTrigger" "AutomationTriggerType"; + +-- CreateTable +CREATE TABLE "IdempotencyRecord" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "status" "IdempotencyStatus" NOT NULL DEFAULT 'PENDING', + "statusCode" INTEGER, + "responseBody" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IdempotencyRecord_createdAt_idx" ON "IdempotencyRecord"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "IdempotencyRecord_userId_key_key" ON "IdempotencyRecord"("userId", "key"); + +-- CreateIndex +CREATE INDEX "Task_sourceAutomationTrigger_relatedType_relatedId_status_idx" ON "Task"("sourceAutomationTrigger", "relatedType", "relatedId", "status"); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 4d56a5c..7600c1d 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -444,27 +444,33 @@ enum TaskRelatedType { } model Task { - id String @id @default(cuid()) - title String - description String? - priority TaskPriority @default(NORMAL) - dueDate DateTime? - status TaskStatus @default(OPEN) - assignedUserId String? - assignedUser User? @relation("TaskAssignee", fields: [assignedUserId], references: [id]) - createdByUserId String? - createdByUser User? @relation("TaskCreator", fields: [createdByUserId], references: [id]) - relatedType TaskRelatedType @default(GENERAL) - relatedId String? - sourceInspectionId String? - sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id]) - sourceTemplateId String? - sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id]) - createdAt DateTime @default(now()) - completedAt DateTime? + id String @id @default(cuid()) + title String + description String? + priority TaskPriority @default(NORMAL) + dueDate DateTime? + status TaskStatus @default(OPEN) + assignedUserId String? + assignedUser User? @relation("TaskAssignee", fields: [assignedUserId], references: [id]) + createdByUserId String? + createdByUser User? @relation("TaskCreator", fields: [createdByUserId], references: [id]) + relatedType TaskRelatedType @default(GENERAL) + relatedId String? + sourceInspectionId String? + sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id]) + sourceTemplateId String? + sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id]) + // Set when an automation rule created the task (not a person). Keeps an + // automatic suggestion distinguishable from a planned task, and lets the + // rule see that it already left an open task for the same object instead + // of piling up a duplicate on every further trigger. + sourceAutomationTrigger AutomationTriggerType? + createdAt DateTime @default(now()) + completedAt DateTime? @@index([status, dueDate]) @@index([relatedType, relatedId]) + @@index([sourceAutomationTrigger, relatedType, relatedId, status]) } enum TaskTemplateRecurrenceType { @@ -1136,6 +1142,37 @@ model CalendarSyncSettings { // erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das // bestehende Graph-Event über die gespeicherte graphEventId, statt ein // Duplikat anzulegen. +// --------------------------------------------------------------------------- +// Idempotenz – schützt Anlege-Anfragen vor doppelter Ausführung, wenn ein +// Client dieselbe Anfrage wiederholt (Offline-Warteschlange nach verlorener +// Antwort, Netzabbruch, erneutes Absenden). Der Client schickt pro fachlichem +// Vorgang einen eindeutigen `Idempotency-Key`; eine Wiederholung bekommt die +// gespeicherte erste Antwort statt eines zweiten Datensatzes. Siehe +// src/common/idempotency/idempotency.interceptor.ts. +// --------------------------------------------------------------------------- + +enum IdempotencyStatus { + PENDING + COMPLETED +} + +model IdempotencyRecord { + id String @id @default(cuid()) + userId String + key String + // "POST /api/v1/inspections" - a key reused for a different endpoint is + // rejected instead of replaying an unrelated response. + scope String + status IdempotencyStatus @default(PENDING) + statusCode Int? + responseBody Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, key]) + @@index([createdAt]) +} + model CalendarSyncOutboxEntry { id String @id @default(cuid()) sourceType CalendarSyncSourceType diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 04d3272..4564b2c 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from './prisma/prisma.module'; @@ -47,6 +47,8 @@ import { BackupModule } from './backup/backup.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; +import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor'; +import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service'; @Module({ imports: [ @@ -101,6 +103,8 @@ import { PermissionsModule } from './auth/permissions/permissions.module'; { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_GUARD, useClass: ThrottlerGuard }, + { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, + IdempotencyCleanupService, ], }) export class AppModule {} diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.spec.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.spec.ts index c310263..b552e86 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.spec.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.spec.ts @@ -5,7 +5,7 @@ import { PERMISSION_CATALOG } from './permission-catalog'; describe('PermissionCatalogSyncService', () => { let service: PermissionCatalogSyncService; let prisma: { - permission: { findMany: jest.Mock; create: jest.Mock }; + permission: { findMany: jest.Mock; createMany: jest.Mock }; role: { findMany: jest.Mock }; rolePermission: { createMany: jest.Mock }; }; @@ -13,10 +13,10 @@ describe('PermissionCatalogSyncService', () => { beforeEach(() => { prisma = { permission: { + // 1st call: existing keys (set per test). 2nd call: ids of the + // freshly inserted keys, derived from the `where.key.in` filter. findMany: jest.fn(), - create: jest.fn((args: { data: { key: string } }) => - Promise.resolve({ id: `perm-${args.data.key}`, ...args.data }), - ), + createMany: jest.fn().mockResolvedValue({ count: 0 }), }, role: { findMany: jest.fn().mockResolvedValue([]) }, rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) }, @@ -33,16 +33,16 @@ describe('PermissionCatalogSyncService', () => { await service.onModuleInit(); - expect(prisma.permission.create).not.toHaveBeenCalled(); + expect(prisma.permission.createMany).not.toHaveBeenCalled(); expect(prisma.role.findMany).not.toHaveBeenCalled(); expect(prisma.rolePermission.createMany).not.toHaveBeenCalled(); }); it('inserts missing catalog permissions and grants them to every system role', async () => { const [first, ...rest] = PERMISSION_CATALOG; - prisma.permission.findMany.mockResolvedValue( - rest.map((entry) => ({ key: entry.key })), - ); + prisma.permission.findMany + .mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key }))) + .mockResolvedValueOnce([{ id: `perm-${first.key}` }]); prisma.role.findMany.mockResolvedValue([ { id: 'role-admin' }, { id: 'role-owner' }, @@ -50,7 +50,10 @@ describe('PermissionCatalogSyncService', () => { await service.onModuleInit(); - expect(prisma.permission.create).toHaveBeenCalledWith({ data: first }); + expect(prisma.permission.createMany).toHaveBeenCalledWith({ + data: [first], + skipDuplicates: true, + }); expect(prisma.role.findMany).toHaveBeenCalledWith({ where: { isSystem: true }, select: { id: true }, @@ -70,9 +73,26 @@ describe('PermissionCatalogSyncService', () => { await service.onModuleInit(); - expect(prisma.permission.create).toHaveBeenCalledTimes( - PERMISSION_CATALOG.length, - ); + expect(prisma.permission.createMany).toHaveBeenCalledWith({ + data: PERMISSION_CATALOG, + skipDuplicates: true, + }); expect(prisma.rolePermission.createMany).not.toHaveBeenCalled(); }); + + it('tolerates another replica inserting the same permissions concurrently', async () => { + // Both replicas saw the permission as missing; the other one won the + // insert, so createMany skips it instead of throwing a unique error. + const [first, ...rest] = PERMISSION_CATALOG; + prisma.permission.findMany + .mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key }))) + .mockResolvedValueOnce([{ id: `perm-${first.key}` }]); + prisma.role.findMany.mockResolvedValue([{ id: 'role-admin' }]); + + await expect(service.onModuleInit()).resolves.toBeUndefined(); + expect(prisma.rolePermission.createMany).toHaveBeenCalledWith({ + data: [{ roleId: 'role-admin', permissionId: `perm-${first.key}` }], + skipDuplicates: true, + }); + }); }); diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts index 955dd3d..c3b02c5 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts @@ -33,10 +33,18 @@ export class PermissionCatalogSyncService implements OnModuleInit { const missing = PERMISSION_CATALOG.filter( (entry) => !existingKeys.has(entry.key), ); - const createdPermissions = await Promise.all( - missing.map((entry) => this.prisma.permission.create({ data: entry })), - ); if (missing.length > 0) { + // skipDuplicates: several replicas boot at once against the same + // database and all see the same "missing" set. Plain creates made + // every replica but the first crash on a unique-key error. + await this.prisma.permission.createMany({ + data: missing, + skipDuplicates: true, + }); + const createdPermissions = await this.prisma.permission.findMany({ + where: { key: { in: missing.map((entry) => entry.key) } }, + select: { id: true }, + }); this.logger.log( `Synced ${missing.length} new permission(s) from the code catalog: ${missing .map((entry) => entry.key) diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index 1cf130e..08e156b 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -14,7 +14,7 @@ describe('AutomationService', () => { findUnique: jest.Mock; update: jest.Mock; }; - task: { create: jest.Mock }; + task: { create: jest.Mock; findFirst: jest.Mock }; }; let auditLog: { record: jest.Mock }; let notifications: { record: jest.Mock }; @@ -50,7 +50,10 @@ describe('AutomationService', () => { findUnique: jest.fn(), update: jest.fn(), }, - task: { create: jest.fn().mockResolvedValue({ id: 'task-1' }) }, + task: { + create: jest.fn().mockResolvedValue({ id: 'task-1' }), + findFirst: jest.fn().mockResolvedValue(null), + }, }; auditLog = { record: jest.fn() }; notifications = { record: jest.fn() }; @@ -154,6 +157,7 @@ describe('AutomationService', () => { await service.evaluateVarroaMeasurement({ id: 'measurement-1', colonyId: 'colony-1', + method: 'NATURAL_MITE_FALL', calculatedValue: 12, miteCount: null, } as never); @@ -167,6 +171,7 @@ describe('AutomationService', () => { await service.evaluateVarroaMeasurement({ id: 'measurement-2', colonyId: 'colony-1', + method: 'NATURAL_MITE_FALL', calculatedValue: 5, miteCount: null, } as never); @@ -174,17 +179,59 @@ describe('AutomationService', () => { expect(prisma.task.create).not.toHaveBeenCalled(); }); - it('falls back to miteCount when calculatedValue is null', async () => { + it('ignores a raw board count without a per-day rate', async () => { + // 15 mites over an unknown number of days says nothing about the + // per-day threshold - comparing them would be a unit error. prisma.automationRule.findUnique.mockResolvedValue(varroaRule); await service.evaluateVarroaMeasurement({ id: 'measurement-3', colonyId: 'colony-1', + method: 'NATURAL_MITE_FALL', calculatedValue: null, miteCount: 15, } as never); - expect(prisma.task.create).toHaveBeenCalledTimes(1); + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + + it('ignores sample-based methods, whose values use another unit', async () => { + prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + + await service.evaluateVarroaMeasurement({ + id: 'measurement-4', + colonyId: 'colony-1', + method: 'WASHING_METHOD', + calculatedValue: 30, + miteCount: 30, + } as never); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + + it('adds no second task while one for the same colony is still open', async () => { + prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + prisma.task.findFirst.mockResolvedValue({ id: 'open-task' }); + + await service.evaluateVarroaMeasurement({ + id: 'measurement-5', + colonyId: 'colony-1', + method: 'NATURAL_MITE_FALL', + calculatedValue: 25, + miteCount: 25, + } as never); + + expect(prisma.task.create).not.toHaveBeenCalled(); + expect(notifications.record).not.toHaveBeenCalled(); + expect(prisma.task.findFirst).toHaveBeenCalledWith({ + where: { + sourceAutomationTrigger: 'VARROA_THRESHOLD', + relatedType: 'COLONY', + relatedId: 'colony-1', + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + select: { id: true }, + }); }); }); diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 651dc1b..62cc842 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -62,6 +62,27 @@ const DEFAULT_RULES: Record = { }, }; +/** + * Optional per-rule follow-up interval (config.dueInDays). There is no + * built-in default: how soon a queenless colony or a high mite fall needs a + * look depends on season and practice, so the rule stays date-less unless the + * beekeeper configures one. + */ +function dueDateFromConfig(dueInDays: unknown): Date | undefined { + if ( + typeof dueInDays !== 'number' || + !Number.isInteger(dueInDays) || + dueInDays < 0 || + dueInDays > 365 + ) { + return undefined; + } + const due = new Date(); + due.setUTCHours(0, 0, 0, 0); + due.setUTCDate(due.getUTCDate() + dueInDays); + return due; +} + @Injectable() export class AutomationService { constructor( @@ -174,9 +195,16 @@ export class AutomationService { if (!rule || !rule.active) { return; } + // The threshold is a natural mite fall rate (mites per day). Only that + // method yields a value in this unit - a mite count from a sugar roll or + // wash is per bee sample and would be compared against the wrong scale, + // as would a raw board count without the number of days it covered. + if (measurement.method !== 'NATURAL_MITE_FALL') { + return; + } const config = rule.config as { thresholdMitesPerDay?: number } | null; const threshold = config?.thresholdMitesPerDay ?? 10; - const value = measurement.calculatedValue ?? measurement.miteCount; + const value = measurement.calculatedValue; if (value !== null && value >= threshold) { await this.triggerAction(rule, 'COLONY', measurement.colonyId); } @@ -274,13 +302,35 @@ export class AutomationService { return rule; } + /** + * Leaves a suggestion task (never a record of work done) plus a warning. + * + * At most one open task per rule and object: while the reorder task for a + * stock item or the check task for a colony is still open, further triggers + * (the next removal below minimum, a second high varroa count) add nothing. + * Repeating the same alarm would only bury the one task that matters. + */ private async triggerAction( rule: AutomationRule, relatedType: TaskRelatedType, relatedId: string, sourceInspectionId?: string, ): Promise { + const alreadyOpen = await this.prisma.task.findFirst({ + where: { + sourceAutomationTrigger: rule.triggerType, + relatedType, + relatedId, + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + select: { id: true }, + }); + if (alreadyOpen) { + return; + } + const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; + const config = rule.config as { dueInDays?: unknown } | null; const task = await this.prisma.task.create({ data: { title, @@ -288,6 +338,8 @@ export class AutomationService { relatedType, relatedId, sourceInspectionId, + sourceAutomationTrigger: rule.triggerType, + dueDate: dueDateFromConfig(config?.dueInDays), }, }); await this.webhooks.dispatch('task.created', task); diff --git a/ApiCore-Backend/src/bottling/bottling.controller.ts b/ApiCore-Backend/src/bottling/bottling.controller.ts index d8e9a52..35ec4b1 100644 --- a/ApiCore-Backend/src/bottling/bottling.controller.ts +++ b/ApiCore-Backend/src/bottling/bottling.controller.ts @@ -15,6 +15,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { BottlingService } from './bottling.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('bottling') @ApiBearerAuth() @@ -35,6 +36,7 @@ export class BottlingController { } @RequirePermission('bottling:create') + @Idempotent() @Post() create( @Body() dto: CreateBottlingBatchDto, diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts index 88416bc..26675d9 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.spec.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -5,6 +5,18 @@ import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationService } from '../automation/automation.service'; +type AmountChange = number | { increment?: number; decrement?: number }; + +function applyAmountChange( + current: number | null, + change: AmountChange | undefined, +): number | null { + if (change === undefined) return current; + if (typeof change === 'number') return change; + if (current === null) return null; + return current + (change.increment ?? 0) - (change.decrement ?? 0); +} + describe('BottlingService', () => { let service: BottlingService; let prisma: { @@ -21,18 +33,71 @@ describe('BottlingService', () => { let auditLog: { record: jest.Mock }; let tx: { bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock }; - honeyBatch: { update: jest.Mock }; + honeyBatch: { + findUnique: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + }; }; let webhooks: { dispatch: jest.Mock }; + // Honey batch rows as the transaction sees them - the tests assert the + // resulting amounts instead of the exact sequence of UPDATE statements. + let honeyBatches: Map; beforeEach(() => { + honeyBatches = new Map(); tx = { bottlingBatch: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, - honeyBatch: { update: jest.fn() }, + honeyBatch: { + findUnique: jest.fn(({ where }: { where: { id: string } }) => { + const row = honeyBatches.get(where.id); + return Promise.resolve(row ? { id: where.id, ...row } : null); + }), + update: jest.fn( + ({ + where, + data, + }: { + where: { id: string }; + data: { amount?: AmountChange; status?: string }; + }) => { + const row = honeyBatches.get(where.id) ?? { amount: null }; + honeyBatches.set(where.id, { + amount: applyAmountChange(row.amount, data.amount), + status: data.status ?? row.status, + }); + return Promise.resolve({}); + }, + ), + updateMany: jest.fn( + ({ + where, + data, + }: { + where: { id: string; amount?: { gte?: number } }; + data: { amount: AmountChange; status?: string }; + }) => { + const row = honeyBatches.get(where.id); + const minimum = where.amount?.gte; + if ( + !row || + row.amount === null || + (minimum !== undefined && row.amount < minimum) + ) { + return Promise.resolve({ count: 0 }); + } + honeyBatches.set(where.id, { + amount: applyAmountChange(row.amount, data.amount), + status: data.status ?? row.status, + }); + return Promise.resolve({ count: 1 }); + }, + ), + }, }; prisma = { honeyBatch: { findUnique: jest.fn(), update: jest.fn() }, @@ -101,6 +166,7 @@ describe('BottlingService', () => { id: 'h1', amount: 25, }); + honeyBatches.set('h1', { amount: 25 }); tx.bottlingBatch.create.mockResolvedValueOnce({ id: 'b1', totalAmount: 25, @@ -123,10 +189,7 @@ describe('BottlingService', () => { ); expect(result).toEqual({ id: 'b1', totalAmount: 25 }); - expect(tx.honeyBatch.update).toHaveBeenCalledWith({ - where: { id: 'h1' }, - data: { amount: 0, status: 'BOTTLED' }, - }); + expect(honeyBatches.get('h1')).toEqual({ amount: 0, status: 'BOTTLED' }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'BottlingBatch', @@ -135,6 +198,32 @@ describe('BottlingService', () => { ); }); + it('refuses a bottling when the stored amount shrank after the first check', async () => { + // The pre-check saw 25 kg, but a concurrent bottling already took most + // of it - the conditional booking inside the transaction must catch that. + prisma.honeyBatch.findUnique.mockResolvedValueOnce({ + id: 'h1', + amount: 25, + }); + honeyBatches.set('h1', { amount: 3 }); + + await expect( + service.create( + { + honeyBatchId: 'h1', + date: '2026-01-01', + jarSize: 500, + jarCount: 20, + bestBeforeDate: '2027-01-01', + lotNumber: 'L-9', + }, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.bottlingBatch.create).not.toHaveBeenCalled(); + expect(honeyBatches.get('h1')?.amount).toBe(3); + }); + it('update writes an audit log entry with before and after data', async () => { const before = { id: 'b1', @@ -209,6 +298,7 @@ describe('BottlingService', () => { prisma.bottlingBatch.findUnique .mockResolvedValueOnce(before) .mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 }); + honeyBatches.set('h1', { amount: 0 }); await service.update('b1', { jarCount: 10 }, 'user-1'); @@ -217,10 +307,7 @@ describe('BottlingService', () => { ]; expect(updateArgs[0].data.jarCount).toBe(10); expect(updateArgs[0].data.totalAmount).toBe(5); - expect(tx.honeyBatch.update).toHaveBeenCalledWith({ - where: { id: 'h1' }, - data: { amount: 5 }, - }); + expect(honeyBatches.get('h1')?.amount).toBe(5); }); it('rejects a corrected amount that exceeds the honey batch capacity', async () => { @@ -251,17 +338,13 @@ describe('BottlingService', () => { .mockResolvedValueOnce(before) .mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' }); prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 }); + honeyBatches.set('h1', { amount: 2 }); + honeyBatches.set('h2', { amount: 8 }); await service.update('b1', { honeyBatchId: 'h2' }, 'user-1'); - expect(tx.honeyBatch.update).toHaveBeenCalledWith({ - where: { id: 'h1' }, - data: { amount: 7 }, - }); - expect(tx.honeyBatch.update).toHaveBeenCalledWith({ - where: { id: 'h2' }, - data: { amount: 3, status: 'BOTTLED' }, - }); + expect(honeyBatches.get('h1')?.amount).toBe(7); + expect(honeyBatches.get('h2')).toEqual({ amount: 3, status: 'BOTTLED' }); }); it('remove gives the amount back and records a DELETE audit entry', async () => { @@ -272,6 +355,7 @@ describe('BottlingService', () => { totalAmount: 5, }; prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before); + honeyBatches.set('h1', { amount: 2 }); const result = await service.remove('b1', 'user-1'); @@ -279,10 +363,7 @@ describe('BottlingService', () => { expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({ where: { id: 'b1' }, }); - expect(tx.honeyBatch.update).toHaveBeenCalledWith({ - where: { id: 'h1' }, - data: { amount: 7 }, - }); + expect(honeyBatches.get('h1')?.amount).toBe(7); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'BottlingBatch', diff --git a/ApiCore-Backend/src/bottling/bottling.service.ts b/ApiCore-Backend/src/bottling/bottling.service.ts index 770e460..03dbd77 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.ts @@ -7,7 +7,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationService } from '../automation/automation.service'; -import { BottlingBatch } from '../../generated/prisma'; +import { BottlingBatch, Prisma } from '../../generated/prisma'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; @@ -29,6 +29,74 @@ function roundKg(amount: number): number { return Math.round(amount * 1000) / 1000; } +const INSUFFICIENT_AMOUNT_MESSAGE = + 'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge'; + +type Tx = Prisma.TransactionClient; + +/** + * Deducts `amount` kg from a honey batch in one conditional UPDATE, so the + * database - not a value read earlier - decides whether enough is left. + * A batch without a recorded amount (null) is not tracked and stays null. + */ +async function takeFromHoneyBatch( + tx: Tx, + honeyBatchId: string, + amount: number, +): Promise { + const current = await tx.honeyBatch.findUnique({ + where: { id: honeyBatchId }, + select: { amount: true }, + }); + if (!current) { + throw new NotFoundException('Honey batch not found'); + } + if (current.amount === null) { + await tx.honeyBatch.update({ + where: { id: honeyBatchId }, + data: { status: 'BOTTLED' }, + }); + return; + } + // Tolerance of half a gram absorbs float noise from earlier writes. + const booked = await tx.honeyBatch.updateMany({ + where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } }, + data: { amount: { decrement: amount }, status: 'BOTTLED' }, + }); + if (booked.count === 0) { + throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); + } + await normalizeAmount(tx, honeyBatchId); +} + +async function giveBackToHoneyBatch( + tx: Tx, + honeyBatchId: string, + amount: number, +): Promise { + await tx.honeyBatch.updateMany({ + where: { id: honeyBatchId, amount: { not: null } }, + data: { amount: { increment: amount } }, + }); + await normalizeAmount(tx, honeyBatchId); +} + +/** Keeps gram precision and clamps float noise below zero to exactly 0. */ +async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise { + const row = await tx.honeyBatch.findUnique({ + where: { id: honeyBatchId }, + select: { amount: true }, + }); + if (row?.amount == null) return; + const normalized = Math.max(0, roundKg(row.amount)); + if (normalized !== row.amount) { + await tx.honeyBatch.update({ + where: { id: honeyBatchId }, + data: { amount: normalized }, + }); + } +} + @Injectable() export class BottlingService { constructor( @@ -73,11 +141,9 @@ export class BottlingService { } } - const totalAmount = (dto.jarSize * dto.jarCount) / 1000; + const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000); if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) { - throw new BadRequestException( - 'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge', - ); + throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); } const lotNumber = @@ -91,7 +157,12 @@ export class BottlingService { const bottlingBatch: BottlingBatch = await this.prisma.$transaction( async (tx) => { - const created = await tx.bottlingBatch.create({ + // Booked against the stored amount inside the transaction, never + // against the value read above: two bottlings submitted at the same + // time (double tap, two devices) must not both pass the check and + // overwrite each other's deduction. + await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount); + return tx.bottlingBatch.create({ data: { honeyBatchId: dto.honeyBatchId, productId: dto.productId, @@ -104,17 +175,6 @@ export class BottlingService { userId: actingUserId, }, }); - await tx.honeyBatch.update({ - where: { id: dto.honeyBatchId }, - data: { - amount: - honeyBatch.amount !== null - ? honeyBatch.amount - totalAmount - : null, - status: 'BOTTLED', - }, - }); - return created; }, ); @@ -163,8 +223,8 @@ export class BottlingService { throw new NotFoundException('Honey batch not found'); } - // Capacity on the target batch, with this bottling batch's current amount - // released first when it already sits on that same batch. + // Early, friendly check against the amounts read above. The booking + // itself happens atomically inside the transaction below. const availableOnTarget = targetHoneyBatch.amount === null ? null @@ -173,14 +233,26 @@ export class BottlingService { (movedToOtherHoneyBatch ? 0 : before.totalAmount), ); if (availableOnTarget !== null && totalAmount > availableOnTarget) { - throw new BadRequestException( - 'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge', - ); + throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); } // A duplicate lotNumber surfaces as Prisma P2002 and is turned into a // 409 with a field-specific message by AllExceptionsFilter. await this.prisma.$transaction(async (tx) => { + const amountChanged = + movedToOtherHoneyBatch || totalAmount !== before.totalAmount; + if (amountChanged) { + // Give the previous amount back first, then take the corrected one - + // a correction never double-charges the source batch. + await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount); + await takeFromHoneyBatch(tx, honeyBatchId, totalAmount); + } + if (movedToOtherHoneyBatch) { + await tx.honeyBatch.update({ + where: { id: honeyBatchId }, + data: { status: 'BOTTLED' }, + }); + } await tx.bottlingBatch.update({ where: { id }, data: { @@ -196,38 +268,6 @@ export class BottlingService { lotNumber: dto.lotNumber, }, }); - - if (movedToOtherHoneyBatch) { - await tx.honeyBatch.update({ - where: { id: before.honeyBatchId }, - data: { - amount: - before.honeyBatch.amount === null - ? null - : roundKg(before.honeyBatch.amount + before.totalAmount), - }, - }); - await tx.honeyBatch.update({ - where: { id: honeyBatchId }, - data: { - amount: - availableOnTarget === null - ? null - : roundKg(availableOnTarget - totalAmount), - status: 'BOTTLED', - }, - }); - } else if (totalAmount !== before.totalAmount) { - await tx.honeyBatch.update({ - where: { id: honeyBatchId }, - data: { - amount: - availableOnTarget === null - ? null - : roundKg(availableOnTarget - totalAmount), - }, - }); - } }); const bottlingBatch = await this.findById(id); @@ -257,14 +297,7 @@ export class BottlingService { await this.prisma.$transaction(async (tx) => { await tx.bottlingBatch.delete({ where: { id } }); - if (before.honeyBatch.amount !== null) { - await tx.honeyBatch.update({ - where: { id: before.honeyBatchId }, - data: { - amount: roundKg(before.honeyBatch.amount + before.totalAmount), - }, - }); - } + await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount); }); await this.auditLog.record({ diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 603c4db..1dfee5e 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ConflictException, Injectable, NotFoundException, @@ -13,6 +14,7 @@ import { UpdateColonyDto } from './dto/update-colony.dto'; import { MoveColonyDto } from './dto/move-colony.dto'; import { SplitColonyDto } from './dto/split-colony.dto'; import { MergeColonyDto } from './dto/merge-colony.dto'; +import { isLivingColonyStatus } from './colony-status'; export interface ColonyFilter { status?: ColonyStatus; @@ -43,10 +45,13 @@ export class ColoniesService { return colony; } + // Only a colony that no longer exists (dissolved, sold, dead, merged) is + // locked. Weak, conspicuous or queenless colonies are still worked with - + // uniting a queenless colony with another is the textbook case for merge. private assertActive(colony: Colony): void { - if (colony.status !== 'ACTIVE') { + if (!isLivingColonyStatus(colony.status)) { throw new ConflictException( - `Das Volk ist nicht aktiv (Status: ${colony.status})`, + `Das Volk besteht nicht mehr (Status: ${colony.status})`, ); } } @@ -206,6 +211,16 @@ export class ColoniesService { ): Promise { const survivor = await this.findById(id); this.assertActive(survivor); + if (dto.mergedColonyIds.includes(survivor.id)) { + throw new BadRequestException( + 'Ein Volk kann nicht mit sich selbst vereinigt werden', + ); + } + if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) { + throw new BadRequestException( + 'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden', + ); + } await this.prisma.$transaction(async (tx) => { for (const mergedColonyId of dto.mergedColonyIds) { const merged = await tx.colony.findUnique({ @@ -265,6 +280,9 @@ export class ColoniesService { async dissolve(id: string, actingUserId: string): Promise { const before = await this.findById(id); + // Re-dissolving would overwrite deactivatedAt and the terminal status + // (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended. + this.assertActive(before); const colony = await this.prisma.$transaction(async (tx) => { const updated = await tx.colony.update({ where: { id }, diff --git a/ApiCore-Backend/src/colonies/colony-status.ts b/ApiCore-Backend/src/colonies/colony-status.ts new file mode 100644 index 0000000..a9ace43 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colony-status.ts @@ -0,0 +1,18 @@ +import { ColonyStatus } from '../../generated/prisma'; + +/** + * Statuses after which a colony no longer exists as a living unit. The other + * statuses (ACTIVE, WEAK, CONSPICUOUS, QUEENLESS) describe a colony that is + * still in the apiary and can be worked with - a weak colony still gets moved, + * and a queenless one is exactly the colony a beekeeper unites with another. + */ +export const TERMINAL_COLONY_STATUSES: readonly ColonyStatus[] = [ + 'DISSOLVED', + 'SOLD', + 'DECEASED', + 'MERGED', +]; + +export function isLivingColonyStatus(status: ColonyStatus): boolean { + return !TERMINAL_COLONY_STATUSES.includes(status); +} diff --git a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts new file mode 100644 index 0000000..def40e1 --- /dev/null +++ b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts @@ -0,0 +1,29 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../../prisma/prisma.service'; + +// Longer than any realistic offline stretch at the apiary: an entry queued +// on the phone must still find its key when it finally syncs, otherwise the +// repeat would create a duplicate after all. +export const IDEMPOTENCY_RETENTION_DAYS = 90; + +@Injectable() +export class IdempotencyCleanupService { + private readonly logger = new Logger(IdempotencyCleanupService.name); + + constructor(private readonly prisma: PrismaService) {} + + @Cron(CronExpression.EVERY_DAY_AT_4AM) + async removeExpired(now: Date = new Date()): Promise { + const cutoff = new Date( + now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000, + ); + const { count } = await this.prisma.idempotencyRecord.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + if (count > 0) { + this.logger.log(`Removed ${count} expired idempotency record(s)`); + } + return count; + } +} diff --git a/ApiCore-Backend/src/common/idempotency/idempotency.interceptor.ts b/ApiCore-Backend/src/common/idempotency/idempotency.interceptor.ts new file mode 100644 index 0000000..ae5885f --- /dev/null +++ b/ApiCore-Backend/src/common/idempotency/idempotency.interceptor.ts @@ -0,0 +1,188 @@ +import { + BadRequestException, + CallHandler, + ConflictException, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request, Response } from 'express'; +import { Observable, from, of, throwError } from 'rxjs'; +import { catchError, mergeMap } from 'rxjs/operators'; +import { PrismaService } from '../../prisma/prisma.service'; +import { Prisma } from '../../../generated/prisma'; +import type { AuthenticatedUser } from '../../auth/types/authenticated-user'; +import { IDEMPOTENT_KEY } from './idempotent.decorator'; + +export const IDEMPOTENCY_HEADER = 'idempotency-key'; + +// UUIDs and similar opaque tokens - long enough to be unguessable per user, +// short enough to keep the unique index small. Anything else is a client bug. +const KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/; + +const ABANDONED_PENDING_MS = 5 * 60 * 1000; + +type RequestWithUser = Request & { user?: AuthenticatedUser }; + +/** + * Makes a POST safe to repeat. A client that cannot tell whether its request + * arrived - the offline queue after a dropped connection, a retry after a + * timeout - sends the same `Idempotency-Key` again and gets the stored first + * response back instead of a second inspection, harvest or task. + * + * Flow per (user, key): + * - first request claims the key (PENDING row), runs the handler and stores + * status + body (COMPLETED); + * - a repeat after completion replays the stored response; + * - a repeat while the first one still runs gets 409, the client retries later; + * - a failed handler releases the key, so a corrected retry is possible. + * + * Only handlers marked @Idempotent() take part (response bodies are stored, + * so endpoints returning secrets must never opt in). Requests without the + * header, or to other handlers, behave exactly as before. + */ +@Injectable() +export class IdempotencyInterceptor implements NestInterceptor { + constructor( + private readonly prisma: PrismaService, + private readonly reflector: Reflector, + ) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const http = context.switchToHttp(); + const request = http.getRequest(); + const rawKey = request.headers[IDEMPOTENCY_HEADER]; + const userId = request.user?.id; + const optedIn = this.reflector.get( + IDEMPOTENT_KEY, + context.getHandler(), + ); + if ( + !optedIn || + request.method !== 'POST' || + rawKey === undefined || + !userId + ) { + return next.handle(); + } + const key = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!KEY_PATTERN.test(key)) { + throw new BadRequestException( + 'Ungültiger Idempotency-Key (16–128 Zeichen, nur Buchstaben, Ziffern, "-" und "_")', + ); + } + const scope = `${request.method} ${request.baseUrl}${request.path}`; + + let claimed = await this.claim(userId, key, scope); + if (!claimed && (await this.releaseIfAbandoned(userId, key))) { + claimed = await this.claim(userId, key, scope); + } + if (!claimed) { + const existing = await this.prisma.idempotencyRecord.findUnique({ + where: { userId_key: { userId, key } }, + }); + if (!existing) { + // Released between our failed insert and this read - the first + // attempt failed. Let the client retry instead of guessing. + throw new ConflictException( + 'Diese Anfrage wurde gerade abgebrochen - bitte erneut senden', + ); + } + if (existing.scope !== scope) { + throw new ConflictException( + 'Dieser Idempotency-Key wurde bereits für eine andere Aktion verwendet', + ); + } + if (existing.status !== 'COMPLETED') { + throw new ConflictException( + 'Diese Anfrage wird bereits verarbeitet - bitte kurz warten', + ); + } + http.getResponse().status(existing.statusCode ?? 201); + return of(existing.responseBody); + } + + return next.handle().pipe( + mergeMap((body: unknown) => + from(this.complete(userId, key, http.getResponse(), body)), + ), + catchError((error: unknown) => + from(this.release(userId, key)).pipe( + mergeMap(() => throwError(() => error)), + ), + ), + ); + } + + private async claim( + userId: string, + key: string, + scope: string, + ): Promise { + try { + await this.prisma.idempotencyRecord.create({ + data: { userId, key, scope }, + }); + return true; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return false; + } + throw error; + } + } + + private async complete( + userId: string, + key: string, + response: Response, + body: unknown, + ): Promise { + await this.prisma.idempotencyRecord.update({ + where: { userId_key: { userId, key } }, + data: { + status: 'COMPLETED', + statusCode: response.statusCode, + // Round-trip through JSON so Dates etc. are stored exactly as the + // client received them the first time. + responseBody: + body === undefined + ? Prisma.JsonNull + : (JSON.parse(JSON.stringify(body)) as Prisma.InputJsonValue), + }, + }); + return body; + } + + private async release(userId: string, key: string): Promise { + await this.prisma.idempotencyRecord.deleteMany({ where: { userId, key } }); + } + + /** + * A PENDING row older than any real request means the process died + * mid-request (pod restart) and never completed or released the key. + * Without this, the queued entry holding that key would get "already + * processing" forever and never sync. + */ + private async releaseIfAbandoned( + userId: string, + key: string, + ): Promise { + const released = await this.prisma.idempotencyRecord.deleteMany({ + where: { + userId, + key, + status: 'PENDING', + createdAt: { lt: new Date(Date.now() - ABANDONED_PENDING_MS) }, + }, + }); + return released.count > 0; + } +} diff --git a/ApiCore-Backend/src/common/idempotency/idempotent.decorator.ts b/ApiCore-Backend/src/common/idempotency/idempotent.decorator.ts new file mode 100644 index 0000000..f5b2675 --- /dev/null +++ b/ApiCore-Backend/src/common/idempotency/idempotent.decorator.ts @@ -0,0 +1,13 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IDEMPOTENT_KEY = 'idempotent'; + +/** + * Opts a POST handler into Idempotency-Key handling (see + * IdempotencyInterceptor). Opt-in rather than global on purpose: the + * interceptor stores the response body, and some POST responses carry + * secrets (MFA setup, tokens) that must never be persisted. Only plain + * record-creating endpoints - the ones the offline queue and quick-entry + * forms retry - carry this decorator. + */ +export const Idempotent = () => SetMetadata(IDEMPOTENT_KEY, true); diff --git a/ApiCore-Backend/src/extractions/dto/create-extraction.dto.ts b/ApiCore-Backend/src/extractions/dto/create-extraction.dto.ts index 69ab7c1..eba2d33 100644 --- a/ApiCore-Backend/src/extractions/dto/create-extraction.dto.ts +++ b/ApiCore-Backend/src/extractions/dto/create-extraction.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMinSize, + ArrayUnique, IsArray, IsDateString, IsNumber, @@ -18,6 +19,7 @@ export class CreateExtractionDto { @ApiProperty({ type: [String] }) @IsArray() @ArrayMinSize(1) + @ArrayUnique() @IsString({ each: true }) harvestIds!: string[]; diff --git a/ApiCore-Backend/src/feedings/feedings.controller.ts b/ApiCore-Backend/src/feedings/feedings.controller.ts index 8476166..b1b519b 100644 --- a/ApiCore-Backend/src/feedings/feedings.controller.ts +++ b/ApiCore-Backend/src/feedings/feedings.controller.ts @@ -6,6 +6,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateFeedingDto } from './dto/create-feeding.dto'; import { UpdateFeedingDto } from './dto/update-feeding.dto'; import { FeedingsService } from './feedings.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('feedings') @ApiBearerAuth() @@ -26,6 +27,7 @@ export class FeedingsController { } @RequirePermission('feedings:create') + @Idempotent() @Post() create( @Body() dto: CreateFeedingDto, diff --git a/ApiCore-Backend/src/harvests/dto/create-harvest.dto.ts b/ApiCore-Backend/src/harvests/dto/create-harvest.dto.ts index 100f3b1..bbb210d 100644 --- a/ApiCore-Backend/src/harvests/dto/create-harvest.dto.ts +++ b/ApiCore-Backend/src/harvests/dto/create-harvest.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMinSize, + ArrayUnique, IsArray, IsDateString, IsInt, @@ -22,6 +23,7 @@ export class CreateHarvestDto { @ApiProperty({ type: [String] }) @IsArray() @ArrayMinSize(1) + @ArrayUnique() @IsString({ each: true }) colonyIds!: string[]; diff --git a/ApiCore-Backend/src/harvests/harvests.controller.ts b/ApiCore-Backend/src/harvests/harvests.controller.ts index cd1fcc4..0252df3 100644 --- a/ApiCore-Backend/src/harvests/harvests.controller.ts +++ b/ApiCore-Backend/src/harvests/harvests.controller.ts @@ -6,6 +6,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateHarvestDto } from './dto/create-harvest.dto'; import { UpdateHarvestDto } from './dto/update-harvest.dto'; import { HarvestsService } from './harvests.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('harvests') @ApiBearerAuth() @@ -26,6 +27,7 @@ export class HarvestsController { } @RequirePermission('harvests:create') + @Idempotent() @Post() create( @Body() dto: CreateHarvestDto, diff --git a/ApiCore-Backend/src/harvests/harvests.service.ts b/ApiCore-Backend/src/harvests/harvests.service.ts index 974802e..9c1b62b 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.ts @@ -55,7 +55,20 @@ export class HarvestsService { } } + // Net weight is gross weight minus frames and boxes - it cannot be larger. + private assertWeightsPlausible( + grossWeight: number | null | undefined, + netWeight: number | null | undefined, + ): void { + if (grossWeight != null && netWeight != null && netWeight > grossWeight) { + throw new BadRequestException( + 'Das Nettogewicht kann nicht größer als das Bruttogewicht sein', + ); + } + } + async create(dto: CreateHarvestDto, actingUserId: string) { + this.assertWeightsPlausible(dto.grossWeight, dto.netWeight); await this.assertColoniesExist(dto.colonyIds); const harvest = await this.prisma.$transaction(async (tx) => { const created = await tx.harvest.create({ @@ -93,6 +106,10 @@ export class HarvestsService { async update(id: string, dto: UpdateHarvestDto, actingUserId: string) { const before = await this.findById(id); + this.assertWeightsPlausible( + dto.grossWeight !== undefined ? dto.grossWeight : before.grossWeight, + dto.netWeight !== undefined ? dto.netWeight : before.netWeight, + ); const { colonyIds, ...scalarFields } = dto; if (colonyIds) { await this.assertColoniesExist(colonyIds); diff --git a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts index f147ca8..b0edede 100644 --- a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts +++ b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts @@ -6,6 +6,8 @@ import { IsNumber, IsOptional, IsString, + Max, + Min, } from 'class-validator'; export class CreateInspectionDto { @@ -30,6 +32,7 @@ export class CreateInspectionDto { @ApiPropertyOptional() @IsOptional() @IsInt() + @Min(0) colonyStrength?: number; @ApiPropertyOptional() @@ -40,6 +43,8 @@ export class CreateInspectionDto { @ApiPropertyOptional() @IsOptional() @IsInt() + @Min(1) + @Max(5) broodPattern?: number; @ApiPropertyOptional() @@ -70,16 +75,22 @@ export class CreateInspectionDto { @ApiPropertyOptional() @IsOptional() @IsInt() + @Min(1) + @Max(5) swarmMood?: number; @ApiPropertyOptional() @IsOptional() @IsInt() + @Min(1) + @Max(5) gentleness?: number; @ApiPropertyOptional() @IsOptional() @IsInt() + @Min(1) + @Max(5) combBehavior?: number; @ApiPropertyOptional() diff --git a/ApiCore-Backend/src/inspections/inspections.controller.ts b/ApiCore-Backend/src/inspections/inspections.controller.ts index e0a7ac3..3a847c3 100644 --- a/ApiCore-Backend/src/inspections/inspections.controller.ts +++ b/ApiCore-Backend/src/inspections/inspections.controller.ts @@ -7,6 +7,7 @@ import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { UpdateInspectionDto } from './dto/update-inspection.dto'; import { InspectionsService } from './inspections.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('inspections') @ApiBearerAuth() @@ -27,6 +28,7 @@ export class InspectionsController { } @RequirePermission('inspections:create') + @Idempotent() @Post() create( @Body() dto: CreateInspectionDto, diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index fd7f6db..dd4b2d4 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -80,7 +80,7 @@ export class InspectionsService { actingUserId: string, ): Promise { const inspection = await this.findById(id); - return this.prisma.task.create({ + const task = await this.prisma.task.create({ data: { title: dto.title, description: dto.description, @@ -93,5 +93,19 @@ export class InspectionsService { sourceInspectionId: inspection.id, }, }); + // Same trail as a task created on the task page: audit entry, webhook + // and calendar entry must not depend on where the task was started. + await this.auditLog.record({ + entityType: 'Task', + entityId: task.id, + action: 'CREATE', + userId: actingUserId, + afterData: task, + }); + await this.webhooks.dispatch('task.created', task); + if (task.dueDate) { + await this.calendarSync.enqueue('TASK', task.id); + } + return task; } } diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index 180af18..663aa26 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -14,6 +14,10 @@ import { UpdateQueenDto } from './dto/update-queen.dto'; import { AssignColonyDto } from './dto/assign-colony.dto'; import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto'; import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto'; +import { + TERMINAL_COLONY_STATUSES, + isLivingColonyStatus, +} from '../colonies/colony-status'; export interface QueenFilter { status?: QueenStatus; @@ -53,7 +57,6 @@ const QUEEN_NO_LONGER_AVAILABLE_STATUSES: QueenStatus[] = [ 'REPLACED', 'SOLD', ]; -const TERMINAL_COLONY_STATUSES = ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED']; @Injectable() export class QueensService { @@ -167,25 +170,56 @@ export class QueensService { if (!colony) { throw new NotFoundException('Colony not found'); } + if (!isLivingColonyStatus(colony.status)) { + throw new ConflictException( + `Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`, + ); + } + if (colony.currentQueenId === queen.id) { + throw new ConflictException('Die Königin sitzt bereits in diesem Volk'); + } const history = await this.prisma.$transaction(async (tx) => { - if (colony.currentQueenId && colony.currentQueenId !== queen.id) { + const now = new Date(); + if (colony.currentQueenId) { await tx.queenColonyHistory.updateMany({ where: { queenId: colony.currentQueenId, colonyId: colony.id, endDate: null, }, - data: { endDate: new Date() }, + data: { endDate: now }, + }); + } + // The colony the queen leaves must let go of her - currentQueenId is + // unique, and a stale pointer would both block this assignment and + // keep showing her in a colony she is no longer in. + const previousColony = await tx.colony.findUnique({ + where: { currentQueenId: queen.id }, + }); + if (previousColony) { + await tx.colony.update({ + where: { id: previousColony.id }, + data: { + currentQueenId: null, + status: TERMINAL_COLONY_STATUSES.includes(previousColony.status) + ? undefined + : 'QUEENLESS', + }, }); } await tx.queenColonyHistory.updateMany({ where: { queenId: queen.id, endDate: null }, - data: { endDate: new Date() }, + data: { endDate: now }, }); await tx.colony.update({ where: { id: colony.id }, - data: { currentQueenId: queen.id }, + data: { + currentQueenId: queen.id, + // Receiving a queen is what ends queenlessness; every other status + // (weak, conspicuous) describes something a queen does not fix. + status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined, + }, }); return tx.queenColonyHistory.create({ data: { diff --git a/ApiCore-Backend/src/storage/storage.controller.ts b/ApiCore-Backend/src/storage/storage.controller.ts index 46367f4..adbf50a 100644 --- a/ApiCore-Backend/src/storage/storage.controller.ts +++ b/ApiCore-Backend/src/storage/storage.controller.ts @@ -9,6 +9,7 @@ import { CreateStockItemDto } from './dto/create-stock-item.dto'; import { UpdateStockItemDto } from './dto/update-stock-item.dto'; import { CreateStockMovementDto } from './dto/create-stock-movement.dto'; import { StorageService } from './storage.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('storage') @ApiBearerAuth() @@ -73,6 +74,7 @@ export class StorageController { } @RequirePermission('storage:move') + @Idempotent() @Post('stock-items/:id/movements') addMovement( @Param('id') id: string, diff --git a/ApiCore-Backend/src/storage/storage.service.spec.ts b/ApiCore-Backend/src/storage/storage.service.spec.ts index a13e599..fb988d3 100644 --- a/ApiCore-Backend/src/storage/storage.service.spec.ts +++ b/ApiCore-Backend/src/storage/storage.service.spec.ts @@ -19,7 +19,7 @@ describe('StorageService', () => { let auditLog: { record: jest.Mock }; let tx: { stockMovement: { create: jest.Mock }; - stockItem: { update: jest.Mock }; + stockItem: { update: jest.Mock; updateMany: jest.Mock }; }; const stockItem = { id: 'item-1', name: 'Zuckerlösung', quantity: 10 }; @@ -33,7 +33,10 @@ describe('StorageService', () => { beforeEach(() => { tx = { stockMovement: { create: jest.fn().mockResolvedValue(movement) }, - stockItem: { update: jest.fn() }, + stockItem: { + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, }; prisma = { storageContainer: { findMany: jest.fn(), create: jest.fn() }, @@ -83,10 +86,21 @@ describe('StorageService', () => { await service.addMovement('item-1', { type: 'OUT', quantity: 3 }, 'user-1'); - const [[updateArg]] = tx.stockItem.update.mock.calls as [ - [{ data: { quantity: { increment: number } } }], - ]; - expect(updateArg.data.quantity.increment).toBe(-3); + // Conditional decrement: only books when at least 3 are in stock. + expect(tx.stockItem.updateMany).toHaveBeenCalledWith({ + where: { id: 'item-1', quantity: { gte: 3 } }, + data: { quantity: { increment: -3 } }, + }); + }); + + it('addMovement refuses an OUT movement larger than the stock', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + tx.stockItem.updateMany.mockResolvedValue({ count: 0 }); + + await expect( + service.addMovement('item-1', { type: 'OUT', quantity: 30 }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.stockMovement.create).not.toHaveBeenCalled(); }); it('addMovement leaves quantity untouched for a TRANSFER movement', async () => { @@ -127,10 +141,10 @@ describe('StorageService', () => { 'user-1', ); - const [[updateArg]] = tx.stockItem.update.mock.calls as [ - [{ data: { quantity: { increment: number } } }], - ]; - expect(updateArg.data.quantity.increment).toBe(-3); + expect(tx.stockItem.updateMany).toHaveBeenCalledWith({ + where: { id: 'item-1', quantity: { gte: 3 } }, + data: { quantity: { increment: -3 } }, + }); }); it('books the difference as an ADJUSTMENT movement when the stock quantity is corrected', async () => { diff --git a/ApiCore-Backend/src/storage/storage.service.ts b/ApiCore-Backend/src/storage/storage.service.ts index 0cfec3f..474e526 100644 --- a/ApiCore-Backend/src/storage/storage.service.ts +++ b/ApiCore-Backend/src/storage/storage.service.ts @@ -165,16 +165,28 @@ export class StorageService { : 0; const movement = await this.prisma.$transaction(async (tx) => { - const created = await tx.stockMovement.create({ - data: { ...dto, stockItemId: id, userId: actingUserId }, - }); - if (delta !== 0) { + if (delta < 0) { + // Conditional decrement: the database decides whether enough is in + // stock, so two removals at the same time cannot both pass a check + // made on an older value and push the stock below zero. + const booked = await tx.stockItem.updateMany({ + where: { id, quantity: { gte: -delta } }, + data: { quantity: { increment: delta } }, + }); + if (booked.count === 0) { + throw new BadRequestException( + 'Nicht genug Bestand für diese Entnahme - bitte Menge prüfen oder den Bestand zuerst korrigieren', + ); + } + } else if (delta > 0) { await tx.stockItem.update({ where: { id }, data: { quantity: { increment: delta } }, }); } - return created; + return tx.stockMovement.create({ + data: { ...dto, stockItemId: id, userId: actingUserId }, + }); }); await this.auditLog.record({ entityType: 'StockMovement', diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts b/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts index 0bf6e2f..df823cb 100644 --- a/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts +++ b/ApiCore-Backend/src/swarm-control/swarm-control.controller.ts @@ -6,6 +6,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto'; import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto'; import { SwarmControlService } from './swarm-control.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('swarm-control-checks') @ApiBearerAuth() @@ -26,6 +27,7 @@ export class SwarmControlController { } @RequirePermission('swarm-control:create') + @Idempotent() @Post() create( @Body() dto: CreateSwarmControlCheckDto, diff --git a/ApiCore-Backend/src/task-templates/task-templates.service.ts b/ApiCore-Backend/src/task-templates/task-templates.service.ts index 52f35c5..21bad39 100644 --- a/ApiCore-Backend/src/task-templates/task-templates.service.ts +++ b/ApiCore-Backend/src/task-templates/task-templates.service.ts @@ -130,10 +130,14 @@ export class TaskTemplatesService { } /** - * Lazily catches up any template whose nextDueDate has passed: creates the - * Task and atomically advances nextDueDate. The update's WHERE clause - * matches the previous nextDueDate (compare-and-swap), so concurrent calls - * from multiple API replicas cannot double-fire the same occurrence. + * Lazily catches up any template whose nextDueDate has passed: creates ONE + * task for the most recent missed occurrence (due on that date) and moves + * nextDueDate past today. A weekly template nobody looked at for two months + * yields one reminder, not eight stale duplicates. + * + * The update's WHERE clause matches the previous nextDueDate + * (compare-and-swap), so concurrent calls from multiple API replicas cannot + * double-fire the same occurrence. */ async generateDueTasks(): Promise { const now = new Date(); @@ -142,7 +146,13 @@ export class TaskTemplatesService { }); for (const template of dueTemplates) { - const nextDueDate = computeNextDueDate(template, template.nextDueDate); + let occurrence = template.nextDueDate; + let nextDueDate = computeNextDueDate(template, occurrence); + // Bounded: even a daily template several years behind stays cheap. + for (let step = 0; step < 5000 && nextDueDate <= now; step++) { + occurrence = nextDueDate; + nextDueDate = computeNextDueDate(template, occurrence); + } const claim = await this.prisma.taskTemplate.updateMany({ where: { id: template.id, nextDueDate: template.nextDueDate }, data: { nextDueDate }, @@ -156,6 +166,7 @@ export class TaskTemplatesService { title: template.title, description: template.description, priority: template.priority, + dueDate: occurrence, assignedUserId: template.assignedUserId, relatedType: 'GENERAL', sourceTemplateId: template.id, diff --git a/ApiCore-Backend/src/tasks/tasks.controller.ts b/ApiCore-Backend/src/tasks/tasks.controller.ts index e59f0a2..8abf499 100644 --- a/ApiCore-Backend/src/tasks/tasks.controller.ts +++ b/ApiCore-Backend/src/tasks/tasks.controller.ts @@ -15,6 +15,7 @@ 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'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('tasks') @ApiBearerAuth() @@ -38,6 +39,7 @@ export class TasksController { } @RequirePermission('tasks:create') + @Idempotent() @Post() create(@Body() dto: CreateTaskDto, @CurrentUser() actor: AuthenticatedUser) { return this.tasksService.create(dto, actor.id); diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts index a509ad7..5536f57 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -8,6 +8,21 @@ import { Task, TaskPriority, TaskStatus } from '../../generated/prisma'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; +/** + * Keeps completedAt in step with status when the status is edited directly: + * set when a task becomes DONE, cleared when it is reopened or cancelled - + * a reopened task must not keep claiming it was finished. + */ +function completedAtForStatusChange( + before: Task, + nextStatus: TaskStatus | undefined, +): Date | null | undefined { + if (nextStatus === undefined || nextStatus === before.status) { + return undefined; + } + return nextStatus === 'DONE' ? new Date() : null; +} + export interface TaskFilter { status?: TaskStatus; priority?: TaskPriority; @@ -72,6 +87,7 @@ export class TasksService { data: { ...dto, dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, + completedAt: completedAtForStatusChange(before, dto.status), }, }); await this.auditLog.record({ @@ -92,6 +108,11 @@ export class TasksService { async complete(id: string, actingUserId: string): Promise { const before = await this.findById(id); + // A second tap on "Erledigt" (or a retried request) must not move the + // completion time or announce the completion a second time. + if (before.status === 'DONE') { + return before; + } const task = await this.prisma.task.update({ where: { id }, data: { status: 'DONE', completedAt: new Date() }, diff --git a/ApiCore-Backend/src/treatments/treatments.controller.ts b/ApiCore-Backend/src/treatments/treatments.controller.ts index 6ee3e5e..e6b81a2 100644 --- a/ApiCore-Backend/src/treatments/treatments.controller.ts +++ b/ApiCore-Backend/src/treatments/treatments.controller.ts @@ -14,6 +14,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto'; import { TreatmentsService } from './treatments.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('treatments') @ApiBearerAuth() @@ -41,6 +42,7 @@ export class TreatmentsController { } @RequirePermission('treatments:create') + @Idempotent() @Post() create( @Body() dto: CreateTreatmentDto, diff --git a/ApiCore-Backend/src/varroa/varroa.controller.ts b/ApiCore-Backend/src/varroa/varroa.controller.ts index 33de274..0332f93 100644 --- a/ApiCore-Backend/src/varroa/varroa.controller.ts +++ b/ApiCore-Backend/src/varroa/varroa.controller.ts @@ -6,6 +6,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; import { VarroaService } from './varroa.service'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('varroa-measurements') @ApiBearerAuth() @@ -26,6 +27,7 @@ export class VarroaController { } @RequirePermission('varroa:create') + @Idempotent() @Post() create( @Body() dto: CreateVarroaMeasurementDto, diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts index 3486e5a..9532d6f 100644 --- a/ApiCore-Backend/test/automation.e2e-spec.ts +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; +import { AutomationTriggerType } from '../generated/prisma'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; @@ -96,18 +97,16 @@ describe('Automation rules (e2e)', () => { .expect(403); }); - it('seeds the three fixed triggers for an admin', async () => { + it('seeds every fixed trigger for an admin', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/automation-rules') .set('Authorization', `Bearer ${adminToken}`) .expect(200); const rules = response.body as AutomationRuleBody[]; const triggerTypes = rules.map((rule) => rule.triggerType).sort(); - expect(triggerTypes).toEqual([ - 'FEED_STATUS_CRITICAL', - 'QUEENLESS_NO_EGGS', - 'VARROA_THRESHOLD', - ]); + expect(triggerTypes).toEqual( + [...Object.values(AutomationTriggerType)].sort(), + ); const varroaRule = rules.find( (rule) => rule.triggerType === 'VARROA_THRESHOLD', ); @@ -184,6 +183,8 @@ describe('Automation rules (e2e)', () => { date: new Date().toISOString(), method: 'NATURAL_MITE_FALL', miteCount: 20, + // One day on the board: 20 mites per day, above the default 10. + durationMinutes: 24 * 60, }) .expect(201); @@ -194,12 +195,35 @@ describe('Automation rules (e2e)', () => { expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true); }); + it('does not compare a sample-based count against the per-day threshold', async () => { + const before = await prisma.task.count({ where: { relatedId: colonyId } }); + await request(app.getHttpServer()) + .post('/api/v1/varroa-measurements') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + method: 'POWDERED_SUGAR', + miteCount: 50, + }) + .expect(201); + expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( + before, + ); + }); + it('lets an admin deactivate a rule, preventing further triggers', async () => { await request(app.getHttpServer()) .patch(`/api/v1/automation-rules/${varroaRuleId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ active: false }) .expect(200); + // Close the earlier tasks so the one-open-task-per-rule guard cannot be + // what keeps a new task from appearing - only the deactivation may. + await prisma.task.updateMany({ + where: { relatedId: colonyId }, + data: { status: 'DONE' }, + }); await request(app.getHttpServer()) .post('/api/v1/varroa-measurements') @@ -209,6 +233,7 @@ describe('Automation rules (e2e)', () => { date: new Date().toISOString(), method: 'NATURAL_MITE_FALL', miteCount: 30, + durationMinutes: 24 * 60, }) .expect(201); diff --git a/ApiCore-Backend/test/data-integrity.e2e-spec.ts b/ApiCore-Backend/test/data-integrity.e2e-spec.ts new file mode 100644 index 0000000..307a08d --- /dev/null +++ b/ApiCore-Backend/test/data-integrity.e2e-spec.ts @@ -0,0 +1,490 @@ +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'; + +/** + * Regression tests for data-integrity rules that cut across modules: + * repeated requests, concurrent quantity bookings, colony/queen state + * transitions and duplicate automation tasks. + */ +describe('Data integrity (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const email = `integrity-e2e-${suffix}@example.com`; + const password = 'SuperSecret123!'; + let token: string; + let userId: string; + let apiaryId: string; + + const server = () => app.getHttpServer(); + const auth = () => ({ Authorization: `Bearer ${token}` }); + + async function createColony( + code: string, + status: 'ACTIVE' | 'QUEENLESS' | 'WEAK' = 'ACTIVE', + ) { + return prisma.colony.create({ + data: { + code: `${code}-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + status, + }, + }); + } + + async function createHoneyBatch(amount: number) { + const extraction = await prisma.extraction.create({ + data: { date: new Date(), userId }, + }); + return prisma.honeyBatch.create({ + data: { + batchNumber: `HB-${suffix}-${Math.round(Math.random() * 1e9)}`, + extractionId: extraction.id, + amount, + }, + }); + } + + function bottle(honeyBatchId: string, jarCount: number) { + return request(server()) + .post('/api/v1/bottling-batches') + .set(auth()) + .send({ + honeyBatchId, + date: '2026-08-01T00:00:00.000Z', + bestBeforeDate: '2028-08-01T00:00:00.000Z', + jarSize: 500, + jarCount, + lotNumber: `L-${suffix}-${Math.round(Math.random() * 1e9)}`, + }); + } + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: 'Integrity', + role: { connect: { key: 'ADMIN' } }, + }, + }); + userId = user.id; + token = (await login(app, email, password)).accessToken; + apiaryId = ( + await prisma.apiary.create({ data: { name: `Integrity ${suffix}` } }) + ).id; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Idempotency-Key', () => { + it('stores a repeated inspection only once and replays the first response', async () => { + const colony = await createColony('IDEM'); + const key = `idem-${suffix}-inspection-0001`; + const body = { + colonyId: colony.id, + date: '2026-07-01T00:00:00.000Z', + queenSeen: false, + eggsPresent: false, + }; + + const first = await request(server()) + .post('/api/v1/inspections') + .set(auth()) + .set('Idempotency-Key', key) + .send(body) + .expect(201); + const second = await request(server()) + .post('/api/v1/inspections') + .set(auth()) + .set('Idempotency-Key', key) + .send(body) + .expect(201); + + expect((second.body as { id: string }).id).toBe( + (first.body as { id: string }).id, + ); + expect( + await prisma.inspection.count({ where: { colonyId: colony.id } }), + ).toBe(1); + // The automation fired once, not once per delivery attempt. + expect( + await prisma.task.count({ + where: { + relatedId: colony.id, + sourceAutomationTrigger: { not: null }, + }, + }), + ).toBe(1); + }); + + it('rejects a key reused for a different endpoint', async () => { + const colony = await createColony('IDEM2'); + const key = `idem-${suffix}-crossuse-0001`; + await request(server()) + .post('/api/v1/inspections') + .set(auth()) + .set('Idempotency-Key', key) + .send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z' }) + .expect(201); + await request(server()) + .post('/api/v1/feedings') + .set(auth()) + .set('Idempotency-Key', key) + .send({ + colonyId: colony.id, + date: '2026-07-01T00:00:00.000Z', + feedType: 'SUGAR_WATER', + amount: 2, + unit: 'l', + }) + .expect(409); + }); + + it('ignores the header on endpoints that did not opt in, storing nothing', async () => { + const key = `idem-${suffix}-not-opted-in`; + await request(server()) + .post('/api/v1/apiaries') + .set(auth()) + .set('Idempotency-Key', key) + .send({ name: `Opt-out ${suffix}` }) + .expect(201); + expect( + await prisma.idempotencyRecord.count({ where: { userId, key } }), + ).toBe(0); + }); + + it('rejects a malformed key', () => { + return request(server()) + .post('/api/v1/inspections') + .set(auth()) + .set('Idempotency-Key', 'short') + .send({}) + .expect(400); + }); + + it('releases the key when the request failed, so a corrected retry works', async () => { + const colony = await createColony('IDEM3'); + const key = `idem-${suffix}-retry-00001`; + await request(server()) + .post('/api/v1/inspections') + .set(auth()) + .set('Idempotency-Key', key) + .send({ colonyId: colony.id }) + .expect(400); + await request(server()) + .post('/api/v1/inspections') + .set(auth()) + .set('Idempotency-Key', key) + .send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z' }) + .expect(201); + }); + }); + + describe('Bottling quantities', () => { + it('never over-books a honey batch under concurrent bottlings', async () => { + const honeyBatch = await createHoneyBatch(10); + // Each takes 6 kg of 10 kg - only one of them may succeed. + const results = await Promise.all([ + bottle(honeyBatch.id, 12), + bottle(honeyBatch.id, 12), + ]); + const statuses = results.map((r) => r.status).sort(); + expect(statuses).toEqual([201, 400]); + const after = await prisma.honeyBatch.findUniqueOrThrow({ + where: { id: honeyBatch.id }, + }); + expect(after.amount).toBeCloseTo(4, 3); + }); + + it('gives the amount back when a bottling is removed', async () => { + const honeyBatch = await createHoneyBatch(5); + const created = await bottle(honeyBatch.id, 3).expect(201); + await request(server()) + .delete( + `/api/v1/bottling-batches/${(created.body as { id: string }).id}`, + ) + .set(auth()) + .expect(200); + const after = await prisma.honeyBatch.findUniqueOrThrow({ + where: { id: honeyBatch.id }, + }); + expect(after.amount).toBeCloseTo(5, 3); + }); + }); + + describe('Stock movements', () => { + it('refuses an outgoing movement larger than the stock', async () => { + const item = await prisma.stockItem.create({ + data: { + name: `Gläser ${suffix}`, + category: 'CONSUMABLE', + unit: 'Stk', + quantity: 5, + }, + }); + await request(server()) + .post(`/api/v1/stock-items/${item.id}/movements`) + .set(auth()) + .send({ type: 'OUT', quantity: 6 }) + .expect(400); + const after = await prisma.stockItem.findUniqueOrThrow({ + where: { id: item.id }, + }); + expect(after.quantity).toBe(5); + }); + + it('creates only one open reorder task while the item stays below minimum', async () => { + const item = await prisma.stockItem.create({ + data: { + name: `Deckel ${suffix}`, + category: 'CONSUMABLE', + unit: 'Stk', + quantity: 10, + minThreshold: 8, + }, + }); + for (const quantity of [3, 1, 1]) { + await request(server()) + .post(`/api/v1/stock-items/${item.id}/movements`) + .set(auth()) + .send({ type: 'OUT', quantity }) + .expect(201); + } + expect( + await prisma.task.count({ + where: { + relatedId: item.id, + sourceAutomationTrigger: 'STOCK_ITEM_LOW', + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + }), + ).toBe(1); + }); + }); + + describe('Colony transitions', () => { + it('lets a queenless colony be united with another colony', async () => { + const survivor = await createColony('SURV'); + const queenless = await createColony('QL', 'QUEENLESS'); + await request(server()) + .post(`/api/v1/colonies/${survivor.id}/merge`) + .set(auth()) + .send({ mergedColonyIds: [queenless.id] }) + .expect(201); + const merged = await prisma.colony.findUniqueOrThrow({ + where: { id: queenless.id }, + }); + expect(merged.status).toBe('MERGED'); + }); + + it('lets a weak colony be moved to another apiary', async () => { + const weak = await createColony('WEAK', 'WEAK'); + const otherApiary = await prisma.apiary.create({ + data: { name: `Other ${suffix}` }, + }); + await request(server()) + .post(`/api/v1/colonies/${weak.id}/move`) + .set(auth()) + .send({ apiaryId: otherApiary.id }) + .expect(201); + }); + + it('refuses to merge a colony into itself', async () => { + const colony = await createColony('SELF'); + await request(server()) + .post(`/api/v1/colonies/${colony.id}/merge`) + .set(auth()) + .send({ mergedColonyIds: [colony.id] }) + .expect(400); + const after = await prisma.colony.findUniqueOrThrow({ + where: { id: colony.id }, + }); + expect(after.status).toBe('ACTIVE'); + }); + + it('refuses to dissolve a colony that is already dissolved', async () => { + const colony = await createColony('DISS'); + await request(server()) + .post(`/api/v1/colonies/${colony.id}/dissolve`) + .set(auth()) + .expect(201); + await request(server()) + .post(`/api/v1/colonies/${colony.id}/dissolve`) + .set(auth()) + .expect(409); + }); + }); + + describe('Queen assignment', () => { + it('moves a queen between colonies and leaves the old colony queenless', async () => { + const colonyA = await createColony('QA'); + const colonyB = await createColony('QB', 'QUEENLESS'); + const queen = await prisma.queen.create({ + data: { code: `Q-${suffix}` }, + }); + + await request(server()) + .post(`/api/v1/queens/${queen.id}/assign-colony`) + .set(auth()) + .send({ colonyId: colonyA.id }) + .expect(201); + await request(server()) + .post(`/api/v1/queens/${queen.id}/assign-colony`) + .set(auth()) + .send({ colonyId: colonyB.id }) + .expect(201); + + const a = await prisma.colony.findUniqueOrThrow({ + where: { id: colonyA.id }, + }); + const b = await prisma.colony.findUniqueOrThrow({ + where: { id: colonyB.id }, + }); + expect(a.currentQueenId).toBeNull(); + expect(a.status).toBe('QUEENLESS'); + expect(b.currentQueenId).toBe(queen.id); + expect(b.status).toBe('ACTIVE'); + const openHistory = await prisma.queenColonyHistory.findMany({ + where: { queenId: queen.id, endDate: null }, + }); + expect(openHistory.map((h) => h.colonyId)).toEqual([colonyB.id]); + }); + + it('refuses to assign a queen to a dissolved colony', async () => { + const colony = await createColony('QD'); + await prisma.colony.update({ + where: { id: colony.id }, + data: { status: 'DISSOLVED' }, + }); + const queen = await prisma.queen.create({ + data: { code: `QD-${suffix}` }, + }); + await request(server()) + .post(`/api/v1/queens/${queen.id}/assign-colony`) + .set(auth()) + .send({ colonyId: colony.id }) + .expect(409); + }); + }); + + describe('Tasks', () => { + it('keeps the first completion when a task is completed twice', async () => { + const created = await request(server()) + .post('/api/v1/tasks') + .set(auth()) + .send({ title: `Doppelklick ${suffix}` }) + .expect(201); + const id = (created.body as { id: string }).id; + const first = await request(server()) + .post(`/api/v1/tasks/${id}/complete`) + .set(auth()) + .expect(201); + const second = await request(server()) + .post(`/api/v1/tasks/${id}/complete`) + .set(auth()) + .expect(201); + expect((second.body as { completedAt: string }).completedAt).toBe( + (first.body as { completedAt: string }).completedAt, + ); + }); + + it('clears completedAt when a done task is reopened', async () => { + const created = await request(server()) + .post('/api/v1/tasks') + .set(auth()) + .send({ title: `Wieder offen ${suffix}` }) + .expect(201); + const id = (created.body as { id: string }).id; + await request(server()) + .post(`/api/v1/tasks/${id}/complete`) + .set(auth()) + .expect(201); + const reopened = await request(server()) + .patch(`/api/v1/tasks/${id}`) + .set(auth()) + .send({ status: 'OPEN' }) + .expect(200); + expect( + (reopened.body as { completedAt: string | null }).completedAt, + ).toBeNull(); + }); + + it('gives a task generated from a recurring template its due date', async () => { + const dueDate = new Date('2026-01-15T00:00:00.000Z'); + const template = await prisma.taskTemplate.create({ + data: { + title: `Wiederkehrend ${suffix}`, + recurrenceType: 'INTERVAL_DAYS', + intervalDays: 7, + nextDueDate: dueDate, + }, + }); + await request(server()).get('/api/v1/tasks').set(auth()).expect(200); + const generated = await prisma.task.findMany({ + where: { sourceTemplateId: template.id }, + }); + // Months of missed weekly occurrences collapse into one reminder + // instead of a pile of stale duplicates. + expect(generated).toHaveLength(1); + expect(generated[0].dueDate).not.toBeNull(); + const refreshed = await prisma.taskTemplate.findUniqueOrThrow({ + where: { id: template.id }, + }); + expect(refreshed.nextDueDate.getTime()).toBeGreaterThan(Date.now()); + }); + }); + + describe('Input plausibility', () => { + it('rejects an inspection rating outside the 1–5 scale', async () => { + const colony = await createColony('SCALE'); + await request(server()) + .post('/api/v1/inspections') + .set(auth()) + .send({ + colonyId: colony.id, + date: '2026-07-01T00:00:00.000Z', + gentleness: 9, + }) + .expect(400); + }); + + it('rejects a harvest whose net weight exceeds its gross weight', async () => { + const colony = await createColony('HW'); + await request(server()) + .post('/api/v1/harvests') + .set(auth()) + .send({ + apiaryId, + colonyIds: [colony.id], + date: '2026-07-01T00:00:00.000Z', + grossWeight: 20, + netWeight: 25, + }) + .expect(400); + }); + + it('rejects a harvest listing the same colony twice', async () => { + const colony = await createColony('HD'); + await request(server()) + .post('/api/v1/harvests') + .set(auth()) + .send({ + apiaryId, + colonyIds: [colony.id, colony.id], + date: '2026-07-01T00:00:00.000Z', + }) + .expect(400); + }); + }); +}); diff --git a/ApiCore-Backend/test/documents.e2e-spec.ts b/ApiCore-Backend/test/documents.e2e-spec.ts index 5ee872e..97cca9a 100644 --- a/ApiCore-Backend/test/documents.e2e-spec.ts +++ b/ApiCore-Backend/test/documents.e2e-spec.ts @@ -7,6 +7,13 @@ import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +// The upload checks the file's magic bytes against its declared type, so +// the fixture must start with a real PNG signature. +const PNG_BYTES = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from('test image payload'), +]); + interface DocumentBody { id: string; entityType: string; @@ -96,7 +103,7 @@ describe('Documents (e2e)', () => { .set('Authorization', `Bearer ${readonlyToken}`) .field('entityType', 'COLONY') .field('entityId', colonyId) - .attach('file', Buffer.from('fake image bytes'), { + .attach('file', PNG_BYTES, { filename: 'bild.png', contentType: 'image/png', }) @@ -122,7 +129,7 @@ describe('Documents (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .field('entityType', 'COLONY') .field('entityId', 'does-not-exist') - .attach('file', Buffer.from('fake image bytes'), { + .attach('file', PNG_BYTES, { filename: 'bild.png', contentType: 'image/png', }) @@ -135,7 +142,7 @@ describe('Documents (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .field('entityType', 'COLONY') .field('entityId', colonyId) - .attach('file', Buffer.from('fake image bytes'), { + .attach('file', PNG_BYTES, { filename: 'bild.png', contentType: 'image/png', }) @@ -166,7 +173,7 @@ describe('Documents (e2e)', () => { expect(response.headers['content-type']).toContain('image/png'); expect(response.headers['content-disposition']).toContain('bild.png'); const downloaded = response.body as Buffer; - expect(downloaded.toString('utf-8')).toBe('fake image bytes'); + expect(Buffer.compare(downloaded, PNG_BYTES)).toBe(0); }); it('rejects a delete from a role without write access', () => { diff --git a/ApiCore-Backend/test/roles.e2e-spec.ts b/ApiCore-Backend/test/roles.e2e-spec.ts index 04adfbb..0dba6b4 100644 --- a/ApiCore-Backend/test/roles.e2e-spec.ts +++ b/ApiCore-Backend/test/roles.e2e-spec.ts @@ -5,6 +5,7 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog'; interface RoleBody { id: string; @@ -115,7 +116,7 @@ describe('Roles (e2e)', () => { ); const admin = roles.find((role) => role.key === 'ADMIN'); expect(admin?.isSystem).toBe(true); - expect(admin?.permissionKeys.length).toBe(83); + expect(admin?.permissionKeys.length).toBe(PERMISSION_CATALOG.length); }); it('exposes the full permission catalog', async () => { @@ -123,7 +124,7 @@ describe('Roles (e2e)', () => { .get('/api/v1/roles/permission-catalog') .set('Authorization', `Bearer ${adminToken}`) .expect(200); - expect((response.body as unknown[]).length).toBe(83); + expect((response.body as unknown[]).length).toBe(PERMISSION_CATALOG.length); }); it('lets an admin create a role with a chosen permission set', async () => { @@ -221,7 +222,7 @@ describe('Roles (e2e)', () => { .expect(200); const adminProfile = adminMe.body as MeBody; expect(adminProfile.roleKey).toBe('ADMIN'); - expect(adminProfile.permissions.length).toBe(83); + expect(adminProfile.permissions.length).toBe(PERMISSION_CATALOG.length); const staffMe = await request(app.getHttpServer()) .get('/api/v1/auth/me')