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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -1,8 +1,9 @@
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 { Prisma } from '../../generated/prisma';
import type {
ColonyOverview,
ColonyOverviewRow,
@@ -18,6 +19,7 @@ interface FieldData {
colonies: PackColony[];
permissions: ReadonlySet<string>;
overview: ColonyOverview;
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
}
/**
@@ -30,6 +32,7 @@ export class ApiaryFieldService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
) {}
async colonyOverview(
@@ -40,10 +43,8 @@ export class ApiaryFieldService {
}
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview } = await this.load(
apiaryId,
userId,
);
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')
@@ -60,6 +61,12 @@ export class ApiaryFieldService {
queens,
inspections,
tasks,
nextSteps: Object.fromEntries(
colonies.map((colony) => [
colony.id,
nextSteps.get(colony.id)?.steps ?? [],
]),
),
};
}
@@ -96,9 +103,11 @@ export class ApiaryFieldService {
a.code.localeCompare(b.code, 'de', { numeric: true }),
);
const inspectionsVisible = permissions.has('inspections:read');
const [lastInspections, warnings] = await Promise.all([
const colonyIds = colonies.map((colony) => colony.id);
const [lastInspections, warnings, nextSteps] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.warningCounts(colonies, userId, permissions),
this.nextSteps.warningCounts(colonyIds, userId, permissions),
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
]);
const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id,
@@ -110,14 +119,18 @@ export class ApiaryFieldService {
feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0,
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0,
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,
};
}
@@ -138,42 +151,6 @@ export class ApiaryFieldService {
return dates;
}
private async warningCounts(
colonies: PackColony[],
userId: string,
held: ReadonlySet<string>,
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
if (colonies.length === 0) {
return counts;
}
// Exactly the rule of NotificationsService.findOwn, decided in SQL: a
// row counts only when every key in requiredPermissions is held (subset
// test, so a key outside the catalog hides the row too).
const groups = await this.prisma.$queryRaw<
{ relatedId: string; priority: string; count: number }[]
>(Prisma.sql`
SELECT "relatedId", "priority"::text AS "priority", count(*)::int AS "count"
FROM "Notification"
WHERE "relatedType" = 'COLONY'
AND "relatedId" IN (${Prisma.join(colonies.map((colony) => colony.id))})
AND "read" = false
AND "priority"::text IN ('WARNUNG', 'KRITISCH')
AND "userId" = ${userId}
AND "requiredPermissions" <@ ${[...held]}::text[]
GROUP BY "relatedId", "priority"
`);
for (const group of groups) {
if (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') {
counts.set(group.relatedId, {
...counts.get(group.relatedId),
[group.priority]: group.count,
});
}
}
return counts;
}
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [],