import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { NotificationsService } from '../notifications/notifications.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationRule, AutomationTriggerType, Colony, Inspection, Feeding, Prisma, StockItem, SwarmControlCheck, TaskRelatedType, Treatment, VarroaMeasurement, } from '../../generated/prisma'; import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; import { formatGeneratedNumber } from './number-format'; interface RuleDefaults { config: Record; actionTemplate: string; /** Rules without a sensible built-in setting start switched off. */ active?: boolean; } const DEFAULT_RULES: Record = { QUEENLESS_NO_EGGS: { config: {}, actionTemplate: 'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.', }, VARROA_THRESHOLD: { config: { thresholdMitesPerDay: 10 }, actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.', }, FEED_STATUS_CRITICAL: { config: {}, actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.', }, SWARM_MOOD_HIGH: { config: {}, actionTemplate: 'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.', }, COLONY_HEALTH_CRITICAL: { config: {}, actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.', }, STOCK_ITEM_LOW: { config: {}, actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.', }, // Not task-generating rules like the ones above - `active` here toggles // whether HoneyBatchesService/BottlingService auto-fill the batch/lot // number from `config.format` instead of requiring manual entry. HONEY_BATCH_NUMBER_FORMAT: { config: { format: 'WH-{YYYY}-{SEQ:4}' }, actionTemplate: '', }, BOTTLING_LOT_NUMBER_FORMAT: { config: { format: 'WH-{YYYY}-{SEQ:4}' }, actionTemplate: '', }, // Follow-up checks: how many days after a treatment or feeding a check // makes sense depends on the product and the season, so there is no // built-in interval - the beekeeper switches the rule on and sets // config.dueInDays. TREATMENT_FOLLOW_UP: { config: {}, actionTemplate: 'Behandlungserfolg kontrollieren', active: false, }, FEEDING_FOLLOW_UP: { config: {}, actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren', active: false, }, }; /** * Optional per-rule follow-up interval (config.dueInDays). There is no * built-in default: how soon a queenless colony or a high mite fall needs a * look depends on season and practice, so the rule stays date-less unless the * beekeeper configures one. */ function formatDay(date: Date): string { return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC', }); } function dueDateFromConfig( dueInDays: unknown, baseDate: Date = new Date(), ): Date | undefined { if ( typeof dueInDays !== 'number' || !Number.isInteger(dueInDays) || dueInDays < 0 || dueInDays > 365 ) { return undefined; } const due = new Date(baseDate); due.setUTCHours(0, 0, 0, 0); due.setUTCDate(due.getUTCDate() + dueInDays); return due; } @Injectable() export class AutomationService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly notifications: NotificationsService, private readonly webhooks: WebhooksService, ) {} private async ensureDefaults(): Promise { for (const triggerType of Object.keys( DEFAULT_RULES, ) as AutomationTriggerType[]) { const defaults = DEFAULT_RULES[triggerType]; await this.prisma.automationRule.upsert({ where: { triggerType }, update: {}, create: { triggerType, active: defaults.active ?? true, config: defaults.config as Prisma.InputJsonValue, actionTemplate: defaults.actionTemplate, }, }); } } async findAll(): Promise { await this.ensureDefaults(); return this.prisma.automationRule.findMany({ orderBy: { triggerType: 'asc' }, }); } async findById(id: string): Promise { const rule = await this.prisma.automationRule.findUnique({ where: { id }, }); if (!rule) { throw new NotFoundException('Automation rule not found'); } return rule; } async update( id: string, dto: UpdateAutomationRuleDto, actingUserId: string, ): Promise { const before = await this.findById(id); const rule = await this.prisma.automationRule.update({ where: { id }, data: { ...dto, config: dto.config as Prisma.InputJsonValue | undefined, }, }); await this.auditLog.record({ entityType: 'AutomationRule', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: rule, }); return rule; } async evaluateInspection(inspection: Inspection): Promise { await this.ensureDefaults(); const rules = await this.prisma.automationRule.findMany({ where: { triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] }, active: true, }, }); const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule])); const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS'); if ( queenlessRule && inspection.queenSeen === false && inspection.eggsPresent === false ) { await this.triggerAction( queenlessRule, 'COLONY', inspection.colonyId, inspection.id, ); } const feedRule = ruleByType.get('FEED_STATUS_CRITICAL'); if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') { await this.triggerAction( feedRule, 'COLONY', inspection.colonyId, inspection.id, ); } } async evaluateVarroaMeasurement( measurement: VarroaMeasurement, ): Promise { await this.ensureDefaults(); const rule = await this.prisma.automationRule.findUnique({ where: { triggerType: 'VARROA_THRESHOLD' }, }); if (!rule || !rule.active) { return; } // The threshold is a natural mite fall rate (mites per day). Only that // method yields a value in this unit - a mite count from a sugar roll or // wash is per bee sample and would be compared against the wrong scale, // as would a raw board count without the number of days it covered. if (measurement.method !== 'NATURAL_MITE_FALL') { return; } const config = rule.config as { thresholdMitesPerDay?: number } | null; const threshold = config?.thresholdMitesPerDay ?? 10; const value = measurement.calculatedValue; if (value !== null && value >= threshold) { await this.triggerAction(rule, 'COLONY', measurement.colonyId); } } async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise { await this.ensureDefaults(); const rule = await this.prisma.automationRule.findUnique({ where: { triggerType: 'SWARM_MOOD_HIGH' }, }); if (!rule || !rule.active) { return; } if (check.swarmMood === 'HIGH') { await this.triggerAction(rule, 'COLONY', check.colonyId); } } /** Fires only on the transition into CRITICAL, not on every edit while already critical. */ async evaluateColonyHealth(before: Colony, after: Colony): Promise { await this.ensureDefaults(); if ( after.healthStatus !== 'CRITICAL' || before.healthStatus === 'CRITICAL' ) { return; } const rule = await this.prisma.automationRule.findUnique({ where: { triggerType: 'COLONY_HEALTH_CRITICAL' }, }); if (!rule || !rule.active) { return; } await this.triggerAction(rule, 'COLONY', after.id); } async evaluateStockItem(item: StockItem): Promise { await this.ensureDefaults(); if (item.minThreshold === null || item.quantity >= item.minThreshold) { return; } const rule = await this.prisma.automationRule.findUnique({ where: { triggerType: 'STOCK_ITEM_LOW' }, }); if (!rule || !rule.active) { return; } await this.triggerAction(rule, 'STORAGE', item.id); } async evaluateTreatment(treatment: Treatment): Promise { await this.evaluateFollowUp( 'TREATMENT_FOLLOW_UP', treatment.colonyId, treatment.endDate ?? treatment.date, `Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`, ); } async evaluateFeeding(feeding: Feeding): Promise { await this.evaluateFollowUp( 'FEEDING_FOLLOW_UP', feeding.colonyId, feeding.date, `Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`, ); } /** A check counted from the measure's own date, not from today. */ private async evaluateFollowUp( triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP', colonyId: string, measureDate: Date, description: string, ): Promise { await this.ensureDefaults(); const rule = await this.prisma.automationRule.findUnique({ where: { triggerType }, }); if (!rule || !rule.active) { return; } await this.triggerAction(rule, 'COLONY', colonyId, undefined, { baseDate: measureDate, description, }); } // Returns null (never throws) when the rule is inactive or has no format // configured, so the caller can fall back to requiring manual entry. async generateNumber( triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT', ): Promise { await this.ensureDefaults(); const rule = await this.prisma.automationRule.findUnique({ where: { triggerType }, }); if (!rule || !rule.active) { return null; } const config = rule.config as { format?: string } | null; const format = config?.format; if (!format) { return null; } // Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent // batch/bottling creation, no explicit transaction/lock needed. const updated = await this.prisma.automationRule.update({ where: { triggerType }, data: { sequenceValue: { increment: 1 } }, select: { sequenceValue: true }, }); return formatGeneratedNumber(format, updated.sequenceValue, new Date()); } async resetSequence( id: string, actingUserId: string, ): Promise { const before = await this.findById(id); const rule = await this.prisma.automationRule.update({ where: { id }, data: { sequenceValue: 0 }, }); await this.auditLog.record({ entityType: 'AutomationRule', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: rule, }); return rule; } /** * Leaves a suggestion task (never a record of work done) plus a warning. * * At most one open task per rule and object: while the reorder task for a * stock item or the check task for a colony is still open, further triggers * (the next removal below minimum, a second high varroa count) add nothing. * Repeating the same alarm would only bury the one task that matters. */ private async triggerAction( rule: AutomationRule, relatedType: TaskRelatedType, relatedId: string, sourceInspectionId?: string, options: { baseDate?: Date; description?: string } = {}, ): Promise { const alreadyOpen = await this.prisma.task.findFirst({ where: { sourceAutomationTrigger: rule.triggerType, relatedType, relatedId, status: { in: ['OPEN', 'IN_PROGRESS'] }, }, select: { id: true }, }); if (alreadyOpen) { return; } const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; const config = rule.config as { dueInDays?: unknown } | null; const task = await this.prisma.task.create({ data: { title, priority: 'HIGH', relatedType, relatedId, sourceInspectionId, sourceAutomationTrigger: rule.triggerType, description: options.description, dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate), }, }); await this.webhooks.dispatch('task.created', task); await this.notifications.record({ priority: 'WARNUNG', message: title, relatedType, relatedId, }); await this.auditLog.record({ entityType: 'AutomationRule', entityId: rule.id, action: 'CREATE', afterData: { taskId: task.id, relatedType, relatedId, triggerType: rule.triggerType, }, }); } }