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