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]>
234 lines
7.5 KiB
TypeScript
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' }],
|
|
});
|
|
}
|
|
}
|