feat(backend): reactivate endpoint for dissolved colonies and inactive apiaries

Da fachliche Objekte nie hart geloescht werden (nur status +
deactivatedAt), fehlte bisher ein Weg, ein versehentlich aufgeloestes
Volk oder deaktivierten Bienenstand wieder zu aktivieren. Neue
POST :id/reactivate-Endpunkte setzen status zurueck auf ACTIVE und
loeschen deactivatedAt; ConflictException wenn bereits aktiv.
This commit is contained in:
Nick Szaunig committed 2026-08-23 11:37:06 +02:00
1 parent 4a9fdd5c71
commit 6346bfab1d
8 files changed
+144 -2

No files matched your search

@@ -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);
}
}
@@ -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' }),
);
});
});
@@ -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<Apiary> {
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;
}
}
@@ -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);
}
}
@@ -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' }),
);
});
});
@@ -212,6 +212,26 @@ export class ColoniesService {
return colony;
}
async reactivate(id: string, actingUserId: string): Promise<Colony> {
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] =