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); + }); });