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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user