Files
ApiCore/ApiCore-Backend/src/apiaries/apiaries.service.ts
T
Nick SzaunigandClaude Opus 5.5 c18860c75c
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s
feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:43:44 +02:00

199 lines
6.0 KiB
TypeScript

import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { DomainEventPayloads } from '../domain-events/domain-event-types';
import { lockRowForUpdate } from '../prisma/row-lock';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
export interface ApiaryFilter {
status?: ApiaryStatus;
location?: string;
}
type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change'];
// The fields a change of which counts as a new location of the apiary.
const LOCATION_FIELDS = [
'address',
'location',
'latitude',
'longitude',
] as const;
@Injectable()
export class ApiariesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
return this.prisma.apiary.findMany({
where: {
status: filter.status,
location: filter.location
? { contains: filter.location, mode: 'insensitive' }
: undefined,
},
orderBy: { name: 'asc' },
});
}
async findById(id: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.findUnique({ where: { id } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
return apiary;
}
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
return this.prisma.$transaction(async (tx) => {
const apiary = await tx.apiary.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: apiary.id,
action: 'CREATE',
userId: actingUserId,
afterData: apiary,
},
tx,
);
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
return apiary;
});
}
async update(
id: string,
dto: UpdateApiaryDto,
actingUserId: string,
): Promise<Apiary> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
const apiary = await tx.apiary.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
},
tx,
);
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
}
return apiary;
});
}
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
// Colony.apiaryId is a plain FK, not enforced against apiary status -
// without this guard a colony can silently keep pointing at an
// inactive apiary (still shows ACTIVE, still countable, but its
// "location" has vanished from the active apiary list). Move the
// colonies elsewhere or dissolve them first instead. Counted under the
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
// into it that is still running finishes first.
const activeColonyCount = await tx.colony.count({
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
});
if (activeColonyCount > 0) {
throw new ConflictException(
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
);
}
const apiary = await tx.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
});
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
},
tx,
);
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
return apiary;
});
}
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Apiary is already active');
}
const apiary = await tx.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,
},
tx,
);
await this.recordChange(tx, id, 'REACTIVATED', actingUserId);
return apiary;
});
}
// Locks the apiary row and reads it afterwards: the result - not an earlier
// read - is "before" for the comparison, the audit and the event.
private async lockApiary(
tx: Prisma.TransactionClient,
id: string,
): Promise<Apiary> {
await lockRowForUpdate(tx, 'Apiary', id);
const apiary = await tx.apiary.findUnique({ where: { id } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
return apiary;
}
private recordChange(
tx: Prisma.TransactionClient,
apiaryId: string,
change: ApiaryChange,
actorUserId: string,
): Promise<void> {
return this.events.record(tx, {
type: 'ApiaryChanged',
aggregateType: 'Apiary',
aggregateId: apiaryId,
actorUserId,
payload: { change },
});
}
}