feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s

- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 14:18:41 +02:00
1 parent c18860c75c
commit edc193f128
73 files changed
+9470 -171

No files matched your search

@@ -1,8 +1,12 @@
import { Module } from '@nestjs/common';
import { ApiaryRestrictionsModule } from '../apiary-restrictions/apiary-restrictions.module';
import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service';
// ApiaryRestrictionsModule: the restricted-zone endpoints ride along with the
// colony paths that enforce them (AppModule imports ColoniesModule).
@Module({
imports: [ApiaryRestrictionsModule],
controllers: [ColoniesController],
providers: [ColoniesService],
exports: [ColoniesService],
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { berlinToday } from '../common/dates';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
@@ -16,6 +17,8 @@ describe('ColoniesService', () => {
colonyLineage: { create: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock };
suggestion: { updateMany: jest.Mock };
apiary: { findUnique: jest.Mock };
apiaryRestriction: { findMany: jest.Mock };
};
let prisma: {
colony: {
@@ -32,6 +35,7 @@ describe('ColoniesService', () => {
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock; recordAll: jest.Mock };
let resolver: { getPermissions: jest.Mock };
const parentColony = {
id: 'colony-1',
@@ -49,10 +53,12 @@ describe('ColoniesService', () => {
colonyLineage: { create: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() },
suggestion: { updateMany: jest.fn() },
apiary: { findUnique: jest.fn() },
apiaryRestriction: { findMany: jest.fn().mockResolvedValue([]) },
};
prisma = {
colony: {
findMany: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
@@ -69,6 +75,10 @@ describe('ColoniesService', () => {
callback(tx),
),
};
// The apiary is read inside the transaction now: same answers as before.
tx.apiary.findUnique.mockImplementation(
(args: unknown) => prisma.apiary.findUnique(args) as Promise<unknown>,
);
// The locked re-read sees what the pre-read saw unless a test says otherwise.
tx.colony.findUnique.mockImplementation(
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
@@ -79,11 +89,17 @@ describe('ColoniesService', () => {
record: jest.fn().mockResolvedValue(undefined),
recordAll: jest.fn().mockResolvedValue(0),
};
resolver = {
getPermissions: jest
.fn()
.mockResolvedValue(new Set(['compliance:manage'])),
};
service = new ColoniesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
events as unknown as DomainEventsService,
resolver as unknown as PermissionResolverService,
);
});
@@ -237,17 +253,23 @@ describe('ColoniesService', () => {
});
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
.mockResolvedValueOnce({
...parentColony,
id: 'colony-1',
});
tx.colony.findUnique.mockResolvedValue({
id: 'colony-3',
status: 'ACTIVE',
currentQueenId: 'queen-2',
});
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: 'apiary-1' },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? parentColony
: {
id: 'colony-3',
apiaryId: 'apiary-1',
status: 'ACTIVE',
currentQueenId: 'queen-2',
},
),
);
tx.colony.update.mockResolvedValue({
id: 'colony-3',
status: 'MERGED',
@@ -725,10 +747,15 @@ describe('ColoniesService', () => {
it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: 'apiary-1' },
{ id: 'colony-4', apiaryId: 'apiary-1' },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({
id: where.id,
apiaryId: 'apiary-1',
status: 'ACTIVE',
healthStatus: null,
currentQueenId: null,
@@ -809,7 +836,8 @@ describe('ColoniesService', () => {
});
await service.reactivate('colony-1', 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
// update 1, move 2 (apiary share + colony), dissolve 1, reactivate 1.
expect(tx.$queryRaw).toHaveBeenCalledTimes(5);
});
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
@@ -860,9 +888,18 @@ describe('ColoniesService', () => {
it('locks every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: 'apiary-1' },
{ id: 'colony-4', apiaryId: 'apiary-1' },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }),
Promise.resolve({
id: where.id,
apiaryId: 'apiary-1',
status: 'ACTIVE',
healthStatus: null,
}),
);
tx.colony.update.mockImplementation(
({ where }: { where: { id: string } }) =>
@@ -875,10 +912,12 @@ describe('ColoniesService', () => {
'user-1',
);
// One multi-row statement, not one per colony.
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
// The apiary rows (share), then ONE multi-row statement for the colonies.
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
const [, [query]] = tx.$queryRaw.mock.calls as [unknown, [{ sql: string }]];
expect(query.sql).toContain(
'"Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
);
});
describe('a colony that ends through an edit', () => {
@@ -932,4 +971,521 @@ describe('ColoniesService', () => {
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
});
});
describe('restricted zone', () => {
const reason = 'Amtstierarzt hat die Verbringung genehmigt';
const reloadMessage = 'Der Stand wurde gerade geändert. Bitte neu laden.';
const startedLongAgo = new Date('2000-01-01T00:00:00.000Z');
// An open restriction on each of the given apiaries.
function restrict(...apiaryIds: string[]): void {
tx.apiaryRestriction.findMany.mockImplementation(
({ where }: { where: { apiaryId: { in: string[] } } }) =>
Promise.resolve(
apiaryIds
.filter((apiaryId) => where.apiaryId.in.includes(apiaryId))
.map((apiaryId) => ({
id: `r-${apiaryId}`,
apiaryId,
apiary: { name: `Stand ${apiaryId}` },
disease: 'Faulbrut',
startsOn: startedLongAgo,
endsOn: null,
})),
),
);
}
function lockSql(): string[] {
return (tx.$queryRaw.mock.calls as [{ sql: string }][]).map(
([query]) => query.sql,
);
}
function setupMove(): void {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
}
function setupMerge(mergedApiaryId = 'apiary-2'): void {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: mergedApiaryId },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? parentColony
: {
id: 'colony-3',
apiaryId: mergedApiaryId,
status: 'ACTIVE',
currentQueenId: null,
},
),
);
tx.colony.update.mockResolvedValue({
id: 'colony-3',
status: 'MERGED',
healthStatus: null,
});
}
const createDto = {
code: 'V-9',
apiaryId: 'apiary-1',
colonyType: 'PRODUCTION_COLONY' as const,
};
it('split inside the apiary is allowed', async () => {
restrict('apiary-1');
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({
id: 'colony-2',
apiaryId: 'apiary-1',
});
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it('create in a restricted apiary is refused', async () => {
restrict('apiary-1');
await expect(service.create(createDto, 'user-1')).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it.each([
['into', 'apiary-2'],
['out of', 'apiary-1'],
])('move %s a restricted apiary is refused', async (_way, restricted) => {
setupMove();
restrict(restricted);
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
it('move to the same apiary moves nothing and checks nothing', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
restrict('apiary-1');
await expect(
service.move('colony-1', { apiaryId: 'apiary-1' }, 'user-1'),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it.each([
['the old apiary', 'apiary-1'],
['the new apiary', 'apiary-2'],
])('update with a changed apiary checks %s', async (_which, restricted) => {
setupMove();
restrict(restricted);
await expect(
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('a name change checks nothing and takes no apiary lock', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
restrict('apiary-1', 'apiary-2');
await service.update('colony-1', { code: 'V-neu' }, 'user-1');
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
expect(lockSql().every((sql) => !sql.includes('"Apiary"'))).toBe(true);
});
it('merge across apiaries is refused when one is restricted', async () => {
setupMerge('apiary-2');
restrict('apiary-2');
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyLineage.create).not.toHaveBeenCalled();
});
it('merge within one apiary is allowed even in a restricted apiary', async () => {
setupMerge('apiary-1');
restrict('apiary-1');
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
const scenarios: Record<
string,
{
run: (restrictionOverrideReason: string) => Promise<unknown>;
setup: () => void;
restricted: string[];
}
> = {
create: {
setup: () => {
tx.colony.create.mockResolvedValue({
id: 'colony-9',
apiaryId: 'apiary-1',
});
},
restricted: ['apiary-1'],
run: (restrictionOverrideReason) =>
service.create({ ...createDto, restrictionOverrideReason }, 'user-1'),
},
update: {
setup: setupMove,
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.update(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason },
'user-1',
),
},
move: {
setup: setupMove,
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason },
'user-1',
),
},
merge: {
setup: () => setupMerge('apiary-2'),
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3'], restrictionOverrideReason },
'user-1',
),
},
};
it.each(Object.keys(scenarios))(
'the override reason is never part of the data written to the colony (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
restrict(...scenario.restricted);
await scenario.run(reason);
const written = JSON.stringify([
tx.colony.create.mock.calls,
tx.colony.update.mock.calls,
]);
expect(written).not.toContain(reason);
expect(written).not.toContain('restrictionOverrideReason');
},
);
it.each(Object.keys(scenarios))(
'an override writes the reason and the restriction ids into the audit entry (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
restrict(...scenario.restricted);
await scenario.run(reason);
const audits = (
auditLog.record.mock.calls as [
{ afterData: { restrictionOverride?: unknown } },
][]
).filter(([entry]) => entry.afterData.restrictionOverride);
expect(audits).toHaveLength(1);
expect(audits[0][0].afterData.restrictionOverride).toEqual({
reason,
restrictionIds: [`r-${scenario.restricted[0]}`],
});
expect(resolver.getPermissions).toHaveBeenCalledTimes(1);
},
);
it('an override writes the reason into ColonyApiaryHistory.notes, in front of an existing note', async () => {
setupMove();
restrict('apiary-2');
await service.move(
'colony-1',
{
apiaryId: 'apiary-2',
notes: 'Wanderung',
restrictionOverrideReason: reason,
},
'user-1',
);
await service.update(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
);
const notes = (
tx.colonyApiaryHistory.create.mock.calls as [
{ data: { notes: string } },
][]
).map(([call]) => call.data.notes);
expect(notes).toEqual([
`Ausnahme Sperrbezirk: ${reason}\nWanderung`,
`Ausnahme Sperrbezirk: ${reason}\nKorrektur über Volk bearbeiten`,
]);
});
it('an override without compliance:manage answers 403 and writes nothing', async () => {
setupMove();
restrict('apiary-2');
resolver.getPermissions.mockResolvedValue(new Set(['colonies:update']));
await expect(
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
),
).rejects.toMatchObject({ status: 403 });
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('a reason without an active restriction is ignored for a user with the right: no note, no audit field', async () => {
setupMove();
await service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
);
expect(
JSON.stringify(tx.colonyApiaryHistory.create.mock.calls),
).not.toContain('Ausnahme');
expect(JSON.stringify(auditLog.record.mock.calls)).not.toContain(
'restrictionOverride',
);
});
it.each(Object.keys(scenarios))(
'a reason without compliance:manage answers 403 up front, even without any restriction (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
resolver.getPermissions.mockResolvedValue(new Set());
await expect(scenario.run(reason)).rejects.toMatchObject({
status: 403,
});
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colony.create).not.toHaveBeenCalled();
},
);
it('merge answers 404, not "moved meanwhile", when a merged colony vanished between read and lock', async () => {
setupMerge('apiary-2');
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(where.id === 'colony-1' ? parentColony : null),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 404 });
});
it('a null reason counts as omitted and asks for no permission', async () => {
setupMove();
restrict('apiary-2');
await expect(
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: null },
'user-1',
),
).rejects.toMatchObject({ status: 409 });
expect(resolver.getPermissions).not.toHaveBeenCalled();
});
it('the restriction check runs inside the transaction after the apiary rows (share, ascending) and then the colony row are locked', async () => {
setupMove();
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
]);
const [[shareQuery]] = tx.$queryRaw.mock.calls as [
[{ values: unknown[] }],
];
expect(shareQuery.values).toEqual([['apiary-1', 'apiary-2']]);
const order = [
tx.$queryRaw.mock.invocationCallOrder[0],
tx.$queryRaw.mock.invocationCallOrder[1],
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
tx.colony.update.mock.invocationCallOrder[0],
];
expect(order).toEqual([...order].sort((a, b) => a - b));
expect(tx.apiary.findUnique.mock.invocationCallOrder[0]).toBeGreaterThan(
tx.$queryRaw.mock.invocationCallOrder[0],
);
});
it('create locks the apiary (share) before it checks and writes', async () => {
tx.colony.create.mockResolvedValue({
id: 'colony-9',
apiaryId: 'apiary-1',
});
await service.create(createDto, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
]);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
);
expect(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
).toBeLessThan(tx.colony.create.mock.invocationCallOrder[0]);
});
it('merge locks the apiaries (share), then survivor and merged colonies in one statement', async () => {
setupMerge('apiary-2');
await service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3'] },
'user-1',
);
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
]);
expect(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
).toBeGreaterThan(tx.$queryRaw.mock.invocationCallOrder[1]);
});
it('update, move and merge answer 409 when the colony changed its apiary between the unlocked read and the lock', async () => {
const movedAway = { ...parentColony, apiaryId: 'apiary-3' };
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockResolvedValue(movedAway);
await expect(
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
setupMerge('apiary-2');
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? parentColony
: { id: 'colony-3', apiaryId: 'apiary-3', status: 'ACTIVE' },
),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? movedAway
: { id: 'colony-3', apiaryId: 'apiary-2', status: 'ACTIVE' },
),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
it('an update without an apiary change still works when the colony was moved meanwhile', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-3',
});
tx.colony.update.mockResolvedValue(parentColony);
await expect(
service.update('colony-1', { notes: 'x' }, 'user-1'),
).resolves.toBeDefined();
});
it('updateIn called from an inspection takeover locks no apiary', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
await service.updateIn(tx as never, 'colony-1', { notes: 'x' }, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
]);
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it('updateIn refuses to move the apiary without the locks update() takes', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
await expect(
service.updateIn(
tx as never,
'colony-1',
{ apiaryId: 'apiary-2' },
'user-1',
),
).rejects.toThrow('moving the apiary');
expect(tx.colony.update).not.toHaveBeenCalled();
});
});
});
+213 -34
View File
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -21,9 +22,21 @@ import {
isLivingColonyStatus,
TERMINAL_COLONY_STATUSES,
} from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
assertNoActiveRestriction,
RestrictionCheck,
RestrictionOverride,
} from '../compliance/restricted-zone';
import {
apiaryChangedMeanwhile,
NOT_OVERRIDDEN,
overrideNote,
overrideTrace,
} from './restriction-override';
function colonyMovedEvent(
colonyId: string,
@@ -40,6 +53,21 @@ function colonyMovedEvent(
};
}
/** The fields update() writes: the override reason is read, never stored. */
type ColonyPatch = Omit<UpdateColonyDto, 'restrictionOverrideReason'>;
/**
* What update() hands to updateIn when it moves the colony to another apiary:
* the apiary the unlocked read saw (the apiary rows were locked for it) and
* the override the user may use.
*/
export interface ApiaryMoveGuard {
seenApiaryId: string;
override: RestrictionOverride;
}
const OVERRIDE_PERMISSION = 'compliance:manage';
export interface ColonyFilter {
status?: ColonyStatus;
apiaryId?: string;
@@ -52,6 +80,7 @@ export class ColoniesService {
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService,
private readonly permissions: PermissionResolverService,
) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -84,11 +113,14 @@ export class ColoniesService {
// this, a colony could be created or moved into an apiary that either
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
// 404) or is deactivated (silently orphaning the colony the same way an
// apiary deactivated out from under an existing colony would).
private async assertApiaryActive(apiaryId: string): Promise<void> {
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
});
// apiary deactivated out from under an existing colony would). Read inside
// the transaction, after the apiary rows are locked (FOR SHARE): the
// apiary cannot be deactivated before this colony path commits.
private async assertApiaryActiveIn(
tx: Prisma.TransactionClient,
apiaryId: string,
): Promise<void> {
const apiary = await tx.apiary.findUnique({ where: { id: apiaryId } });
if (!apiary) {
throw new NotFoundException('Bienenstand nicht gefunden');
}
@@ -99,17 +131,49 @@ export class ColoniesService {
}
}
// Asked once per request, and only when a reason was sent (null counts as
// omitted): the right to override is compliance:manage.
private async overrideOf(
reason: string | null | undefined,
actingUserId: string,
): Promise<RestrictionOverride> {
if (!reason) {
return { reason: null, canOverride: false };
}
const held = await this.permissions.getPermissions(actingUserId);
// Spec 5.6: a reason without compliance:manage is a 403 - always, also
// when no restriction is active. Checked before anything is read or locked.
if (!held.has(OVERRIDE_PERMISSION)) {
throw new ForbiddenException(
'Eine Begründung für eine Ausnahme vom Sperrbezirk darf nur mit dem Recht Prüfbereitschaft verwalten (compliance:manage) angegeben werden.',
);
}
return { reason, canOverride: true };
}
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
await this.assertApiaryActive(dto.apiaryId);
const { restrictionOverrideReason, ...data } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const colony = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ data: { ...dto } });
await lockRowsForShare(tx, 'Apiary', [data.apiaryId]);
await this.assertApiaryActiveIn(tx, data.apiaryId);
const check = await assertNoActiveRestriction(
tx,
[data.apiaryId],
berlinToday(),
override,
);
const created = await tx.colony.create({ data });
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
afterData: { ...created, ...overrideTrace(check, override.reason) },
},
tx,
);
@@ -126,9 +190,25 @@ export class ColoniesService {
dto: UpdateColonyDto,
actingUserId: string,
): Promise<Colony> {
const { after } = await this.prisma.$transaction((tx) =>
this.updateIn(tx, id, dto, actingUserId),
const { restrictionOverrideReason, ...patch } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const seen = await this.findById(id);
// Only a change of the apiary involves apiaries: the old and the new one
// are locked first (FOR SHARE, ascending), then the colony (Task 1 order).
const movesApiary =
patch.apiaryId !== undefined && patch.apiaryId !== seen.apiaryId;
const { after } = await this.prisma.$transaction(async (tx) => {
if (movesApiary) {
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, patch.apiaryId!]);
}
return this.updateIn(tx, id, patch, actingUserId, {
seenApiaryId: seen.apiaryId,
override,
});
});
return after;
}
@@ -137,22 +217,44 @@ export class ColoniesService {
* inspection handing values over to the colony card). Locks the colony row
* and reads "before" only afterwards (404 when missing); writes the apiary
* history, the status events and the audit entry with the change.
*
* It locks no apiary itself. A patch that moves the colony to another
* apiary needs `guard`: update() has then locked both apiaries (FOR SHARE)
* before the colony, so the restriction check and the write cannot be
* overtaken by a restriction or a deactivation. The inspection takeover
* passes no apiary and no guard.
*/
async updateIn(
tx: Prisma.TransactionClient,
id: string,
dto: UpdateColonyDto,
dto: ColonyPatch,
actingUserId: string,
guard?: ApiaryMoveGuard,
): Promise<{ before: Colony; after: Colony }> {
const before = await lockColony(tx, id);
const today = berlinToday();
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
let check: RestrictionCheck = NOT_OVERRIDDEN;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
if (!guard) {
throw new Error(
'ColoniesService.updateIn: moving the apiary needs update()',
);
}
if (before.apiaryId !== guard.seenApiaryId) {
throw apiaryChangedMeanwhile();
}
await this.assertApiaryActiveIn(tx, dto.apiaryId!);
check = await assertNoActiveRestriction(
tx,
[before.apiaryId, dto.apiaryId!],
today,
guard.override,
);
}
// An edit into an ended status ends the queen assignment like dissolve
// does, before the status is written (the pointer is read under the lock).
const today = berlinToday();
const endsColony =
dto.status !== undefined &&
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
@@ -173,7 +275,11 @@ export class ColoniesService {
colonyId: id,
apiaryId: updated.apiaryId,
movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten',
notes: overrideNote(
check,
guard?.override.reason,
'Korrektur über Volk bearbeiten',
),
},
});
await this.events.record(
@@ -196,7 +302,10 @@ export class ColoniesService {
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
afterData: {
...updated,
...overrideTrace(check, guard?.override.reason),
},
},
tx,
);
@@ -208,21 +317,41 @@ export class ColoniesService {
dto: MoveColonyDto,
actingUserId: string,
): Promise<Colony> {
this.assertActive(await this.findById(id));
await this.assertApiaryActive(dto.apiaryId);
const { restrictionOverrideReason, ...data } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const seen = await this.findById(id);
this.assertActive(seen);
const colony = await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, data.apiaryId]);
const before = await lockColony(tx, id);
this.assertActive(before);
if (before.apiaryId !== seen.apiaryId) {
throw apiaryChangedMeanwhile();
}
await this.assertApiaryActiveIn(tx, data.apiaryId);
// A "move" within one apiary leaves the herd where it is.
const check =
before.apiaryId === data.apiaryId
? NOT_OVERRIDDEN
: await assertNoActiveRestriction(
tx,
[before.apiaryId, data.apiaryId],
berlinToday(),
override,
);
const updated = await tx.colony.update({
where: { id },
data: { apiaryId: dto.apiaryId },
data: { apiaryId: data.apiaryId },
});
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: dto.apiaryId,
apiaryId: data.apiaryId,
movedByUserId: actingUserId,
notes: dto.notes,
notes: overrideNote(check, override.reason, data.notes),
},
});
if (before.apiaryId !== updated.apiaryId) {
@@ -238,7 +367,7 @@ export class ColoniesService {
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
afterData: { ...updated, ...overrideTrace(check, override.reason) },
},
tx,
);
@@ -313,24 +442,54 @@ export class ColoniesService {
dto: MergeColonyDto,
actingUserId: string,
): Promise<Colony> {
const { restrictionOverrideReason, ...data } = dto;
const survivor = await this.findById(id);
this.assertActive(survivor);
if (dto.mergedColonyIds.includes(survivor.id)) {
if (data.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) {
if (new Set(data.mergedColonyIds).size !== data.mergedColonyIds.length) {
throw new BadRequestException(
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
);
}
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const today = berlinToday();
// The apiaries of the colonies as the unlocked read saw them: they are
// locked first, and compared with the locked rows afterwards.
const seenApiaries = new Map(
(
await this.prisma.colony.findMany({
where: { id: { in: data.mergedColonyIds } },
select: { id: true, apiaryId: true },
})
).map((colony) => [colony.id, colony.apiaryId]),
);
await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [
...new Set([survivor.apiaryId, ...seenApiaries.values()]),
]);
// One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write.
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds);
for (const mergedColonyId of dto.mergedColonyIds) {
await lockRowsForUpdate(tx, 'Colony', [
survivor.id,
...data.mergedColonyIds,
]);
const lockedSurvivor = await tx.colony.findUnique({ where: { id } });
if (!lockedSurvivor) {
throw new NotFoundException('Colony not found');
}
this.assertActive(lockedSurvivor);
if (lockedSurvivor.apiaryId !== survivor.apiaryId) {
throw apiaryChangedMeanwhile();
}
const mergedRows: Colony[] = [];
for (const mergedColonyId of data.mergedColonyIds) {
const merged = await tx.colony.findUnique({
where: { id: mergedColonyId },
});
@@ -340,6 +499,22 @@ export class ColoniesService {
);
}
this.assertActive(merged);
if (seenApiaries.get(merged.id) !== merged.apiaryId) {
throw apiaryChangedMeanwhile();
}
mergedRows.push(merged);
}
// Uniting colonies of different apiaries brings bees from one stand to
// the other; within one apiary nothing leaves it.
const apiaryIds = new Set([
lockedSurvivor.apiaryId,
...mergedRows.map((merged) => merged.apiaryId),
]);
const check =
apiaryIds.size > 1
? await assertNoActiveRestriction(tx, [...apiaryIds], today, override)
: NOT_OVERRIDDEN;
for (const merged of mergedRows) {
// currentQueenId is unique across colonies - if it stayed set on
// this now-dead colony, any later attempt to assign her queen to a
// different colony would fail with a unique-constraint conflict.
@@ -348,11 +523,11 @@ export class ColoniesService {
// merge is a beekeeper decision, not one to guess here).
const releasedQueenId = await releaseQueenIn(
tx,
mergedColonyId,
merged.id,
dayToDate(today),
);
const mergedAway = await tx.colony.update({
where: { id: mergedColonyId },
where: { id: merged.id },
data: { status: 'MERGED', deactivatedAt: new Date() },
});
await this.events.recordAll(
@@ -366,21 +541,25 @@ export class ColoniesService {
await tx.colonyLineage.create({
data: {
type: 'MERGE',
fromColonyId: mergedColonyId,
toColonyId: survivor.id,
fromColonyId: merged.id,
toColonyId: lockedSurvivor.id,
date: new Date(),
notes: dto.notes,
notes: data.notes,
},
});
}
await this.auditLog.record(
{
entityType: 'Colony',
entityId: survivor.id,
entityId: lockedSurvivor.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: survivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
beforeData: lockedSurvivor,
afterData: {
...lockedSurvivor,
mergedColonyIds: data.mergedColonyIds,
...overrideTrace(check, override.reason),
},
},
tx,
);
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ColonyCondition, ColonyType } from '../../../generated/prisma';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class CreateColonyDto {
@ApiProperty()
@@ -67,4 +68,7 @@ export class CreateColonyDto {
@IsOptional()
@IsString()
notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
}
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class MergeColonyDto {
@ApiProperty({ type: [String] })
@@ -12,4 +13,7 @@ export class MergeColonyDto {
@IsOptional()
@IsString()
notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
}
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class MoveColonyDto {
@ApiProperty()
@@ -10,4 +11,7 @@ export class MoveColonyDto {
@IsOptional()
@IsString()
notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
}
@@ -0,0 +1,32 @@
import { applyDecorators } from '@nestjs/common';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export const OVERRIDE_REASON_MIN = 10;
export const OVERRIDE_REASON_MAX = 500;
/**
* `restrictionOverrideReason`: why a colony may enter or leave an apiary in a
* restricted zone. Only read by ColoniesService (needs compliance:manage) and
* written to audit and apiary history - never to the colony. Trimmed before
* the length check, so blanks do not count; null counts as omitted.
*/
export function RestrictionOverrideReason(): PropertyDecorator {
return applyDecorators(
ApiPropertyOptional({
nullable: true,
minLength: OVERRIDE_REASON_MIN,
maxLength: OVERRIDE_REASON_MAX,
description:
'Begründung für eine Ausnahme vom Sperrbezirk (nur mit compliance:manage)',
}),
IsOptional(),
Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
),
IsString(),
MinLength(OVERRIDE_REASON_MIN),
MaxLength(OVERRIDE_REASON_MAX),
);
}
@@ -3,6 +3,7 @@ import { IsEnum } from 'class-validator';
import { ColonyStatus } from '../../../generated/prisma';
import { CreateColonyDto } from './create-colony.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
import { RestrictionOverrideReason } from './restriction-override-reason';
/**
* `apiaryId` is editable here too, not only through POST /colonies/:id/move:
@@ -32,4 +33,7 @@ export class UpdateColonyDto extends UpdatableColonyFieldsDto {
@IsOptionalNotNull()
@IsEnum(ColonyStatus)
status?: ColonyStatus;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
}
@@ -0,0 +1,41 @@
import { ConflictException } from '@nestjs/common';
import type { RestrictionCheck } from '../compliance/restricted-zone';
/** 409 when the colony or an apiary changed between the unlocked read and the lock. */
export function apiaryChangedMeanwhile(): ConflictException {
return new ConflictException(
'Der Stand wurde gerade geändert. Bitte neu laden.',
);
}
export const NOT_OVERRIDDEN: RestrictionCheck = {
overridden: false,
restrictionIds: [],
};
/**
* The audit trace of an override: only the reason and the restriction ids,
* and only when an active restriction was really overridden. Spread into the
* audit entry's afterData - never into data written to the colony.
*/
export function overrideTrace(
check: RestrictionCheck,
reason: string | null | undefined,
): { restrictionOverride?: { reason: string; restrictionIds: string[] } } {
return check.overridden && reason
? { restrictionOverride: { reason, restrictionIds: check.restrictionIds } }
: {};
}
/** The apiary-history note: the override in front of an existing note. */
export function overrideNote(
check: RestrictionCheck,
reason: string | null | undefined,
note: string | undefined,
): string | undefined {
if (!check.overridden || !reason) {
return note;
}
const prefix = `Ausnahme Sperrbezirk: ${reason}`;
return note ? `${prefix}\n${note}` : prefix;
}