feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,230 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {}
|
||||
|
||||
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 } = 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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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 [lastInspections, warnings] = await Promise.all([
|
||||
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
||||
this.warningCounts(colonies, userId),
|
||||
]);
|
||||
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
||||
id: colony.id,
|
||||
code: colony.code,
|
||||
status: colony.status,
|
||||
strength: colony.strength,
|
||||
broodStatus: colony.broodStatus,
|
||||
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,
|
||||
}));
|
||||
return {
|
||||
apiary,
|
||||
colonies,
|
||||
permissions,
|
||||
overview: { apiaryId, inspectionsVisible, rows },
|
||||
};
|
||||
}
|
||||
|
||||
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 async warningCounts(
|
||||
colonies: PackColony[],
|
||||
userId: string,
|
||||
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
|
||||
const groups = await this.prisma.notification.groupBy({
|
||||
by: ['relatedId', 'priority'],
|
||||
where: {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: { in: colonies.map((colony) => colony.id) },
|
||||
read: false,
|
||||
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
||||
OR: [{ userId }, { userId: null }],
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
|
||||
for (const group of groups) {
|
||||
if (
|
||||
group.relatedId &&
|
||||
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
|
||||
) {
|
||||
counts.set(group.relatedId, {
|
||||
...counts.get(group.relatedId),
|
||||
[group.priority]: group._count._all,
|
||||
});
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
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' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user