From 1688e77a89b4b2dafe249e008320eaac8c784219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Mon, 19 Oct 2026 16:48:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(queens):=20verlorene,=20eingegangene,=20ers?= =?UTF-8?q?etzte=20und=20verkaufte=20K=C3=B6niginnen=20ins=20Archiv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Königinnen-Liste trennt Aktiv und Historie über deactivatedAt, das Backend setzte das Feld aber nie. Jetzt stempelt ein Statuswechsel auf LOST, DECEASED, REPLACED oder SOLD das Datum (update und updateStatusIn), ein korrigierter Status nimmt es wieder weg. Die Migration 20261021100000_queen_deactivated_backfill trägt das Datum für bestehende Königinnen nach (Ende der letzten Volkszuordnung, sonst updatedAt). Auch die Lebensdauer in der Statistik rechnet damit wieder. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../migration.sql | 14 ++++ .../src/queens/queens.service.spec.ts | 77 ++++++++++++++++++- ApiCore-Backend/src/queens/queens.service.ts | 26 ++++++- .../queen-deactivated-migration.e2e-spec.ts | 62 +++++++++++++++ ApiCore-Backend/test/queens.e2e-spec.ts | 51 ++++++++++++ 5 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261021100000_queen_deactivated_backfill/migration.sql create mode 100644 ApiCore-Backend/test/queen-deactivated-migration.e2e-spec.ts diff --git a/ApiCore-Backend/prisma/migrations/20261021100000_queen_deactivated_backfill/migration.sql b/ApiCore-Backend/prisma/migrations/20261021100000_queen_deactivated_backfill/migration.sql new file mode 100644 index 0000000..264c4c6 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261021100000_queen_deactivated_backfill/migration.sql @@ -0,0 +1,14 @@ +-- Queens that died, got lost, were replaced or sold never received a +-- deactivatedAt, so the /queens history tab and the queen lifespans in the +-- statistics never saw them. The service stamps it from now on; this dates +-- the existing ones by the end of their last colony assignment, or by their +-- last change when no assignment ever ended. +-- Data only and idempotent: it touches only unavailable queens still +-- without deactivatedAt (QueenColonyHistory is indexed on queenId). +UPDATE "Queen" AS q +SET "deactivatedAt" = COALESCE( + (SELECT max(h."endDate") FROM "QueenColonyHistory" AS h WHERE h."queenId" = q."id"), + q."updatedAt" +) +WHERE q."status" IN ('LOST', 'DECEASED', 'REPLACED', 'SOLD') + AND q."deactivatedAt" IS NULL; diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts index 0ee146d..84824e0 100644 --- a/ApiCore-Backend/src/queens/queens.service.spec.ts +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -305,6 +305,66 @@ describe('QueensService', () => { expect(tx.colony.update).not.toHaveBeenCalled(); }); + it('stamps deactivatedAt, which moves her into the history', async () => { + prisma.queen.findUnique.mockResolvedValue({ + ...queen, + deactivatedAt: null, + }); + + await service.update('queen-1', { status: 'DECEASED' }, 'user-1'); + + expect(tx.queen.update).toHaveBeenCalledWith({ + where: { id: 'queen-1' }, + data: { status: 'DECEASED', deactivatedAt: expect.any(Date) as Date }, + }); + }); + + it('keeps the first deactivatedAt when she moves between unavailable statuses', async () => { + const diedOn = new Date('2026-05-01T00:00:00Z'); + prisma.queen.findUnique.mockResolvedValue({ + ...queen, + status: 'DECEASED', + deactivatedAt: diedOn, + }); + + await service.update('queen-1', { status: 'LOST' }, 'user-1'); + + expect(tx.queen.update).toHaveBeenCalledWith({ + where: { id: 'queen-1' }, + data: { status: 'LOST', deactivatedAt: diedOn }, + }); + }); + + it('clears deactivatedAt when the status is corrected back to an available one', async () => { + prisma.queen.findUnique.mockResolvedValue({ + ...queen, + status: 'DECEASED', + deactivatedAt: new Date('2026-05-01T00:00:00Z'), + }); + + await service.update('queen-1', { status: 'ACTIVE' }, 'user-1'); + + expect(tx.queen.update).toHaveBeenCalledWith({ + where: { id: 'queen-1' }, + data: { status: 'ACTIVE', deactivatedAt: null }, + }); + }); + + it('leaves deactivatedAt alone when the status is not part of the change', async () => { + prisma.queen.findUnique.mockResolvedValue({ + ...queen, + status: 'DECEASED', + deactivatedAt: new Date('2026-05-01T00:00:00Z'), + }); + + await service.update('queen-1', { notes: 'Notiz' }, 'user-1'); + + expect(tx.queen.update).toHaveBeenCalledWith({ + where: { id: 'queen-1' }, + data: { notes: 'Notiz', deactivatedAt: undefined }, + }); + }); + it('does not touch the colony for a status change that keeps the queen available', async () => { prisma.queen.findUnique.mockResolvedValue(queen); @@ -392,7 +452,7 @@ describe('QueensService', () => { ); expect(tx.queen.update).toHaveBeenCalledWith({ where: { id: 'queen-1' }, - data: { status: 'DECEASED' }, + data: { status: 'DECEASED', deactivatedAt: expect.any(Date) as Date }, }); expect(auditLog.record).toHaveBeenCalledWith( { @@ -408,6 +468,21 @@ describe('QueensService', () => { expect(prisma.$transaction).not.toHaveBeenCalled(); }); + it('clears deactivatedAt when a status is corrected back to an available one', async () => { + tx.queen.findUnique.mockResolvedValue({ + ...queen, + status: 'SOLD', + deactivatedAt: new Date('2026-05-01T00:00:00Z'), + }); + + await service.updateStatusIn(tx as never, 'queen-1', 'RESERVE', 'user-1'); + + expect(tx.queen.update).toHaveBeenCalledWith({ + where: { id: 'queen-1' }, + data: { status: 'RESERVE', deactivatedAt: null }, + }); + }); + it('answers 404 for an unknown queen', async () => { tx.queen.findUnique.mockResolvedValue(null); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index 2da8fd7..94fc7da 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -61,6 +61,19 @@ const QUEEN_NO_LONGER_AVAILABLE_STATUSES: QueenStatus[] = [ 'SOLD', ]; +/** + * deactivatedAt for a queen moving to `status`: the /queens history and the + * queen lifespans in the statistics read it. Stamped when she becomes + * unavailable (the first date kept on a move between such statuses), cleared + * when a status is corrected back to an available one. + */ +function deactivatedAtFor(before: Queen, status: QueenStatus): Date | null { + if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(status)) { + return before.deactivatedAt ?? new Date(); + } + return null; +} + @Injectable() export class QueensService { constructor( @@ -121,7 +134,13 @@ export class QueensService { const queen = await this.prisma.$transaction(async (tx) => { const updated = await tx.queen.update({ where: { id }, - data: { ...dto }, + data: { + ...dto, + deactivatedAt: + dto.status === undefined + ? undefined + : deactivatedAtFor(before, dto.status), + }, }); if (becameUnavailable) { const colony = await tx.colony.findUnique({ @@ -195,7 +214,10 @@ export class QueensService { ); } } - const updated = await tx.queen.update({ where: { id }, data: { status } }); + const updated = await tx.queen.update({ + where: { id }, + data: { status, deactivatedAt: deactivatedAtFor(before, status) }, + }); await this.auditLog.record( { entityType: 'Queen', diff --git a/ApiCore-Backend/test/queen-deactivated-migration.e2e-spec.ts b/ApiCore-Backend/test/queen-deactivated-migration.e2e-spec.ts new file mode 100644 index 0000000..613775b --- /dev/null +++ b/ApiCore-Backend/test/queen-deactivated-migration.e2e-spec.ts @@ -0,0 +1,62 @@ +import { + ScratchDatabase, + createScratchDatabase, +} from './utils/migration-harness'; + +const BACKFILL = '20261021100000_queen_deactivated_backfill'; + +const TIMEOUT_MS = 240_000; + +describe('Queen deactivatedAt backfill migration (e2e)', () => { + let db: ScratchDatabase | undefined; + + afterEach(async () => { + await db?.drop(); + db = undefined; + }); + + it( + 'dates every unavailable queen without deactivatedAt by her last colony end, else her last change, and leaves the rest alone', + async () => { + db = await createScratchDatabase(BACKFILL); + await db.query( + `INSERT INTO "Apiary" ("id", "name", "updatedAt") VALUES ('apiary-1', 'Stand', now())`, + ); + await db.query( + `INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt") + VALUES ('colony-1', 'V-1', 'apiary-1', 'PRODUCTION_COLONY', now())`, + ); + await db.query( + `INSERT INTO "Queen" ("id", "code", "status", "updatedAt", "deactivatedAt") VALUES + ('q-dead-history', 'Q-1', 'DECEASED', '2026-09-01T00:00:00Z', NULL), + ('q-sold-nohistory', 'Q-2', 'SOLD', '2026-08-15T00:00:00Z', NULL), + ('q-lost-dated', 'Q-3', 'LOST', '2026-07-01T00:00:00Z', '2026-06-01T00:00:00Z'), + ('q-active', 'Q-4', 'ACTIVE', '2026-09-01T00:00:00Z', NULL), + ('q-replaced-open', 'Q-5', 'REPLACED', '2026-09-10T00:00:00Z', NULL)`, + ); + await db.query( + `INSERT INTO "QueenColonyHistory" ("id", "queenId", "colonyId", "startDate", "endDate") VALUES + ('h-1', 'q-dead-history', 'colony-1', '2025-04-01T00:00:00Z', '2025-10-01T00:00:00Z'), + ('h-2', 'q-dead-history', 'colony-1', '2026-03-01T00:00:00Z', '2026-05-20T00:00:00Z'), + ('h-3', 'q-active', 'colony-1', '2026-05-20T00:00:00Z', NULL), + ('h-4', 'q-replaced-open', 'colony-1', '2026-01-01T00:00:00Z', NULL)`, + ); + + await db.migrateThrough(BACKFILL); + + expect( + await db.query( + `SELECT "id", to_char("deactivatedAt", 'YYYY-MM-DD') AS "deactivatedOn" + FROM "Queen" ORDER BY "id"`, + ), + ).toEqual([ + { id: 'q-active', deactivatedOn: null }, + { id: 'q-dead-history', deactivatedOn: '2026-05-20' }, + { id: 'q-lost-dated', deactivatedOn: '2026-06-01' }, + { id: 'q-replaced-open', deactivatedOn: '2026-09-10' }, + { id: 'q-sold-nohistory', deactivatedOn: '2026-08-15' }, + ]); + }, + TIMEOUT_MS, + ); +}); diff --git a/ApiCore-Backend/test/queens.e2e-spec.ts b/ApiCore-Backend/test/queens.e2e-spec.ts index 51a3120..983461e 100644 --- a/ApiCore-Backend/test/queens.e2e-spec.ts +++ b/ApiCore-Backend/test/queens.e2e-spec.ts @@ -26,6 +26,7 @@ describe('Queens (e2e)', () => { let daughterQueenId: string; let secondColonyId: string; let subscriptionId: string; + let retiredQueenId: string; beforeAll(async () => { app = await createTestApp(); @@ -104,6 +105,9 @@ describe('Queens (e2e)', () => { data: { currentQueenId: null }, }); } + if (retiredQueenId) { + await prisma.queen.delete({ where: { id: retiredQueenId } }); + } if (daughterQueenId) { await prisma.queen.delete({ where: { id: daughterQueenId } }); } @@ -307,4 +311,51 @@ describe('Queens (e2e)', () => { expect(daughterBody.queen.mother?.id).toBe(queenId); expect(daughterBody.descendants).toHaveLength(0); }); + + it('moves a queen into the history when she dies and back when the status is corrected', async () => { + const created = await request(app.getHttpServer()) + .post('/api/v1/queens') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ code: `Q-retired-${Date.now()}` }) + .expect(201); + retiredQueenId = (created.body as QueenBody).id; + expect( + (created.body as { deactivatedAt: string | null }).deactivatedAt, + ).toBeNull(); + + const deceased = await request(app.getHttpServer()) + .patch(`/api/v1/queens/${retiredQueenId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ status: 'DECEASED' }) + .expect(200); + expect( + (deceased.body as { deactivatedAt: string | null }).deactivatedAt, + ).toEqual(expect.any(String)); + + const list = await request(app.getHttpServer()) + .get('/api/v1/queens') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const listed = ( + list.body as { id: string; deactivatedAt: string | null }[] + ).find((queen) => queen.id === retiredQueenId); + expect(listed?.deactivatedAt).toEqual(expect.any(String)); + + const corrected = await request(app.getHttpServer()) + .patch(`/api/v1/queens/${retiredQueenId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ status: 'ACTIVE' }) + .expect(200); + expect( + (corrected.body as { deactivatedAt: string | null }).deactivatedAt, + ).toBeNull(); + }); + + it('does not let a client set deactivatedAt directly', () => { + return request(app.getHttpServer()) + .patch(`/api/v1/queens/${queenId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ deactivatedAt: new Date().toISOString() }) + .expect(400); + }); });