fix: keep quantities, colony/queen states and tasks consistent under retries
Data-integrity fixes found in a systematic review of ApiCore: - Bottling booked the honey batch amount from a value read outside the transaction and wrote an absolute result back, so two concurrent bottlings (double tap, two devices) could both pass the check and one deduction was lost. Bookings now use a conditional UPDATE inside the transaction; corrections and deletions give amounts back atomically. - Outgoing stock movements could drive stock below zero; the removal is now a conditional decrement that refuses when stock is insufficient. - Weak, conspicuous and queenless colonies were treated as "not active" and could not be moved, split or merged - uniting a queenless colony is the textbook merge. Only terminal statuses lock a colony now; merging a colony into itself and dissolving twice are rejected. - Moving a queen to another colony failed on the unique currentQueenId because the old colony kept pointing at her. The old colony now releases her (and becomes QUEENLESS); a queenless colony receiving a queen becomes ACTIVE; dissolved colonies cannot receive a queen. - Completing a task twice moved completedAt and re-sent the webhook; reopening a task kept completedAt. Recurring templates created tasks without a due date and caught up missed weeks one stale task at a time. - Automation rules created a new task and warning on every trigger (every stock removal below minimum). Tasks now record their source trigger and at most one open task per rule and object exists. The varroa threshold (mites per day) is only compared with natural-mite-fall rates, not with sample-based counts in another unit. - New opt-in Idempotency-Key support for record-creating endpoints, so the offline queue can retry after a lost response without duplicates. - Permission catalog sync crashed replicas booting concurrently on a fresh database (unique violation); it now uses createMany/skipDuplicates. - Server-side validation for inspection rating scales (1-5), unique colony/harvest lists and net weight <= gross weight. - Stale e2e expectations updated (permission count, trigger list, real PNG fixture for the file-signature check). Migration is additive only (nullable Task column, new table). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
786d929df9
commit
5d11756a87
37 files changed
+1420
-164
No files matched your search
+29
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,27 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, { amount: number | null; status?: string }>;
|
||||
|
||||
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',
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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({
|
||||
|
||||
@@ -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<Colony> {
|
||||
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<Colony> {
|
||||
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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Observable<unknown>> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<RequestWithUser>();
|
||||
const rawKey = request.headers[IDEMPOTENCY_HEADER];
|
||||
const userId = request.user?.id;
|
||||
const optedIn = this.reflector.get<boolean | undefined>(
|
||||
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<Response>().status(existing.statusCode ?? 201);
|
||||
return of(existing.responseBody);
|
||||
}
|
||||
|
||||
return next.handle().pipe(
|
||||
mergeMap((body: unknown) =>
|
||||
from(this.complete(userId, key, http.getResponse<Response>(), body)),
|
||||
),
|
||||
catchError((error: unknown) =>
|
||||
from(this.release(userId, key)).pipe(
|
||||
mergeMap(() => throwError(() => error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async claim(
|
||||
userId: string,
|
||||
key: string,
|
||||
scope: string,
|
||||
): Promise<boolean> {
|
||||
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<unknown> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,7 +80,7 @@ export class InspectionsService {
|
||||
actingUserId: string,
|
||||
): Promise<Task> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Task> {
|
||||
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() },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<App>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user