Files
ApiCore/ApiCore-Backend/src/apiaries/apiary-field.service.ts
T
Nick SzaunigandClaude Opus 5.5 ef33e5549b feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:28:07 +02:00

217 lines
6.7 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import type {
ColonyOverview,
ColonyOverviewRow,
OfflinePackV1,
} from './apiary-field.types';
const INSPECTIONS_PER_COLONY = 3;
type PackColony = OfflinePackV1['colonies'][number];
interface FieldData {
apiary: OfflinePackV1['apiary'];
colonies: PackColony[];
permissions: ReadonlySet<string>;
overview: ColonyOverview;
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
}
/**
* Read models for work at the apiary: the colony traffic-light overview and
* the offline pack the PWA caches before the beekeeper loses reception.
* Sections beyond the colonies are only included for callers who may read them.
*/
@Injectable()
export class ApiaryFieldService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
) {}
async colonyOverview(
apiaryId: string,
userId: string,
): Promise<ColonyOverview> {
return (await this.load(apiaryId, userId)).overview;
}
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview, nextSteps } =
await this.load(apiaryId, userId);
const [queens, inspections, tasks] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read')
? this.loadInspections(colonies)
: null,
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
]);
return {
version: 1,
generatedAt: new Date().toISOString(),
apiary,
overview,
colonies,
queens,
inspections,
tasks,
nextSteps: Object.fromEntries(
colonies.map((colony) => [
colony.id,
nextSteps.get(colony.id)?.steps ?? [],
]),
),
};
}
private async load(apiaryId: string, userId: string): Promise<FieldData> {
// Explicit select: coordinates, address and notes of an apiary are
// theft-sensitive and must never reach the field payloads.
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
select: { id: true, name: true, location: true, status: true },
});
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
const permissions = await this.permissionResolver.getPermissions(userId);
const found = await this.prisma.colony.findMany({
where: { ...LIVING_COLONY_WHERE, apiaryId },
select: {
id: true,
code: true,
apiaryId: true,
status: true,
colonyType: true,
hiveType: true,
strength: true,
occupiedFrames: true,
broodStatus: true,
feedStatus: true,
healthStatus: true,
currentQueenId: true,
notes: true,
},
});
const colonies = [...found].sort((a, b) =>
a.code.localeCompare(b.code, 'de', { numeric: true }),
);
const inspectionsVisible = permissions.has('inspections:read');
const colonyIds = colonies.map((colony) => colony.id);
const [lastInspections, warnings, nextSteps] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.nextSteps.warningCounts(colonyIds, userId, permissions),
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
]);
const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id,
code: colony.code,
status: colony.status,
strength: colony.strength,
occupiedFrames: colony.occupiedFrames,
broodStatus: colony.broodStatus,
feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
segment: nextSteps.get(colony.id)?.segment ?? null,
nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null,
openWarnings: warnings.get(colony.id)?.warnings ?? 0,
openCritical: warnings.get(colony.id)?.critical ?? 0,
}));
return {
apiary,
colonies,
permissions,
overview: { apiaryId, inspectionsVisible, rows },
nextSteps,
};
}
private async lastInspectionDates(
colonies: PackColony[],
): Promise<Map<string, string>> {
const groups = await this.prisma.inspection.groupBy({
by: ['colonyId'],
where: { colonyId: { in: colonies.map((colony) => colony.id) } },
_max: { date: true },
});
const dates = new Map<string, string>();
for (const group of groups) {
if (group._max.date) {
dates.set(group.colonyId, group._max.date.toISOString());
}
}
return dates;
}
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [],
);
return this.prisma.queen.findMany({
where: { id: { in: queenIds } },
select: {
id: true,
code: true,
hatchYear: true,
breed: true,
markingColor: true,
markingNumber: true,
status: true,
},
});
}
private async loadInspections(
colonies: PackColony[],
): Promise<OfflinePackV1['inspections']> {
const perColony = await Promise.all(
colonies.map((colony) =>
this.prisma.inspection.findMany({
where: { colonyId: colony.id },
take: INSPECTIONS_PER_COLONY,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
}),
),
);
return perColony.flat();
}
private loadTasks(
apiaryId: string,
colonies: PackColony[],
): Promise<OfflinePackV1['tasks']> {
return this.prisma.task.findMany({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: apiaryId },
{
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
},
],
},
select: {
id: true,
title: true,
description: true,
dueDate: true,
priority: true,
status: true,
relatedType: true,
relatedId: true,
},
orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }],
});
}
}