Files
ApiCore/ApiCore-Backend/src/apiaries/apiary-field.service.ts
T
Nick SzaunigandClaude Opus 5.5 1a57de8573 feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

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

234 lines
7.5 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 { HiveCatalogService } from '../hive/hive-catalog.service';
import { HiveService } from '../hive/hive.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;
/** Active systems plus the ones the pack's hives use (a hidden system still builds an old hive). */
function activeOrUsed<S extends { id: string; active: boolean }>(
systems: S[],
hives: Record<string, { systemId: string }>,
): S[] {
const used = new Set(Object.values(hives).map((hive) => hive.systemId));
return systems.filter((system) => system.active || used.has(system.id));
}
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,
private readonly hives: HiveService,
private readonly hiveCatalogs: HiveCatalogService,
) {}
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, hives, hiveCatalog] = 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,
this.hives.getMany(colonies.map((colony) => colony.id)),
this.hiveCatalogs.listSystems(),
]);
return {
version: 1,
generatedAt: new Date().toISOString(),
apiary,
overview,
colonies,
queens,
inspections,
tasks,
hives,
hiveCatalog: activeOrUsed(hiveCatalog, hives),
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' }],
});
}
}