diff --git a/ApiCore-Backend/src/apiaries/apiaries.controller.ts b/ApiCore-Backend/src/apiaries/apiaries.controller.ts index 5cb38af..866dece 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.controller.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.controller.ts @@ -59,4 +59,10 @@ export class ApiariesController { deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { return this.apiariesService.deactivate(id, actor.id); } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/reactivate') + reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.apiariesService.reactivate(id, actor.id); + } } diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts b/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts index 20fcd2c..9467667 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { ConflictException, NotFoundException } from '@nestjs/common'; import { ApiariesService } from './apiaries.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; @@ -75,4 +75,35 @@ describe('ApiariesService', () => { expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }), ); }); + + it('reactivate throws ConflictException when already active', async () => { + prisma.apiary.findUnique.mockResolvedValue(existingApiary); + await expect( + service.reactivate('apiary-1', 'user-1'), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => { + prisma.apiary.findUnique.mockResolvedValue({ + ...existingApiary, + status: 'INACTIVE', + deactivatedAt: new Date(), + }); + prisma.apiary.update.mockResolvedValue({ + ...existingApiary, + status: 'ACTIVE', + deactivatedAt: null, + }); + + const result = await service.reactivate('apiary-1', 'user-1'); + + expect(result.status).toBe('ACTIVE'); + expect(prisma.apiary.update).toHaveBeenCalledWith({ + where: { id: 'apiary-1' }, + data: { status: 'ACTIVE', deactivatedAt: null }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }), + ); + }); }); diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.ts b/ApiCore-Backend/src/apiaries/apiaries.service.ts index 8d9d9c1..8a957fa 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.service.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { Apiary, ApiaryStatus } from '../../generated/prisma'; @@ -86,4 +90,24 @@ export class ApiariesService { }); return apiary; } + + async reactivate(id: string, actingUserId: string): Promise { + const before = await this.findById(id); + if (before.status === 'ACTIVE') { + throw new ConflictException('Apiary is already active'); + } + const apiary = await this.prisma.apiary.update({ + where: { id }, + data: { status: 'ACTIVE', deactivatedAt: null }, + }); + await this.auditLog.record({ + entityType: 'Apiary', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: apiary, + }); + return apiary; + } } diff --git a/ApiCore-Backend/src/colonies/colonies.controller.ts b/ApiCore-Backend/src/colonies/colonies.controller.ts index 8e2a560..0f53197 100644 --- a/ApiCore-Backend/src/colonies/colonies.controller.ts +++ b/ApiCore-Backend/src/colonies/colonies.controller.ts @@ -97,4 +97,10 @@ export class ColoniesController { dissolve(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { return this.coloniesService.dissolve(id, actor.id); } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/reactivate') + reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.coloniesService.reactivate(id, actor.id); + } } diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index 8d6277b..fbcf78a 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -170,4 +170,35 @@ describe('ColoniesService', () => { expect.objectContaining({ action: 'DEACTIVATE' }), ); }); + + it('reactivate throws ConflictException when already active', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + await expect( + service.reactivate('colony-1', 'user-1'), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('reactivate sets status ACTIVE and clears deactivatedAt', async () => { + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + status: 'DISSOLVED', + deactivatedAt: new Date(), + }); + prisma.colony.update.mockResolvedValue({ + ...parentColony, + status: 'ACTIVE', + deactivatedAt: null, + }); + + const result = await service.reactivate('colony-1', 'user-1'); + + expect(result.status).toBe('ACTIVE'); + expect(prisma.colony.update).toHaveBeenCalledWith({ + where: { id: 'colony-1' }, + data: { status: 'ACTIVE', deactivatedAt: null }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'UPDATE' }), + ); + }); }); diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 85a257e..08234fd 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -212,6 +212,26 @@ export class ColoniesService { return colony; } + async reactivate(id: string, actingUserId: string): Promise { + const before = await this.findById(id); + if (before.status === 'ACTIVE') { + throw new ConflictException('Colony is already active'); + } + const colony = await this.prisma.colony.update({ + where: { id }, + data: { status: 'ACTIVE', deactivatedAt: null }, + }); + await this.auditLog.record({ + entityType: 'Colony', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: colony, + }); + return colony; + } + async getHistory(id: string) { await this.findById(id); const [apiaryHistory, queenHistory, lineageAsSource, lineageAsResult] = diff --git a/ApiCore-Backend/test/apiaries.e2e-spec.ts b/ApiCore-Backend/test/apiaries.e2e-spec.ts index b3b7aae..9cb1eb5 100644 --- a/ApiCore-Backend/test/apiaries.e2e-spec.ts +++ b/ApiCore-Backend/test/apiaries.e2e-spec.ts @@ -115,5 +115,16 @@ describe('Apiaries (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .expect(201); expect((deactivated.body as ApiaryBody).status).toBe('INACTIVE'); + + const reactivated = await request(app.getHttpServer()) + .post(`/api/v1/apiaries/${apiaryId}/reactivate`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(201); + expect((reactivated.body as ApiaryBody).status).toBe('ACTIVE'); + + await request(app.getHttpServer()) + .post(`/api/v1/apiaries/${apiaryId}/reactivate`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(409); }); }); diff --git a/ApiCore-Backend/test/colonies.e2e-spec.ts b/ApiCore-Backend/test/colonies.e2e-spec.ts index d4beda6..9208781 100644 --- a/ApiCore-Backend/test/colonies.e2e-spec.ts +++ b/ApiCore-Backend/test/colonies.e2e-spec.ts @@ -195,4 +195,17 @@ describe('Colonies (e2e)', () => { .send({ apiaryId: apiaryAId }) .expect(409); }); + + it('reactivates a dissolved colony, and rejects reactivating an already-active one', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/reactivate`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(201); + expect((response.body as ColonyBody).status).toBe('ACTIVE'); + + await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/reactivate`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(409); + }); });