diff --git a/ApiCore-Backend/prisma/migrations/20260824151154_add_automation_rules/migration.sql b/ApiCore-Backend/prisma/migrations/20260824151154_add_automation_rules/migration.sql new file mode 100644 index 0000000..0b009ca --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260824151154_add_automation_rules/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "AutomationTriggerType" AS ENUM ('QUEENLESS_NO_EGGS', 'VARROA_THRESHOLD', 'FEED_STATUS_CRITICAL'); + +-- CreateTable +CREATE TABLE "AutomationRule" ( + "id" TEXT NOT NULL, + "triggerType" "AutomationTriggerType" NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "config" JSONB, + "actionTemplate" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutomationRule_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AutomationRule_triggerType_key" ON "AutomationRule"("triggerType"); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index eeb2636..5a7230f 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -823,3 +823,26 @@ model Document { @@index([entityType, entityId]) } + +// --------------------------------------------------------------------------- +// Automatisierungen (Abschnitt 26) – v1.x Modul 30 +// --------------------------------------------------------------------------- + +// Fest hinterlegte Trigger (Code, nicht nutzerdefiniert) – die Tabelle erlaubt +// nur, jeden Trigger zu aktivieren/deaktivieren und seine Schwellenwerte +// (config) sowie den Aufgabentext (actionTemplate) anzupassen. +enum AutomationTriggerType { + QUEENLESS_NO_EGGS + VARROA_THRESHOLD + FEED_STATUS_CRITICAL +} + +model AutomationRule { + id String @id @default(cuid()) + triggerType AutomationTriggerType @unique + active Boolean @default(true) + config Json? + actionTemplate String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 6ecd1db..c3c7bc7 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -30,6 +30,7 @@ import { NotificationsModule } from './notifications/notifications.module'; import { StatisticsModule } from './statistics/statistics.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { DocumentsModule } from './documents/documents.module'; +import { AutomationModule } from './automation/automation.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -65,6 +66,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; StatisticsModule, WebhooksModule, DocumentsModule, + AutomationModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/automation/automation.controller.ts b/ApiCore-Backend/src/automation/automation.controller.ts new file mode 100644 index 0000000..393a448 --- /dev/null +++ b/ApiCore-Backend/src/automation/automation.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Param, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Role } from '../../generated/prisma'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { AutomationService } from './automation.service'; +import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; + +@ApiTags('automation-rules') +@ApiBearerAuth() +@Roles(Role.ADMIN) +@Controller('automation-rules') +export class AutomationController { + constructor(private readonly automationService: AutomationService) {} + + @Get() + findAll() { + return this.automationService.findAll(); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateAutomationRuleDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.automationService.update(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/automation/automation.module.ts b/ApiCore-Backend/src/automation/automation.module.ts new file mode 100644 index 0000000..6c66f55 --- /dev/null +++ b/ApiCore-Backend/src/automation/automation.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { AutomationController } from './automation.controller'; +import { AutomationService } from './automation.service'; + +@Module({ + imports: [NotificationsModule], + controllers: [AutomationController], + providers: [AutomationService], + exports: [AutomationService], +}) +export class AutomationModule {} diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts new file mode 100644 index 0000000..4dccc79 --- /dev/null +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -0,0 +1,210 @@ +import { NotFoundException } from '@nestjs/common'; +import { AutomationService } from './automation.service'; +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'; + +describe('AutomationService', () => { + let service: AutomationService; + let prisma: { + automationRule: { + upsert: jest.Mock; + findMany: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + task: { create: jest.Mock }; + }; + let auditLog: { record: jest.Mock }; + let notifications: { record: jest.Mock }; + let webhooks: { dispatch: jest.Mock }; + + const queenlessRule = { + id: 'rule-1', + triggerType: 'QUEENLESS_NO_EGGS', + active: true, + config: {}, + actionTemplate: 'Kontrolle empfohlen', + }; + const feedRule = { + id: 'rule-2', + triggerType: 'FEED_STATUS_CRITICAL', + active: true, + config: {}, + actionTemplate: 'Fütterung erforderlich', + }; + const varroaRule = { + id: 'rule-3', + triggerType: 'VARROA_THRESHOLD', + active: true, + config: { thresholdMitesPerDay: 10 }, + actionTemplate: 'Behandlung prüfen', + }; + + beforeEach(() => { + prisma = { + automationRule: { + upsert: jest.fn().mockResolvedValue(undefined), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + task: { create: jest.fn().mockResolvedValue({ id: 'task-1' }) }, + }; + auditLog = { record: jest.fn() }; + notifications = { record: jest.fn() }; + webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + service = new AutomationService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + notifications as unknown as NotificationsService, + webhooks as unknown as WebhooksService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.automationRule.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('findAll seeds the three default rules before returning them', async () => { + prisma.automationRule.findMany.mockResolvedValue([ + queenlessRule, + varroaRule, + feedRule, + ]); + + const result = await service.findAll(); + + expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(3); + expect(result).toHaveLength(3); + }); + + describe('evaluateInspection', () => { + it('creates a task and notification when queen not seen and no eggs present', async () => { + prisma.automationRule.findMany.mockResolvedValue([queenlessRule]); + + await service.evaluateInspection({ + id: 'inspection-1', + colonyId: 'colony-1', + queenSeen: false, + eggsPresent: false, + feedStatus: null, + } as never); + + expect(prisma.task.create).toHaveBeenCalledTimes(1); + const [[taskArg]] = prisma.task.create.mock.calls as [ + [{ data: { relatedId: string; sourceInspectionId?: string } }], + ]; + expect(taskArg.data.relatedId).toBe('colony-1'); + expect(taskArg.data.sourceInspectionId).toBe('inspection-1'); + expect(notifications.record).toHaveBeenCalledTimes(1); + }); + + it('does not trigger when the queen was seen', async () => { + prisma.automationRule.findMany.mockResolvedValue([queenlessRule]); + + await service.evaluateInspection({ + id: 'inspection-2', + colonyId: 'colony-1', + queenSeen: true, + eggsPresent: false, + feedStatus: null, + } as never); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + + it('triggers when feed status is kritisch, case-insensitive', async () => { + prisma.automationRule.findMany.mockResolvedValue([feedRule]); + + await service.evaluateInspection({ + id: 'inspection-3', + colonyId: 'colony-1', + queenSeen: true, + eggsPresent: true, + feedStatus: 'Kritisch', + } as never); + + expect(prisma.task.create).toHaveBeenCalledTimes(1); + }); + + it('does not trigger a deactivated rule', async () => { + prisma.automationRule.findMany.mockResolvedValue([]); + + await service.evaluateInspection({ + id: 'inspection-4', + colonyId: 'colony-1', + queenSeen: false, + eggsPresent: false, + feedStatus: 'kritisch', + } as never); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + }); + + describe('evaluateVarroaMeasurement', () => { + it('creates a task when the calculated value meets the threshold', async () => { + prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + + await service.evaluateVarroaMeasurement({ + id: 'measurement-1', + colonyId: 'colony-1', + calculatedValue: 12, + miteCount: null, + } as never); + + expect(prisma.task.create).toHaveBeenCalledTimes(1); + }); + + it('does not trigger when below the threshold', async () => { + prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + + await service.evaluateVarroaMeasurement({ + id: 'measurement-2', + colonyId: 'colony-1', + calculatedValue: 5, + miteCount: null, + } as never); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + + it('falls back to miteCount when calculatedValue is null', async () => { + prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + + await service.evaluateVarroaMeasurement({ + id: 'measurement-3', + colonyId: 'colony-1', + calculatedValue: null, + miteCount: 15, + } as never); + + expect(prisma.task.create).toHaveBeenCalledTimes(1); + }); + }); + + describe('update', () => { + it('records an audit log entry with before/after data', async () => { + prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + prisma.automationRule.update.mockResolvedValue({ + ...varroaRule, + active: false, + }); + + await service.update('rule-3', { active: false }, 'user-1'); + + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'AutomationRule', + entityId: 'rule-3', + action: 'UPDATE', + }), + ); + }); + }); +}); diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts new file mode 100644 index 0000000..db26d5e --- /dev/null +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -0,0 +1,179 @@ +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, + Inspection, + Prisma, + VarroaMeasurement, +} from '../../generated/prisma'; +import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; + +interface RuleDefaults { + config: Record; + actionTemplate: string; +} + +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.', + }, +}; + +@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, + 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, + inspection.colonyId, + inspection.id, + ); + } + + const feedRule = ruleByType.get('FEED_STATUS_CRITICAL'); + if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') { + await this.triggerAction(feedRule, 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; + } + const config = rule.config as { thresholdMitesPerDay?: number } | null; + const threshold = config?.thresholdMitesPerDay ?? 10; + const value = measurement.calculatedValue ?? measurement.miteCount; + if (value !== null && value >= threshold) { + await this.triggerAction(rule, measurement.colonyId); + } + } + + private async triggerAction( + rule: AutomationRule, + colonyId: string, + sourceInspectionId?: string, + ): Promise { + const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; + const task = await this.prisma.task.create({ + data: { + title, + priority: 'HIGH', + relatedType: 'COLONY', + relatedId: colonyId, + sourceInspectionId, + }, + }); + await this.webhooks.dispatch('task.created', task); + await this.notifications.record({ + priority: 'WARNUNG', + message: title, + relatedType: 'COLONY', + relatedId: colonyId, + }); + await this.auditLog.record({ + entityType: 'AutomationRule', + entityId: rule.id, + action: 'CREATE', + afterData: { taskId: task.id, colonyId, triggerType: rule.triggerType }, + }); + } +} diff --git a/ApiCore-Backend/src/automation/dto/update-automation-rule.dto.ts b/ApiCore-Backend/src/automation/dto/update-automation-rule.dto.ts new file mode 100644 index 0000000..9dba527 --- /dev/null +++ b/ApiCore-Backend/src/automation/dto/update-automation-rule.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; + +export class UpdateAutomationRuleDto { + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + active?: boolean; + + @ApiPropertyOptional({ type: Object }) + @IsOptional() + @IsObject() + config?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + actionTemplate?: string; +} diff --git a/ApiCore-Backend/src/inspections/inspections.module.ts b/ApiCore-Backend/src/inspections/inspections.module.ts index 4d81fd9..427dfc7 100644 --- a/ApiCore-Backend/src/inspections/inspections.module.ts +++ b/ApiCore-Backend/src/inspections/inspections.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { AutomationModule } from '../automation/automation.module'; import { InspectionsController } from './inspections.controller'; import { ColonyInspectionsController } from './colony-inspections.controller'; import { InspectionsService } from './inspections.service'; @Module({ + imports: [AutomationModule], controllers: [InspectionsController, ColonyInspectionsController], providers: [InspectionsService], exports: [InspectionsService], diff --git a/ApiCore-Backend/src/inspections/inspections.service.spec.ts b/ApiCore-Backend/src/inspections/inspections.service.spec.ts index a75fbb8..04bf7a4 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.spec.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { InspectionsService } from './inspections.service'; import { PrismaService } from '../prisma/prisma.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { AutomationService } from '../automation/automation.service'; describe('InspectionsService', () => { let service: InspectionsService; @@ -30,9 +31,13 @@ describe('InspectionsService', () => { task: { create: jest.fn() }, }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + const automation = { + evaluateInspection: jest.fn().mockResolvedValue(undefined), + }; service = new InspectionsService( prisma as unknown as PrismaService, webhooks as unknown as WebhooksService, + automation as unknown as AutomationService, ); }); diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index a264e37..6695851 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { AutomationService } from '../automation/automation.service'; import { Inspection, Task } from '../../generated/prisma'; import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; @@ -10,6 +11,7 @@ export class InspectionsService { constructor( private readonly prisma: PrismaService, private readonly webhooks: WebhooksService, + private readonly automation: AutomationService, ) {} async findAll(): Promise { @@ -41,6 +43,7 @@ export class InspectionsService { data: { ...dto, date: new Date(dto.date), userId: actingUserId }, }); await this.webhooks.dispatch('inspection.created', inspection); + await this.automation.evaluateInspection(inspection); return inspection; } diff --git a/ApiCore-Backend/src/varroa/varroa.module.ts b/ApiCore-Backend/src/varroa/varroa.module.ts index db34ab6..5fc185c 100644 --- a/ApiCore-Backend/src/varroa/varroa.module.ts +++ b/ApiCore-Backend/src/varroa/varroa.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { AutomationModule } from '../automation/automation.module'; import { VarroaController } from './varroa.controller'; import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller'; import { VarroaService } from './varroa.service'; @Module({ + imports: [AutomationModule], controllers: [VarroaController, ColonyVarroaMeasurementsController], providers: [VarroaService], exports: [VarroaService], diff --git a/ApiCore-Backend/src/varroa/varroa.service.spec.ts b/ApiCore-Backend/src/varroa/varroa.service.spec.ts index 855ec12..f747506 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.spec.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { VarroaService } from './varroa.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { AutomationService } from '../automation/automation.service'; describe('VarroaService', () => { let service: VarroaService; @@ -29,9 +30,13 @@ describe('VarroaService', () => { }, }; auditLog = { record: jest.fn() }; + const automation = { + evaluateVarroaMeasurement: jest.fn().mockResolvedValue(undefined), + }; service = new VarroaService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + automation as unknown as AutomationService, ); }); diff --git a/ApiCore-Backend/src/varroa/varroa.service.ts b/ApiCore-Backend/src/varroa/varroa.service.ts index 09f1bc4..0b05d54 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { AutomationService } from '../automation/automation.service'; import { VarroaMeasurement } from '../../generated/prisma'; import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; @@ -9,6 +10,7 @@ export class VarroaService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly automation: AutomationService, ) {} async findAll(): Promise { @@ -48,6 +50,7 @@ export class VarroaService { userId: actingUserId, afterData: measurement, }); + await this.automation.evaluateVarroaMeasurement(measurement); return measurement; } } diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts new file mode 100644 index 0000000..2ca37c7 --- /dev/null +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -0,0 +1,220 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface AutomationRuleBody { + id: string; + triggerType: string; + active: boolean; + config: Record | null; + actionTemplate: string | null; +} + +describe('Automation rules (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const adminEmail = `automation-e2e-admin-${Date.now()}@example.com`; + const staffEmail = `automation-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let adminId: string; + let staffId: string; + let adminToken: string; + let staffToken: string; + let apiaryId: string; + let colonyId: string; + let varroaRuleId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const admin = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash, + name: 'Automation E2E Admin', + role: 'ADMIN', + }, + }); + adminId = admin.id; + const staff = await prisma.user.create({ + data: { + email: staffEmail, + passwordHash, + name: 'Automation E2E Staff', + role: 'STAFF', + }, + }); + staffId = staff.id; + adminToken = (await login(app, adminEmail, password)).accessToken; + staffToken = (await login(app, staffEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Automation Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `AUTO-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyId = colony.id; + }); + + afterAll(async () => { + await prisma.automationRule.updateMany({ + where: { triggerType: 'VARROA_THRESHOLD' }, + data: { active: true }, + }); + await prisma.task.deleteMany({ where: { relatedId: colonyId } }); + await prisma.notification.deleteMany({ where: { relatedId: colonyId } }); + await prisma.varroaMeasurement.deleteMany({ where: { colonyId } }); + await prisma.inspection.deleteMany({ where: { colonyId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [adminId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [adminId, staffId] } }, + }); + await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } }); + await app.close(); + }); + + it('rejects a non-admin listing automation rules', () => { + return request(app.getHttpServer()) + .get('/api/v1/automation-rules') + .set('Authorization', `Bearer ${staffToken}`) + .expect(403); + }); + + it('seeds the three fixed triggers for an admin', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/automation-rules') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + const rules = response.body as AutomationRuleBody[]; + const triggerTypes = rules.map((rule) => rule.triggerType).sort(); + expect(triggerTypes).toEqual([ + 'FEED_STATUS_CRITICAL', + 'QUEENLESS_NO_EGGS', + 'VARROA_THRESHOLD', + ]); + const varroaRule = rules.find( + (rule) => rule.triggerType === 'VARROA_THRESHOLD', + ); + expect(varroaRule).toBeDefined(); + varroaRuleId = varroaRule!.id; + }); + + it('creates a task and notification when queen not seen and no eggs present', async () => { + await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${staffToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + queenSeen: false, + eggsPresent: false, + }) + .expect(201); + + const tasks = await prisma.task.findMany({ + where: { relatedId: colonyId }, + }); + expect(tasks).toHaveLength(1); + expect(tasks[0].title).toContain('Kontrolle'); + + const notifications = await prisma.notification.findMany({ + where: { relatedId: colonyId }, + }); + expect(notifications).toHaveLength(1); + }); + + it('does not trigger when the queen was seen', async () => { + await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${staffToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + queenSeen: true, + eggsPresent: false, + }) + .expect(201); + + const tasks = await prisma.task.findMany({ + where: { relatedId: colonyId }, + }); + expect(tasks).toHaveLength(1); + }); + + it('triggers a feeding task when feed status is kritisch', async () => { + await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${staffToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + feedStatus: 'kritisch', + }) + .expect(201); + + const tasks = await prisma.task.findMany({ + where: { relatedId: colonyId }, + }); + expect(tasks).toHaveLength(2); + expect(tasks.some((task) => task.title.includes('Fütterung'))).toBe(true); + }); + + it('triggers a treatment check when the varroa threshold is exceeded', async () => { + await request(app.getHttpServer()) + .post('/api/v1/varroa-measurements') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + method: 'NATURAL_MITE_FALL', + miteCount: 20, + }) + .expect(201); + + const tasks = await prisma.task.findMany({ + where: { relatedId: colonyId }, + }); + expect(tasks).toHaveLength(3); + expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true); + }); + + it('lets an admin deactivate a rule, preventing further triggers', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/automation-rules/${varroaRuleId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ active: false }) + .expect(200); + + await request(app.getHttpServer()) + .post('/api/v1/varroa-measurements') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + method: 'NATURAL_MITE_FALL', + miteCount: 30, + }) + .expect(201); + + const tasks = await prisma.task.findMany({ + where: { relatedId: colonyId }, + }); + expect(tasks).toHaveLength(3); + }); +});