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( systems: S[], hives: Record, ): 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; overview: ColonyOverview; nextSteps: ReadonlyMap; } /** * 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 { return (await this.load(apiaryId, userId)).overview; } async offlinePack(apiaryId: string, userId: string): Promise { 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 { // 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> { const groups = await this.prisma.inspection.groupBy({ by: ['colonyId'], where: { colonyId: { in: colonies.map((colony) => colony.id) } }, _max: { date: true }, }); const dates = new Map(); for (const group of groups) { if (group._max.date) { dates.set(group.colonyId, group._max.date.toISOString()); } } return dates; } private loadQueens(colonies: PackColony[]): Promise { 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 { 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 { 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' }], }); } }