Backend - Backup covers every Prisma model (bundle v3); a guard test fails for any new model that is neither backed up nor explicitly excluded. Unsealed bundles may only add rows to 27 legacy master/field tables and never overwrite; counters, ledgers, settings, roles etc. need a sealed file. Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB) with warnings in log and UI; replaced document files are removed after commit. - Post-commit side effects (automation, calendar sync, webhooks) run via runAfterCommit and never turn a committed write into a 500, so retries with the same Idempotency-Key no longer create duplicates. - Calendar outbox is multi-replica safe: per-entry claims with stale takeover, revision-based settle, new Graph event only on 404/410, transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID validation for groupId, "run now" in background and resets FAILED. - Automation tasks are deduplicated under a row lock on the rule. - Update DTOs follow one null rule: omitted = unchanged, null clears a nullable field, null on a required field is a 400. - Varroa mites/day is recomputed on correction; withdrawal status is checked against the harvest date (?at=), bounded colonyIds; inspection create is audited; updates re-enqueue calendar entries. - Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS and QUEENLESS colonies as living. - Prisma errors and body-parser errors are logged without query values or request bodies. - Migration 20261012100000_calendar_sync_claims; schema index name aligned with the existing database object. Frontend - Sticky save bar sticks again (no scroll container ancestors, card uses overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms, sticky desktop sidebar. - Inspections record yes / no / not checked instead of silent defaults. - All living colonies are selectable; shared RecordSelect, EnumSelect, ChoiceGroup and TriStateField replace per-page copies; selects show names instead of ids; labels point at real controls. - Emptied optional fields are sent as null (emptyToNull) so every field can be cleared; dates default to the local day and are sent via toApiDate. - Harvest withdrawal warning uses the harvest date; backup page warns when a backup outgrows the import limit and lists skipped tables. Build and docs - Docker builds fail unless lint, typecheck and unit tests pass. - ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md for the backbone (api/worker/job roles, domain event outbox) and the feature roadmap T3-T7. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
|
import { AutomationService } from '../automation/automation.service';
|
|
import { Colony, ColonyStatus } from '../../generated/prisma';
|
|
import { CreateColonyDto } from './dto/create-colony.dto';
|
|
import { UpdateColonyDto } from './dto/update-colony.dto';
|
|
import { MoveColonyDto } from './dto/move-colony.dto';
|
|
import { SplitColonyDto } from './dto/split-colony.dto';
|
|
import { MergeColonyDto } from './dto/merge-colony.dto';
|
|
import { isLivingColonyStatus } from './colony-status';
|
|
|
|
export interface ColonyFilter {
|
|
status?: ColonyStatus;
|
|
apiaryId?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ColoniesService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly auditLog: AuditLogService,
|
|
private readonly webhooks: WebhooksService,
|
|
private readonly automation: AutomationService,
|
|
) {}
|
|
|
|
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
|
return this.prisma.colony.findMany({
|
|
where: { status: filter.status, apiaryId: filter.apiaryId },
|
|
orderBy: { code: 'asc' },
|
|
});
|
|
}
|
|
|
|
async findById(id: string): Promise<Colony> {
|
|
const colony = await this.prisma.colony.findUnique({ where: { id } });
|
|
if (!colony) {
|
|
throw new NotFoundException('Colony not found');
|
|
}
|
|
return colony;
|
|
}
|
|
|
|
// Only a colony that no longer exists (dissolved, sold, dead, merged) is
|
|
// locked. Weak, conspicuous or queenless colonies are still worked with -
|
|
// uniting a queenless colony with another is the textbook case for merge.
|
|
private assertActive(colony: Colony): void {
|
|
if (!isLivingColonyStatus(colony.status)) {
|
|
throw new ConflictException(
|
|
`Das Volk besteht nicht mehr (Status: ${colony.status})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Colony.apiaryId is a plain FK with no built-in status check - without
|
|
// this, a colony could be created or moved into an apiary that either
|
|
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
|
|
// 404) or is deactivated (silently orphaning the colony the same way an
|
|
// apiary deactivated out from under an existing colony would).
|
|
private async assertApiaryActive(apiaryId: string): Promise<void> {
|
|
const apiary = await this.prisma.apiary.findUnique({
|
|
where: { id: apiaryId },
|
|
});
|
|
if (!apiary) {
|
|
throw new NotFoundException('Bienenstand nicht gefunden');
|
|
}
|
|
if (apiary.status !== 'ACTIVE') {
|
|
throw new ConflictException(
|
|
'Der Bienenstand ist nicht aktiv und kann daher keine Völker aufnehmen',
|
|
);
|
|
}
|
|
}
|
|
|
|
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
|
await this.assertApiaryActive(dto.apiaryId);
|
|
const colony = await this.prisma.colony.create({ data: { ...dto } });
|
|
await this.auditLog.record({
|
|
entityType: 'Colony',
|
|
entityId: colony.id,
|
|
action: 'CREATE',
|
|
userId: actingUserId,
|
|
afterData: colony,
|
|
});
|
|
await this.webhooks.dispatch('colony.created', colony);
|
|
return colony;
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
dto: UpdateColonyDto,
|
|
actingUserId: string,
|
|
): Promise<Colony> {
|
|
const before = await this.findById(id);
|
|
const movesApiary =
|
|
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
|
if (movesApiary) {
|
|
await this.assertApiaryActive(dto.apiaryId!);
|
|
}
|
|
|
|
const colony = await this.prisma.$transaction(async (tx) => {
|
|
const updated = await tx.colony.update({
|
|
where: { id },
|
|
data: { ...dto },
|
|
});
|
|
// Correcting the apiary has to leave the same trace a deliberate move
|
|
// does - otherwise the colony's location history would silently skip a
|
|
// stand it was recorded on.
|
|
if (movesApiary) {
|
|
await tx.colonyApiaryHistory.create({
|
|
data: {
|
|
colonyId: id,
|
|
apiaryId: updated.apiaryId,
|
|
movedByUserId: actingUserId,
|
|
notes: 'Korrektur über Volk bearbeiten',
|
|
},
|
|
});
|
|
}
|
|
return updated;
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'Colony',
|
|
entityId: id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
afterData: colony,
|
|
});
|
|
await this.automation.evaluateColonyHealth(before, colony);
|
|
return colony;
|
|
}
|
|
|
|
async move(
|
|
id: string,
|
|
dto: MoveColonyDto,
|
|
actingUserId: string,
|
|
): Promise<Colony> {
|
|
const before = await this.findById(id);
|
|
this.assertActive(before);
|
|
await this.assertApiaryActive(dto.apiaryId);
|
|
const colony = await this.prisma.$transaction(async (tx) => {
|
|
const updated = await tx.colony.update({
|
|
where: { id },
|
|
data: { apiaryId: dto.apiaryId },
|
|
});
|
|
await tx.colonyApiaryHistory.create({
|
|
data: {
|
|
colonyId: id,
|
|
apiaryId: dto.apiaryId,
|
|
movedByUserId: actingUserId,
|
|
notes: dto.notes,
|
|
},
|
|
});
|
|
return updated;
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'Colony',
|
|
entityId: id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
afterData: colony,
|
|
});
|
|
return colony;
|
|
}
|
|
|
|
async split(
|
|
id: string,
|
|
dto: SplitColonyDto,
|
|
actingUserId: string,
|
|
): Promise<Colony> {
|
|
const parent = await this.findById(id);
|
|
this.assertActive(parent);
|
|
const child = await this.prisma.$transaction(async (tx) => {
|
|
const created = await tx.colony.create({
|
|
data: {
|
|
code: dto.code,
|
|
apiaryId: parent.apiaryId,
|
|
colonyType: dto.colonyType,
|
|
notes: dto.notes,
|
|
},
|
|
});
|
|
await tx.colonyLineage.create({
|
|
data: {
|
|
type: 'SPLIT',
|
|
fromColonyId: parent.id,
|
|
toColonyId: created.id,
|
|
date: new Date(),
|
|
notes: dto.notes,
|
|
},
|
|
});
|
|
return created;
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'Colony',
|
|
entityId: child.id,
|
|
action: 'CREATE',
|
|
userId: actingUserId,
|
|
afterData: { ...child, splitFromColonyId: parent.id },
|
|
});
|
|
return child;
|
|
}
|
|
|
|
async merge(
|
|
id: string,
|
|
dto: MergeColonyDto,
|
|
actingUserId: string,
|
|
): Promise<Colony> {
|
|
const survivor = await this.findById(id);
|
|
this.assertActive(survivor);
|
|
if (dto.mergedColonyIds.includes(survivor.id)) {
|
|
throw new BadRequestException(
|
|
'Ein Volk kann nicht mit sich selbst vereinigt werden',
|
|
);
|
|
}
|
|
if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) {
|
|
throw new BadRequestException(
|
|
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
|
|
);
|
|
}
|
|
await this.prisma.$transaction(async (tx) => {
|
|
for (const mergedColonyId of dto.mergedColonyIds) {
|
|
const merged = await tx.colony.findUnique({
|
|
where: { id: mergedColonyId },
|
|
});
|
|
if (!merged) {
|
|
throw new NotFoundException(
|
|
'Zu vereinigendes Volk wurde nicht gefunden',
|
|
);
|
|
}
|
|
this.assertActive(merged);
|
|
// currentQueenId is unique across colonies - if it stayed set on
|
|
// this now-dead colony, any later attempt to assign her queen to a
|
|
// different colony would fail with a unique-constraint conflict.
|
|
// The survivor keeps its own queen; merged-away queens are simply
|
|
// released, not auto-transferred (which colony's queen "wins" a
|
|
// merge is a beekeeper decision, not one to guess here).
|
|
await tx.colony.update({
|
|
where: { id: mergedColonyId },
|
|
data: {
|
|
status: 'MERGED',
|
|
deactivatedAt: new Date(),
|
|
currentQueenId: null,
|
|
},
|
|
});
|
|
if (merged.currentQueenId) {
|
|
await tx.queenColonyHistory.updateMany({
|
|
where: {
|
|
queenId: merged.currentQueenId,
|
|
colonyId: mergedColonyId,
|
|
endDate: null,
|
|
},
|
|
data: { endDate: new Date() },
|
|
});
|
|
}
|
|
await tx.colonyLineage.create({
|
|
data: {
|
|
type: 'MERGE',
|
|
fromColonyId: mergedColonyId,
|
|
toColonyId: survivor.id,
|
|
date: new Date(),
|
|
notes: dto.notes,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'Colony',
|
|
entityId: survivor.id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: survivor,
|
|
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
|
|
});
|
|
return this.findById(survivor.id);
|
|
}
|
|
|
|
async dissolve(id: string, actingUserId: string): Promise<Colony> {
|
|
const before = await this.findById(id);
|
|
// Re-dissolving would overwrite deactivatedAt and the terminal status
|
|
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
|
|
this.assertActive(before);
|
|
const colony = await this.prisma.$transaction(async (tx) => {
|
|
const updated = await tx.colony.update({
|
|
where: { id },
|
|
data: {
|
|
status: 'DISSOLVED',
|
|
deactivatedAt: new Date(),
|
|
currentQueenId: null,
|
|
},
|
|
});
|
|
if (before.currentQueenId) {
|
|
await tx.queenColonyHistory.updateMany({
|
|
where: {
|
|
queenId: before.currentQueenId,
|
|
colonyId: id,
|
|
endDate: null,
|
|
},
|
|
data: { endDate: new Date() },
|
|
});
|
|
}
|
|
return updated;
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'Colony',
|
|
entityId: id,
|
|
action: 'DEACTIVATE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
afterData: colony,
|
|
});
|
|
return colony;
|
|
}
|
|
|
|
async reactivate(id: string, actingUserId: string): Promise<Colony> {
|
|
const before = await this.findById(id);
|
|
// Only an ended colony can be brought back. A weak or queenless one
|
|
// still exists - reactivating it would just overwrite its condition.
|
|
if (isLivingColonyStatus(before.status)) {
|
|
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] =
|
|
await Promise.all([
|
|
this.prisma.colonyApiaryHistory.findMany({
|
|
where: { colonyId: id },
|
|
orderBy: { movedAt: 'desc' },
|
|
}),
|
|
this.prisma.queenColonyHistory.findMany({
|
|
where: { colonyId: id },
|
|
orderBy: { startDate: 'desc' },
|
|
}),
|
|
this.prisma.colonyLineage.findMany({ where: { fromColonyId: id } }),
|
|
this.prisma.colonyLineage.findMany({ where: { toColonyId: id } }),
|
|
]);
|
|
return { apiaryHistory, queenHistory, lineageAsSource, lineageAsResult };
|
|
}
|
|
}
|