Files
ApiCore/ApiCore-Backend/src/colonies/colonies.service.ts
T
Nick SzaunigandClaude Opus 5.5 10cbe9bf2d
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s
fix: Paket 0 – data integrity, security hardening and field usability
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]>
2026-10-12 16:38:55 +02:00

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