feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -373,4 +373,73 @@ describe('QueensService', () => {
|
||||
expect(lineage.descendants[0].code).toBe('Q-D');
|
||||
expect(lineage.descendants[0].daughters).toEqual([]);
|
||||
});
|
||||
|
||||
describe('updateStatusIn', () => {
|
||||
it('locks the queen, reads it afterwards, sets the status and audits in the same transaction', async () => {
|
||||
tx.queen.findUnique.mockResolvedValue(queen);
|
||||
tx.queen.update.mockResolvedValue({ ...queen, status: 'DECEASED' });
|
||||
|
||||
const result = await service.updateStatusIn(
|
||||
tx as never,
|
||||
'queen-1',
|
||||
'DECEASED',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result.status).toBe('DECEASED');
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.queen.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(tx.queen.update).toHaveBeenCalledWith({
|
||||
where: { id: 'queen-1' },
|
||||
data: { status: 'DECEASED' },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
{
|
||||
entityType: 'Queen',
|
||||
entityId: 'queen-1',
|
||||
action: 'UPDATE',
|
||||
userId: 'user-1',
|
||||
beforeData: queen,
|
||||
afterData: { ...queen, status: 'DECEASED' },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown queen', async () => {
|
||||
tx.queen.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateStatusIn(tx as never, 'gone', 'SOLD', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.queen.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 409 when the queen still sits in a colony and leaves the status unavailable', async () => {
|
||||
tx.queen.findUnique.mockResolvedValue(queen);
|
||||
tx.colony.findUnique.mockResolvedValue({ id: 'colony-9' });
|
||||
|
||||
await expect(
|
||||
service.updateStatusIn(tx as never, 'queen-1', 'DECEASED', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(tx.queen.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changes nothing and audits nothing when the status is the same', async () => {
|
||||
tx.queen.findUnique.mockResolvedValue(queen);
|
||||
|
||||
const result = await service.updateStatusIn(
|
||||
tx as never,
|
||||
'queen-1',
|
||||
'ACTIVE',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result).toBe(queen);
|
||||
expect(tx.queen.update).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,9 @@ import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import { assertTransactionClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { Queen, QueenStatus } from '../../generated/prisma';
|
||||
import { Prisma, Queen, QueenStatus } from '../../generated/prisma';
|
||||
import { CreateQueenDto } from './dto/create-queen.dto';
|
||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||
import { AssignColonyDto } from './dto/assign-colony.dto';
|
||||
@@ -155,6 +156,60 @@ export class QueensService {
|
||||
return queen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Differs from update(): this is the status alone, on the caller's
|
||||
* transaction, with the audit entry inside it. update() also lets go of
|
||||
* the colony that holds a queen who becomes unavailable (and audits after
|
||||
* its transaction); here that case is a 409, because the clean-up of an
|
||||
* ended colony must never unlink a colony she was re-assigned to.
|
||||
*
|
||||
* Sets the status of a queen on the caller's transaction: locks the queen,
|
||||
* reads it afterwards, audits the change. For a status that takes her out
|
||||
* of use it refuses while a colony still holds her (409) - the colony
|
||||
* side has to let go of her first (releaseQueenIn, assignColony); this
|
||||
* method never touches a colony. The same status again changes nothing.
|
||||
*/
|
||||
async updateStatusIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
status: QueenStatus,
|
||||
actingUserId: string,
|
||||
): Promise<Queen> {
|
||||
assertTransactionClient(tx, 'QueensService.updateStatusIn');
|
||||
await lockRowForUpdate(tx, 'Queen', id);
|
||||
const before = await tx.queen.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Queen not found');
|
||||
}
|
||||
if (before.status === status) {
|
||||
return before;
|
||||
}
|
||||
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(status)) {
|
||||
const holder = await tx.colony.findUnique({
|
||||
where: { currentQueenId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (holder) {
|
||||
throw new ConflictException(
|
||||
'Die Königin sitzt noch in einem Volk - bitte zuerst dort lösen',
|
||||
);
|
||||
}
|
||||
}
|
||||
const updated = await tx.queen.update({ where: { id }, data: { status } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Queen',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async assignColony(
|
||||
id: string,
|
||||
dto: AssignColonyDto,
|
||||
|
||||
Reference in new issue
Block a user