feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- 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:
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user