From cdd30cf3eee3a3d4fc850095d752b1e7e68d1dff Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 14 Oct 2026 11:36:38 +0200 Subject: [PATCH] feat: platform backbone and field capture complete (checkpoint) Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 120 +- ApiCore-Backend/package-lock.json | 24 +- ApiCore-Backend/package.json | 4 +- ApiCore-Backend/prisma/schema.prisma | 2 +- ApiCore-Backend/src/app.module.ts | 6 +- .../auth/permissions/permission-catalog.ts | 9 + .../automation/automation.handlers.spec.ts | 253 ++ .../src/automation/automation.handlers.ts | 118 + .../src/automation/automation.module.ts | 12 +- .../src/automation/automation.service.spec.ts | 892 ++++-- .../src/automation/automation.service.ts | 503 ++-- ApiCore-Backend/src/backup/backup-due.spec.ts | 82 + ApiCore-Backend/src/backup/backup-due.ts | 34 + .../backup/backup-scheduler.service.spec.ts | 327 ++- .../src/backup/backup-scheduler.service.ts | 216 +- .../src/backup/backup.controller.spec.ts | 44 +- .../src/backup/backup.controller.ts | 13 +- ApiCore-Backend/src/backup/backup.module.ts | 5 + .../src/bottling/bottling.service.spec.ts | 299 +- .../src/bottling/bottling.service.ts | 244 +- .../calendar-sync.handlers.spec.ts | 115 + .../calendar-sync/calendar-sync.handlers.ts | 77 + .../src/calendar-sync/calendar-sync.module.ts | 10 +- .../calendar-sync.service.spec.ts | 153 +- .../calendar-sync/calendar-sync.service.ts | 135 +- ApiCore-Backend/src/cli.spec.ts | 61 + ApiCore-Backend/src/cli.ts | 26 + .../src/colonies/colonies.module.ts | 2 - .../src/colonies/colonies.service.spec.ts | 248 +- .../src/colonies/colonies.service.ts | 213 +- .../src/colonies/colony-events.spec.ts | 72 + ApiCore-Backend/src/colonies/colony-events.ts | 74 + .../src/common/after-commit-usage.spec.ts | 86 + .../bulk/assert-living-colonies.spec.ts | 111 + .../src/common/bulk/assert-living-colonies.ts | 41 + .../src/common/bulk/bulk-create.ts | 21 + .../src/common/bulk/bulk-limits.ts | 13 + .../src/common/bulk/bulk-validation.ts | 84 + .../src/common/bulk/run-bulk.spec.ts | 137 + ApiCore-Backend/src/common/bulk/run-bulk.ts | 51 + .../idempotency-cleanup.service.spec.ts | 105 +- .../idempotency-cleanup.service.ts | 55 +- .../throttler/prisma-throttler.storage.ts | 14 +- .../src/domain-events/domain-event-types.ts | 23 +- .../domain-events.service.spec.ts | 16 + .../feedings/dto/bulk-create-feeding.dto.ts | 11 + .../src/feedings/feedings.controller.ts | 12 + .../src/feedings/feedings.module.ts | 2 - .../src/feedings/feedings.service.spec.ts | 204 +- .../src/feedings/feedings.service.ts | 137 +- .../src/harvests/harvests.service.spec.ts | 108 +- .../src/harvests/harvests.service.ts | 111 +- .../src/inspections/inspections.module.ts | 8 +- .../inspections/inspections.service.spec.ts | 252 +- .../src/inspections/inspections.service.ts | 164 +- .../src/jobs/app-backup.job.spec.ts | 48 + ApiCore-Backend/src/jobs/app-backup.job.ts | 21 + ApiCore-Backend/src/jobs/cleanup.job.spec.ts | 140 + ApiCore-Backend/src/jobs/cleanup.job.ts | 78 + .../src/jobs/daily-evaluation.job.spec.ts | 146 + .../src/jobs/daily-evaluation.job.ts | 72 + .../src/jobs/daily-evaluation.rules.spec.ts | 447 +++ .../src/jobs/daily-evaluation.rules.ts | 337 +++ ApiCore-Backend/src/jobs/job-names.spec.ts | 39 + ApiCore-Backend/src/jobs/job-names.ts | 52 + ApiCore-Backend/src/jobs/job-registry.spec.ts | 52 + ApiCore-Backend/src/jobs/job-registry.ts | 47 + ApiCore-Backend/src/jobs/job.ts | 17 + ApiCore-Backend/src/jobs/jobs.module.ts | 73 + ApiCore-Backend/src/jobs/rekey.job.spec.ts | 569 ++++ ApiCore-Backend/src/jobs/rekey.job.ts | 265 ++ ApiCore-Backend/src/jobs/run-cli.spec.ts | 216 ++ ApiCore-Backend/src/jobs/run-cli.ts | 92 + .../src/jobs/task-templates.job.ts | 21 + ApiCore-Backend/src/prisma/row-lock.spec.ts | 145 + ApiCore-Backend/src/prisma/row-lock.ts | 90 + .../production-batches.service.spec.ts | 299 ++ .../production-batches.service.ts | 176 +- .../raw-materials.service.spec.ts | 205 ++ .../raw-materials/raw-materials.service.ts | 105 +- .../runtime/no-in-process-schedulers.spec.ts | 54 + .../src/runtime/root-module.spec.ts | 32 +- .../src/stock/stock.service.spec.ts | 517 ++++ ApiCore-Backend/src/stock/stock.service.ts | 260 +- .../src/system/dto/system-list-query.dto.ts | 32 + .../src/system/job-run.service.spec.ts | 287 ++ ApiCore-Backend/src/system/job-run.service.ts | 187 ++ .../src/system/system-queue.service.spec.ts | 207 ++ .../src/system/system-queue.service.ts | 291 ++ .../src/system/system.controller.ts | 87 + ApiCore-Backend/src/system/system.module.ts | 17 + .../task-templates.service.spec.ts | 47 - .../task-templates/task-templates.service.ts | 57 +- ApiCore-Backend/src/tasks/tasks.module.ts | 2 - .../src/tasks/tasks.service.spec.ts | 522 +++- ApiCore-Backend/src/tasks/tasks.service.ts | 371 ++- .../dto/bulk-create-treatment.dto.ts | 16 + .../src/treatments/treatments.controller.ts | 12 + .../src/treatments/treatments.module.ts | 2 - .../src/treatments/treatments.service.spec.ts | 301 +- .../src/treatments/treatments.service.ts | 201 +- ...bulk-create-varroa-measurement.dto.spec.ts | 72 + .../dto/bulk-create-varroa-measurement.dto.ts | 35 + .../src/varroa/varroa.controller.ts | 12 + ApiCore-Backend/src/varroa/varroa.module.ts | 7 +- .../src/varroa/varroa.service.spec.ts | 359 ++- ApiCore-Backend/src/varroa/varroa.service.ts | 180 +- .../dto/list-webhook-deliveries.dto.ts | 21 + .../src/webhooks/webhook-delivery.spec.ts | 134 + .../src/webhooks/webhook-delivery.ts | 26 +- .../webhooks/webhook-delivery.worker.spec.ts | 561 ++++ .../src/webhooks/webhook-delivery.worker.ts | 285 ++ .../src/webhooks/webhook-events.ts | 3 + .../src/webhooks/webhook-secret.spec.ts | 9 + .../src/webhooks/webhook-secret.ts | 5 + .../src/webhooks/webhook.handlers.spec.ts | 273 ++ .../src/webhooks/webhook.handlers.ts | 117 + .../src/webhooks/webhooks.controller.ts | 18 + .../src/webhooks/webhooks.module.ts | 11 +- .../src/webhooks/webhooks.service.spec.ts | 285 +- .../src/webhooks/webhooks.service.ts | 131 +- .../winter-checks.service.spec.ts | 84 +- .../winter-checks/winter-checks.service.ts | 52 +- .../src/worker/worker-runtime.module.ts | 4 +- .../src/worker/worker-runtime.service.spec.ts | 58 +- .../src/worker/worker-runtime.service.ts | 19 + .../test/app-backup-job.e2e-spec.ts | 137 + ApiCore-Backend/test/automation.e2e-spec.ts | 254 +- ApiCore-Backend/test/bottling.e2e-spec.ts | 139 +- .../test/calendar-sync.e2e-spec.ts | 33 + ApiCore-Backend/test/colonies.e2e-spec.ts | 129 +- .../test/daily-evaluation.e2e-spec.ts | 252 ++ .../test/data-integrity.e2e-spec.ts | 101 + ApiCore-Backend/test/feedings.e2e-spec.ts | 186 ++ .../test/follow-up-controls.e2e-spec.ts | 50 +- ApiCore-Backend/test/harvests.e2e-spec.ts | 86 + ApiCore-Backend/test/inspections.e2e-spec.ts | 97 + ApiCore-Backend/test/jest-e2e.json | 2 +- ApiCore-Backend/test/jobs.e2e-spec.ts | 415 +++ .../test/post-commit-side-effects.e2e-spec.ts | 332 ++- .../test/raw-materials.e2e-spec.ts | 223 +- ApiCore-Backend/test/rekey.e2e-spec.ts | 329 +++ ApiCore-Backend/test/stock.e2e-spec.ts | 146 +- ApiCore-Backend/test/system.e2e-spec.ts | 237 ++ .../test/task-templates.e2e-spec.ts | 52 +- ApiCore-Backend/test/tasks.e2e-spec.ts | 93 +- ApiCore-Backend/test/throttler.e2e-spec.ts | 49 + ApiCore-Backend/test/treatments.e2e-spec.ts | 289 ++ ApiCore-Backend/test/utils/webhooks.ts | 15 + ApiCore-Backend/test/varroa.e2e-spec.ts | 259 ++ ApiCore-Backend/test/webhooks.e2e-spec.ts | 467 ++- .../test/winter-checks.e2e-spec.ts | 15 + .../test/worker-runtime.e2e-spec.ts | 22 + ApiCore-Frontend/public/sw-routes.js | 250 ++ ApiCore-Frontend/public/sw.js | 543 +++- .../[id]/apiary-colonies-card.spec.tsx | 18 + .../apiaries/[id]/apiary-colonies-card.tsx | 72 +- .../colonies/[id]/edit-dialog-parts.spec.tsx | 48 + .../(app)/colonies/[id]/edit-dialog-parts.tsx | 25 +- .../(app)/feedings/new/feeding-form-values.ts | 26 +- .../src/app/(app)/feedings/new/page.spec.tsx | 86 +- .../src/app/(app)/feedings/new/page.tsx | 115 +- .../app/(app)/inspections/new/page.spec.tsx | 253 +- .../src/app/(app)/inspections/new/page.tsx | 159 +- .../src/app/(app)/layout.spec.tsx | 93 +- ApiCore-Frontend/src/app/(app)/layout.tsx | 36 +- .../src/app/(app)/nav-config.spec.ts | 5 + ApiCore-Frontend/src/app/(app)/nav-config.ts | 7 + .../app/(app)/offline/apiary/page.spec.tsx | 86 + .../src/app/(app)/offline/apiary/page.tsx | 102 + .../app/(app)/offline/colony/page.spec.tsx | 219 ++ .../src/app/(app)/offline/colony/page.tsx | 293 ++ .../src/app/(app)/offline/offline-parts.tsx | 142 + .../src/app/(app)/offline/page.spec.tsx | 73 + .../src/app/(app)/offline/page.tsx | 96 + .../profile/danger-zone-section.spec.tsx | 58 + .../app/(app)/profile/danger-zone-section.tsx | 3 +- .../webhook-deliveries-dialog.spec.tsx | 82 + .../automation/webhook-deliveries-dialog.tsx | 98 + .../settings/automation/webhooks-tab.tsx | 2 + .../app/(app)/settings/backup/page.spec.tsx | 61 + .../src/app/(app)/settings/backup/page.tsx | 31 +- .../src/app/(app)/settings/layout.spec.tsx | 12 + .../src/app/(app)/settings/layout.tsx | 1 + .../app/(app)/settings/system/page.spec.tsx | 206 ++ .../src/app/(app)/settings/system/page.tsx | 310 ++ .../app/(app)/treatments/new/page.spec.tsx | 187 +- .../src/app/(app)/treatments/new/page.tsx | 108 +- .../treatments/new/treatment-form-values.ts | 26 +- .../src/app/(app)/varroa/new/page.spec.tsx | 140 +- .../src/app/(app)/varroa/new/page.tsx | 204 +- .../(app)/varroa/new/varroa-form-values.ts | 30 + .../src/app/(auth)/login/page.spec.tsx | 36 +- .../src/app/(auth)/login/page.tsx | 18 +- .../auth/logout-confirm-dialog.spec.tsx | 190 ++ .../components/auth/logout-confirm-dialog.tsx | 170 ++ .../components/colony-select-field.spec.tsx | 72 + .../src/components/colony-select-field.tsx | 82 +- .../src/components/form/bulk-submit-note.tsx | 69 + .../components/form/capture-colony-field.tsx | 61 + .../components/form/capture-target-toggle.tsx | 39 + .../form/colony-multi-select.spec.tsx | 233 ++ .../components/form/colony-multi-select.tsx | 180 ++ .../offline/load-pack-button.spec.tsx | 149 + .../components/offline/load-pack-button.tsx | 127 + .../components/offline/offline-band.spec.tsx | 43 +- .../src/components/offline/offline-band.tsx | 33 +- .../offline/pending-queue-sheet.spec.tsx | 75 +- .../offline/pending-queue-sheet.tsx | 74 +- .../photos/photo-capture-field.spec.tsx | 25 +- .../components/photos/photo-capture-field.tsx | 32 +- .../components/varroa/bulk-mite-counts.tsx | 89 + .../src/lib/apiaries/field-api.spec.ts | 63 + .../src/lib/apiaries/field-api.ts | 22 +- .../src/lib/apiaries/offline-pack.fixtures.ts | 151 + .../src/lib/apiaries/offline-pack.spec.ts | 310 ++ .../src/lib/apiaries/offline-pack.ts | 266 ++ .../src/lib/apiaries/use-colony-overview.ts | 12 +- .../src/lib/auth/api-client.spec.ts | 66 +- ApiCore-Frontend/src/lib/auth/api-client.ts | 11 +- ApiCore-Frontend/src/lib/auth/api.spec.ts | 41 +- ApiCore-Frontend/src/lib/auth/api.ts | 8 +- .../src/lib/auth/auth-context.spec.tsx | 658 +++++ .../src/lib/auth/auth-context.tsx | 491 +++- .../src/lib/auth/legacy-queue.spec.ts | 44 + ApiCore-Frontend/src/lib/auth/legacy-queue.ts | 34 + .../src/lib/auth/offline-identity.spec.ts | 106 + .../src/lib/auth/offline-identity.ts | 130 + .../src/lib/auth/session-cleanup.spec.ts | 164 ++ .../src/lib/auth/session-cleanup.ts | 119 + ApiCore-Frontend/src/lib/auth/token-store.ts | 22 + ApiCore-Frontend/src/lib/backup/types.ts | 10 + ApiCore-Frontend/src/lib/bulk/api.spec.ts | 131 + ApiCore-Frontend/src/lib/bulk/api.ts | 102 + .../src/lib/bulk/bulk-input.spec.ts | 47 + ApiCore-Frontend/src/lib/bulk/bulk-input.ts | 43 + .../src/lib/bulk/capture-texts.spec.ts | 39 + .../src/lib/bulk/capture-texts.ts | 32 + .../src/lib/bulk/use-apiary-colonies.ts | 101 + .../src/lib/bulk/use-bulk-capture.ts | 64 + .../src/lib/forms/draft-storage.spec.ts | 21 +- .../src/lib/forms/draft-storage.ts | 33 + .../src/lib/forms/use-form-draft.spec.ts | 31 +- .../src/lib/forms/use-form-draft.ts | 24 +- .../src/lib/http/fetch-with-timeout.spec.ts | 27 + .../src/lib/http/fetch-with-timeout.ts | 13 +- .../src/lib/inspections/takeover.ts | 7 +- .../inspections/use-last-inspection.spec.tsx | 90 + .../lib/inspections/use-last-inspection.ts | 38 +- ApiCore-Frontend/src/lib/offline/db.spec.ts | 148 + ApiCore-Frontend/src/lib/offline/db.ts | 111 +- .../src/lib/offline/null-roundtrip.spec.ts | 1 + .../src/lib/offline/pack-fallback.spec.ts | 73 + .../src/lib/offline/pack-fallback.ts | 32 + .../src/lib/offline/queue-texts.spec.ts | 20 + .../src/lib/offline/queue-texts.ts | 20 + .../src/lib/offline/return-path.spec.ts | 32 + .../src/lib/offline/return-path.ts | 27 + .../src/lib/offline/sw-routes.spec.ts | 272 ++ ApiCore-Frontend/src/lib/offline/sw.spec.ts | 338 +++ .../src/lib/offline/sync-bulk.spec.ts | 286 ++ ApiCore-Frontend/src/lib/offline/sync.spec.ts | 158 + ApiCore-Frontend/src/lib/offline/sync.ts | 198 +- .../lib/offline/use-capture-mutation.spec.tsx | 66 + .../src/lib/offline/use-capture-mutation.ts | 39 +- .../src/lib/photos/handover.spec.ts | 87 + ApiCore-Frontend/src/lib/photos/handover.ts | 113 + .../src/lib/photos/reencode.spec.ts | 57 +- ApiCore-Frontend/src/lib/photos/reencode.ts | 23 +- ApiCore-Frontend/src/lib/photos/store.spec.ts | 221 +- ApiCore-Frontend/src/lib/photos/store.ts | 271 +- ApiCore-Frontend/src/lib/system/api.ts | 37 + .../src/lib/system/labels.spec.ts | 43 + ApiCore-Frontend/src/lib/system/labels.ts | 77 + ApiCore-Frontend/src/lib/system/types.ts | 54 + ApiCore-Frontend/src/lib/webhooks/api.ts | 7 + ApiCore-Frontend/src/lib/webhooks/labels.ts | 4 + ApiCore-Frontend/src/lib/webhooks/types.ts | 17 + TASKS.md | 33 + .../plans/2026-10-13-plattform-rueckgrat.md | 18 +- .../plans/2026-10-13-t3-erfassung-am-stand.md | 4 + .../plans/2026-10-13-t4-logik-ketten.md | 2532 +++++++++++++++++ docs/superpowers/plans/2026-10-14-t5-optik.md | 945 ++++++ .../plans/2026-10-14-t6-compliance.md | 1402 +++++++++ .../2026-10-13-t4-logik-ketten-design.md | 1383 +++++++++ .../specs/2026-10-13-t5-optik-design.md | 1166 ++++++++ .../specs/2026-10-13-t6-compliance-design.md | 852 ++++++ .../specs/2026-10-14-t7-sprache-design.md | 594 ++++ 288 files changed, 39165 insertions(+), 2853 deletions(-) create mode 100644 ApiCore-Backend/src/automation/automation.handlers.spec.ts create mode 100644 ApiCore-Backend/src/automation/automation.handlers.ts create mode 100644 ApiCore-Backend/src/backup/backup-due.spec.ts create mode 100644 ApiCore-Backend/src/backup/backup-due.ts create mode 100644 ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts create mode 100644 ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts create mode 100644 ApiCore-Backend/src/cli.spec.ts create mode 100644 ApiCore-Backend/src/cli.ts create mode 100644 ApiCore-Backend/src/colonies/colony-events.spec.ts create mode 100644 ApiCore-Backend/src/colonies/colony-events.ts create mode 100644 ApiCore-Backend/src/common/after-commit-usage.spec.ts create mode 100644 ApiCore-Backend/src/common/bulk/assert-living-colonies.spec.ts create mode 100644 ApiCore-Backend/src/common/bulk/assert-living-colonies.ts create mode 100644 ApiCore-Backend/src/common/bulk/bulk-create.ts create mode 100644 ApiCore-Backend/src/common/bulk/bulk-limits.ts create mode 100644 ApiCore-Backend/src/common/bulk/bulk-validation.ts create mode 100644 ApiCore-Backend/src/common/bulk/run-bulk.spec.ts create mode 100644 ApiCore-Backend/src/common/bulk/run-bulk.ts create mode 100644 ApiCore-Backend/src/feedings/dto/bulk-create-feeding.dto.ts create mode 100644 ApiCore-Backend/src/jobs/app-backup.job.spec.ts create mode 100644 ApiCore-Backend/src/jobs/app-backup.job.ts create mode 100644 ApiCore-Backend/src/jobs/cleanup.job.spec.ts create mode 100644 ApiCore-Backend/src/jobs/cleanup.job.ts create mode 100644 ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts create mode 100644 ApiCore-Backend/src/jobs/daily-evaluation.job.ts create mode 100644 ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts create mode 100644 ApiCore-Backend/src/jobs/daily-evaluation.rules.ts create mode 100644 ApiCore-Backend/src/jobs/job-names.spec.ts create mode 100644 ApiCore-Backend/src/jobs/job-names.ts create mode 100644 ApiCore-Backend/src/jobs/job-registry.spec.ts create mode 100644 ApiCore-Backend/src/jobs/job-registry.ts create mode 100644 ApiCore-Backend/src/jobs/job.ts create mode 100644 ApiCore-Backend/src/jobs/jobs.module.ts create mode 100644 ApiCore-Backend/src/jobs/rekey.job.spec.ts create mode 100644 ApiCore-Backend/src/jobs/rekey.job.ts create mode 100644 ApiCore-Backend/src/jobs/run-cli.spec.ts create mode 100644 ApiCore-Backend/src/jobs/run-cli.ts create mode 100644 ApiCore-Backend/src/jobs/task-templates.job.ts create mode 100644 ApiCore-Backend/src/prisma/row-lock.spec.ts create mode 100644 ApiCore-Backend/src/prisma/row-lock.ts create mode 100644 ApiCore-Backend/src/production-batches/production-batches.service.spec.ts create mode 100644 ApiCore-Backend/src/raw-materials/raw-materials.service.spec.ts create mode 100644 ApiCore-Backend/src/runtime/no-in-process-schedulers.spec.ts create mode 100644 ApiCore-Backend/src/stock/stock.service.spec.ts create mode 100644 ApiCore-Backend/src/system/dto/system-list-query.dto.ts create mode 100644 ApiCore-Backend/src/system/job-run.service.spec.ts create mode 100644 ApiCore-Backend/src/system/job-run.service.ts create mode 100644 ApiCore-Backend/src/system/system-queue.service.spec.ts create mode 100644 ApiCore-Backend/src/system/system-queue.service.ts create mode 100644 ApiCore-Backend/src/system/system.controller.ts create mode 100644 ApiCore-Backend/src/system/system.module.ts create mode 100644 ApiCore-Backend/src/treatments/dto/bulk-create-treatment.dto.ts create mode 100644 ApiCore-Backend/src/varroa/dto/bulk-create-varroa-measurement.dto.spec.ts create mode 100644 ApiCore-Backend/src/varroa/dto/bulk-create-varroa-measurement.dto.ts create mode 100644 ApiCore-Backend/src/webhooks/dto/list-webhook-deliveries.dto.ts create mode 100644 ApiCore-Backend/src/webhooks/webhook-delivery.spec.ts create mode 100644 ApiCore-Backend/src/webhooks/webhook-delivery.worker.spec.ts create mode 100644 ApiCore-Backend/src/webhooks/webhook-delivery.worker.ts create mode 100644 ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts create mode 100644 ApiCore-Backend/src/webhooks/webhook.handlers.ts create mode 100644 ApiCore-Backend/test/app-backup-job.e2e-spec.ts create mode 100644 ApiCore-Backend/test/daily-evaluation.e2e-spec.ts create mode 100644 ApiCore-Backend/test/jobs.e2e-spec.ts create mode 100644 ApiCore-Backend/test/rekey.e2e-spec.ts create mode 100644 ApiCore-Backend/test/system.e2e-spec.ts create mode 100644 ApiCore-Backend/test/utils/webhooks.ts create mode 100644 ApiCore-Frontend/public/sw-routes.js create mode 100644 ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/offline/apiary/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/offline/apiary/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/offline/colony/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/offline/colony/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/offline/offline-parts.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/offline/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/offline/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/webhook-deliveries-dialog.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/webhook-deliveries-dialog.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/system/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/system/page.tsx create mode 100644 ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx create mode 100644 ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx create mode 100644 ApiCore-Frontend/src/components/form/bulk-submit-note.tsx create mode 100644 ApiCore-Frontend/src/components/form/capture-colony-field.tsx create mode 100644 ApiCore-Frontend/src/components/form/capture-target-toggle.tsx create mode 100644 ApiCore-Frontend/src/components/form/colony-multi-select.spec.tsx create mode 100644 ApiCore-Frontend/src/components/form/colony-multi-select.tsx create mode 100644 ApiCore-Frontend/src/components/offline/load-pack-button.spec.tsx create mode 100644 ApiCore-Frontend/src/components/offline/load-pack-button.tsx create mode 100644 ApiCore-Frontend/src/components/varroa/bulk-mite-counts.tsx create mode 100644 ApiCore-Frontend/src/lib/apiaries/field-api.spec.ts create mode 100644 ApiCore-Frontend/src/lib/apiaries/offline-pack.fixtures.ts create mode 100644 ApiCore-Frontend/src/lib/apiaries/offline-pack.spec.ts create mode 100644 ApiCore-Frontend/src/lib/apiaries/offline-pack.ts create mode 100644 ApiCore-Frontend/src/lib/auth/auth-context.spec.tsx create mode 100644 ApiCore-Frontend/src/lib/auth/legacy-queue.spec.ts create mode 100644 ApiCore-Frontend/src/lib/auth/legacy-queue.ts create mode 100644 ApiCore-Frontend/src/lib/auth/offline-identity.spec.ts create mode 100644 ApiCore-Frontend/src/lib/auth/offline-identity.ts create mode 100644 ApiCore-Frontend/src/lib/auth/session-cleanup.spec.ts create mode 100644 ApiCore-Frontend/src/lib/auth/session-cleanup.ts create mode 100644 ApiCore-Frontend/src/lib/bulk/api.spec.ts create mode 100644 ApiCore-Frontend/src/lib/bulk/api.ts create mode 100644 ApiCore-Frontend/src/lib/bulk/bulk-input.spec.ts create mode 100644 ApiCore-Frontend/src/lib/bulk/bulk-input.ts create mode 100644 ApiCore-Frontend/src/lib/bulk/capture-texts.spec.ts create mode 100644 ApiCore-Frontend/src/lib/bulk/capture-texts.ts create mode 100644 ApiCore-Frontend/src/lib/bulk/use-apiary-colonies.ts create mode 100644 ApiCore-Frontend/src/lib/bulk/use-bulk-capture.ts create mode 100644 ApiCore-Frontend/src/lib/inspections/use-last-inspection.spec.tsx create mode 100644 ApiCore-Frontend/src/lib/offline/pack-fallback.spec.ts create mode 100644 ApiCore-Frontend/src/lib/offline/pack-fallback.ts create mode 100644 ApiCore-Frontend/src/lib/offline/return-path.spec.ts create mode 100644 ApiCore-Frontend/src/lib/offline/return-path.ts create mode 100644 ApiCore-Frontend/src/lib/offline/sw-routes.spec.ts create mode 100644 ApiCore-Frontend/src/lib/offline/sw.spec.ts create mode 100644 ApiCore-Frontend/src/lib/offline/sync-bulk.spec.ts create mode 100644 ApiCore-Frontend/src/lib/photos/handover.spec.ts create mode 100644 ApiCore-Frontend/src/lib/photos/handover.ts create mode 100644 ApiCore-Frontend/src/lib/system/api.ts create mode 100644 ApiCore-Frontend/src/lib/system/labels.spec.ts create mode 100644 ApiCore-Frontend/src/lib/system/labels.ts create mode 100644 ApiCore-Frontend/src/lib/system/types.ts create mode 100644 docs/superpowers/plans/2026-10-13-t4-logik-ketten.md create mode 100644 docs/superpowers/plans/2026-10-14-t5-optik.md create mode 100644 docs/superpowers/plans/2026-10-14-t6-compliance.md create mode 100644 docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md create mode 100644 docs/superpowers/specs/2026-10-13-t5-optik-design.md create mode 100644 docs/superpowers/specs/2026-10-13-t6-compliance-design.md create mode 100644 docs/superpowers/specs/2026-10-14-t7-sprache-design.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 13bc76b..46c7087 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -50,10 +50,20 @@ app/ │ ├── honey-batches/[id]/ │ ├── storage/ │ ├── search/ -│ └── settings/users/ nur Administrator +│ ├── offline/ Offline-Seiten (Start, apiary?id=, colony?id=) +│ ├── nav-config.ts einzige Quelle aller Navigationspunkte +│ └── settings/ +│ ├── users-roles/ nur Administrator +│ └── system/ „Verwaltung > System“ (Jobs, Ereignisse, Laufrollen) ``` -Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet Auswahl-Sheet (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld. +Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet ein Auswahl-Sheet mit Icons und 56-px-Zeilen (auch am Desktop; `quick-add-sheet.tsx`, Einträge aus `quick-actions.ts`) (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld. + +**Navigation aus einer Quelle:** `app/(app)/nav-config.ts` ist die einzige Quelle für Seitenleiste, Bottom-Nav, „Mehr“-Sheet und „+ Neu“; der Berechtigungsfilter gilt überall gleich. Neue Seiten (auch „Verwaltung > System“) werden nur dort eingetragen. Auf allen `new`-Seiten führt ein Zurück-Link zur Liste, bei einem Entwurf mit Rückfrage. „Mehr“ und „+ Neu“ teilen sich die Sheet-Hülle `components/nav/nav-sheet.tsx`. + +**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/`, `/colonies/`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`). + +**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht. Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein direkter DB-Zugriff, kein Next.js-eigenes Datenbank-Layer) – erzwingt API-first auch praktisch. @@ -93,15 +103,15 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 2. **Treatment auf Einzelvolk statt "Völkergruppe"**: Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert `Treatment` pro Volk (`colonyId` Pflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicher `batchNumber`/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eine `TreatmentGroup`-Entität nachziehen, falls das in der Praxis zu unhandlich ist. 3. **Lagerverwaltung MVP-Umfang**: "grundlegende Lagerverwaltung" (Abschnitt 26 Punkt 12) wird als generisches `StockItem` + `StockMovement` (Kategorie Honig/Verbrauchsmaterial/Imkereimaterial) umgesetzt. Das detaillierte Inventar mit Wartungsdaten (Abschnitt 4.22) bleibt v1.x/"SOLL", wie im Lastenheft vorgesehen. 4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN. -5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung. +5. **Backup-fähige Struktur**: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (`BackupModule`, Abschnitt 8) und der CronJob `apicore-db-backup` mit `pg_dump` (Abschnitt 7), `/health`-Endpoint für Betriebsüberwachung. 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. 8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. -9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. +9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Nur die Königin-Zuordnung reiht noch nach dem Commit über `CalendarSyncService.enqueue` ein (Annahme 11). Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. 10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. -11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`). -12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. +11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`), den Webhook `colony.created` und den Kalendereintrag der Königin-Zuordnung (`CalendarSyncService.enqueue`). Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`, `queen.replaced`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`. +12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Geprüft wird unter einer Zeilensperre auf der Regel (`SELECT … FOR UPDATE`), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Ihr Standardwert 10 und der Rückfall bei unbrauchbarer Einstellung kommen allein aus `varroaThresholdOf` (`automation/varroa-threshold.ts`). Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Automatik-Aufgaben laufen wie jede andere Aufgabe über `TasksService.createInTx(tx, …, null)`: Audit-Eintrag ohne Benutzer und `TaskCreated`. Aus dem Ereignis folgen Kalendereintrag (`calendar-sync.task`) und Webhook `task.created` (`webhooks.forward`); die Regel ruft `WebhooksService` nicht selbst auf. Aufgabe, Warnung (`Notification`, Priorität `WARNUNG`) und der Audit-Eintrag der Regel (`AutomationRule CREATE`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie. 14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`): Ein Schlüssel mit beiden Rechten wird beim Anlegen mit 400 abgelehnt; bereits ausgestellte kombinierte Schlüssel bleiben gültig, die Oberfläche markiert sie und empfiehlt zwei getrennte Schlüssel (neue anlegen, in Wabenhain eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). @@ -114,21 +124,15 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist. 22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:..` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore--v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. 23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden. -**Rotation über `*_PREVIOUS`:** alten Wert nach `_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `` setzen, Job `rekey` laufen lassen, dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen. +**Rotation über `*_PREVIOUS`:** alten Wert nach `_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen. **Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar). - -## Concerns - -1. **Rollout order (blocking for production).** The live `apicore-backend-secret` must contain `MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY` and `BACKUP_ENCRYPTION_KEY` *before* this image runs with `NODE_ENV=production`. Without them, api, worker and job all refuse to start (by design). - - The three keys must be distinct and must not equal the JWT secrets. - - The pods get them via `envFrom: secretRef`, so no manifest change is needed. - - Per the owner's rule, the user creates the secrets. -2. **JWT rotation after Task 21 will strand old backup files.** Backups sealed before `BACKUP_ENCRYPTION_KEY` was set (JWT-derived keyId) become unrestorable once `JWT_ACCESS_SECRET` rotates. - - Remedy: take a fresh export after setting the key, before rotating. This is in the Annahme 22 text. - - Task 21 or the runbook should state this ordering explicitly. -3. **Dev-only edge.** Values sealed with the dev fallback (no env key set) become unreadable if a developer later sets an explicit key: the fallback cannot be named as `*_PREVIOUS`. This does not affect production, which never uses the fallback. -4. **Not checked in production:** `*_PREVIOUS` values are not checked for equality with the JWT or other keys; only their length is enforced. This matches the brief, which limits that rule to the three main variables. -5. **Not run:** whole-project `npx eslint "{src,test}/**/*.ts"` (I linted only my files). Typecheck was project-wide and clean. +**Job `rekey`** (`src/jobs/rekey.job.ts`, nur von Hand: `kubectl create job --from=cronjob/apicore-job-rekey` bzw. `…-rekey-dry-run`): versiegelt jedes MFA-Secret (`User.mfaSecret`) und jedes Webhook-Secret (`WebhookSubscription.secret`), das nicht mit dem aktuellen Schlüssel seines Zwecks versiegelt ist, neu – Werte unter `*_PREVIOUS`, Altchiffrate und Webhook-Secrets im Klartext. Zeile für Zeile, ohne Transaktion um den Lauf: Jede Schreiboperation ist ein Compare-and-Swap auf den gelesenen Wert (`UPDATE … WHERE id = … AND "mfaSecret" = `, bei Webhooks `secret`), damit überschreibt der Job nie, was die App inzwischen geschrieben hat (z. B. eine neue MFA-Einrichtung). Solche Zeilen zählt er als `changedMeanwhile`; `changedMeanwhile > 0` ist ein harter Stopp, kein Erfolg: die Zeile kann noch mit dem alten Schlüssel versiegelt sein, darum Dry-Run (und bei Bedarf Lauf) wiederholen, bis alle Zähler 0 sind, erst dann rotieren. Vor dem Schreiben prüft der Job, dass der neue Wert zum selben Klartext öffnet. Ein abgebrochener Lauf behält, was er umgestellt hat; der nächste überspringt diese Zeilen ungeöffnet (`alreadyCurrent`). Ergebnis: `dryRun` und je Zweck `mfa.*`/`webhook.*` mit `total`, `rekeyed`, `alreadyCurrent`, `plaintextSealed`, `changedMeanwhile`, `failed`. Eine Zeile, die sich nicht öffnen oder schreiben lässt, behält ihren Wert; der Lauf macht weiter und endet bei `failed > 0` immer `FAILED` (`JobRun`, Zähler im Fehlertext, das Log nennt nur Zeilen-ID und Grund, z. B. `unknown key id`). `rekey --dry-run` schreibt nichts und öffnet jeden umzustellenden Wert, anders als der echte Lauf auch die Zeilen, die schon den aktuellen Schlüssel tragen (ein beschädigter Wert zählt als `failed`). Andere Argumente lehnen `dist/cli.js` (Exit 2) und der Job selbst ab. Werte, Chiffrate und Schlüssel-IDs erscheinen nie in Log oder `JobRun`. `updatedAt` umgestellter Zeilen ändert sich. Den Klartext-Rückfall in `openWebhookSecret` kann man entfernen, sobald der Dry-Run in Produktion `webhook.plaintextSealed: 0` zeigt. +24. **Offline-Standmodus**: „Stand laden“ legt ein Paket je Stand in IndexedDB ab (`GET /apiaries/:id/offline-pack`: lebende Völker, Königinnen, die letzten 3 Durchsichten je Volk, offene Aufgaben). Es enthält weder Koordinaten noch Adresse des Standes; Königinnen, Durchsichten und Aufgaben fehlen ohne das jeweilige Leserecht (Benutzer-ID der Durchsichten wird weggelassen). Ein Paket gilt nach 24 h als veraltet (Alter immer sichtbar), wird nach 1 h automatisch erneuert und nach 14 Tagen oder nach 403/404 gelöscht. Lehnt der Server einen Eintrag ab (bei der Sammelerfassung mit `invalidColonyIds`), erzwingt das ein Neuladen der betroffenen Pakete (frühestens nach 5 min). Der Service Worker (`public/sw.js`, `sw-routes.js`) speichert Seiten nur zusammen mit ihren Assets und Daten nur als `application/json`; nie gespeichert werden Auth-Antworten, Anfrage-Header (kein Bearer-Token im Cache), die Antworten, die ein Paket liefert (eine Offline-Datenquelle mit sichtbarem Alter), `/api/v1/apiaries` und `/apiaries/:id` (Standort), `/export/` und `/backup/`. Navigation hat routenabhängige Zeitlimits: App-Hülle 4 s, Offline-Seiten zuerst aus dem Cache, Deep Links etwa 10 s, übrige Seiten Netzfehler bzw. erst ab 15 s. Alle API-Anfragen haben eine Gesamtfrist inklusive Antwortkörper (Standard 4 s; Schreibzugriffe ohne Idempotenz-Schlüssel, darunter Login, MFA und Refresh, 15 s; Sammelerfassung 15 s; Upload 30 s; Download 60 s). +25. **Offline-Sitzung**: Ohne Netz bleibt der Benutzer mit der zwischengespeicherten Identität (Name, Rechte) 7 Tage angemeldet, nie mit einem Token; der Sync bleibt gesperrt, bis ein Refresh gelingt. Ein Refresh gilt nur für dieselbe Benutzer-ID; das Token eines anderen Kontos löscht das Gerät (Pakete, Warteschlange, Caches). Nur 401/403 beenden die Sitzung, 429/502/503 und Netzfehler zählen als Netzfehler. Ein Abmelden ohne Netz merkt den Widerruf vor und holt ihn beim nächsten Start vor allem anderen nach. Jeder Warteschlangen-Eintrag hat einen Besitzer (`ownerUserId`); fremde Einträge werden nie gesendet. Beim Abmelden und Benutzerwechsel werden Pakete, Rundenzustand, Query-Cache, Foto-Übergabemarker und die Cache-Storage-Einträge des Service Workers gelöscht (`WIPE_CACHES` mit Generationszähler, damit eine laufende Anfrage danach nichts mehr schreibt). Beim Sync beendet 401 den Lauf, ohne Einträge zu markieren; 408/429/502/503/504 lassen Einträge wartend; 409 „wird bereits verarbeitet“ heißt „später erneut“. +26. **Fotos**: Aufnahmen werden im Browser per Canvas neu kodiert (längste Kante 2560 px, JPEG, ohne EXIF/XMP/GPS), auch im bestehenden Dokument-Upload. Vor dem Upload prüft der Client Metadaten und höchstens 10 MB; der Upload ist idempotent. Ein Foto wartet in IndexedDB auf die Server-ID seiner Durchsicht und wird danach angehängt. Ein Übergabemarker in `localStorage` (mit `createdAt`, höchstens 3 Tage alt, je Benutzer) verhindert, dass ein bereits gespeicherter Entwurf samt Fotos ein zweites Mal gesendet wird. Offen: die Prüfung mit `exiftool` auf einem echten Telefon (auch älteres iOS Safari). +27. **Sammelerfassung am Stand**: `POST /treatments|feedings|varroa/bulk` schreibt alles oder nichts in einer Transaktion (`@Idempotent()`). Die Völker werden sortiert mit `lockRowsForShare` (`FOR SHARE`, `src/prisma/row-lock.ts`) gesperrt, der Ablauf steckt im Helfer `runBulk`; das Ereignis je Volk trägt `bulkId`. Entscheidung K1: Die Chargennummer des Arzneimittels (`batchNumber`) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird über `bulkId`. Entscheidung K2: Varroa nimmt je Volk einen eigenen Eintrag, ein gemeinsamer Milbenwert wäre erfundene Messung. Unbekannte oder beendete Völker meldet die 400-Antwort als `invalidColonyIds`. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel. +28. **Skalen und Vorbelegung**: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (`RatingScale direction="lower-is-better"`; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus in `colonyStrength` und neu `broodFrames`, das Altfeld `broodAmount` nur im Bearbeiten-Dialog. Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt. +29. **Ampeln**: Varroa wird gelb ab der halben Schwelle der aktiven Regel (`VARROA_WARN_FRACTION = 0.5`, in Milben/Tag), rot ab der Schwelle; der Trend zeigt Milben/Tag mit Schwellenlinie, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (`lib/colonies/status-light.ts`) nimmt den schlechtesten Befund: rot bei `QUEENLESS`, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei `WEAK`/`CONSPICUOUS`, schwachem Zustand, offener Warnung, noch keiner Durchsicht oder mehr als `INSPECTION_STALE_DAYS = 14` Tagen ohne Durchsicht (Letzteres nur mit Leserecht auf Durchsichten). Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test. ## 7. Kubernetes @@ -205,10 +209,10 @@ Scheitert die Migration, scheitert der initContainer und der neue Pod startet ni **Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie: - **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14), IdempotencyRecord (Annahme 11) sowie die Betriebsmodelle des Plattform-Rückgrats DomainEvent und DomainEventDelivery (Outbox – Seiteneffekte dürfen nach einem Restore nicht erneut ausgelöst werden), WebhookDelivery (Zustellprotokoll und Wiederholungs-Warteschlange – sonst gingen alte Ereignisse erneut an Empfänger), JobRun (Laufhistorie der CronJobs dieses Deployments) und ThrottlerHit (UNLOGGED-Zähler des Rate-Limits); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`. -- **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer. +- **Automatischer Zeitplan:** Kein Timer im API-Prozess. Der CronJob `apicore-job-app-backup` (`0 * * * *`, Europe/Berlin, `concurrencyPolicy: Forbid`) startet stündlich `node dist/cli.js app-backup` (`BackupSchedulerService.runIfDue`). Der Job liest Pfad (`BACKUP_STORAGE_PATH`), Cron-Ausdruck und Aufbewahrung (`BACKUP_RETENTION_COUNT`) frisch – Env-Variable vor GUI-Wert (Verwaltung > Backup) –, vergleicht den nächsten Termin nach der mtime der neuesten Backup-Datei (`isBackupDue`, Zeitzone Europe/Berlin, „jetzt“ = DB-Uhr) und schreibt nur, wenn er erreicht ist; alte Stände rotiert er nach der Aufbewahrung. Prüfung und Schreiben laufen unter einem DB-Advisory-Lock, zwei Job-Pods schreiben nie doppelt. Ohne Pfad endet der Lauf erfolgreich mit `{ written: false, reason: 'no-path' }`; ein Fehler lässt den `JobRun` fehlschlagen (Benachrichtigung an die Inhaber von `backup:configure`). GUI-Änderungen und Importe gelten ab der nächsten stündlichen Prüfung. „Jetzt sichern“ bleibt in der API und schreibt immer. `GET /backup/schedule` liefert zusätzlich `lastJobRun` (Status, Start, Ende, Fehler); die Seite zeigt „Wird stündlich geprüft · letzter Lauf …“. Die Lock-Transaktion der Fälligkeitsprüfung hält eine Verbindung bis zum Ende des Schreibens (Timeout 1 h = `activeDeadlineSeconds`; der Export nutzt andere Verbindungen, der Pool braucht mindestens zwei). Sie braucht weder `idle_in_transaction_session_timeout` noch einen Transaction-Mode-PgBouncer: Postgres wird direkt angesprochen, und der Lock hängt an der Transaktion. Scheitert der Commit, nachdem die Datei schon geschrieben wurde, bleibt die Datei bestehen, der Lauf steht aber kosmetisch auf `FAILED`; der nächste Lauf sieht die Datei an ihrer mtime. Das Aufräumen von Idempotenz-Einträgen (90 Tage) und verbrauchten Einmal-Tokens läuft einmal täglich im CronJob `apicore-job-cleanup` (Abschnitt 10, „Jobs“). - **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe. -Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab. +Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber (CronJob `apicore-db-backup`, Abschnitt 7) nicht der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab. ## 9. API-Sicherheit (Stand Oktober 2026) @@ -222,7 +226,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un | Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) | | Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten | | Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`, `shop-sync:write`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Lesen und Buchen sind getrennte Schlüssel: `ApiKeysService.create` lehnt einen Schlüssel mit beiden Shop-Rechten mit 400 ab; bereits ausgestellte kombinierte Schlüssel bleiben gültig und werden in der Oberfläche mit Hinweis markiert | -| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body | +| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` | | Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies | | Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf | | Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings | @@ -253,5 +257,73 @@ Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivitä **Handler-Vertrag.** `@OnDomainEvent(types, '.')` steht nur auf Singleton-Providern. Ein Handler schreibt ausschließlich über den übergebenen `tx`; lesen darf er auch über `PrismaService`. Er macht keine HTTP- oder Graph-Aufrufe: Netzwerkarbeit kommt in eine eigene Outbox (`CalendarSyncOutboxEntry`, `WebhookDelivery`), die eine Worker-Schleife abarbeitet. Der Wächter `src/domain-events/handler-contract.spec.ts` prüft, dass `*.handlers.ts` keine Netzwerk-Clients importieren. Fehlertexte gehen nur über `loggableError()` in `lastError` und Logs, nie Payload oder Secrets. +**Aufgaben.** Jede Aufgabe, die die App anlegt oder bearbeitet, läuft über `TasksService` (auch die Aufgaben der Automatik-Regeln, siehe Annahme 12; nur die Backup-Wiederherstellung ist ausgenommen). `createInTx(tx, input, actingUserId)` schreibt Aufgabe, Audit-Eintrag und `TaskCreated` (`relatedType`, `relatedId`, `dueDate` als UTC-Tag, `utcDay()`) in der Transaktion des Aufrufers – Aufgabenseite, Folgeaufgabe aus der Durchsicht, Vorlage. `update` schreibt `TaskUpdated` (`status`, `dueDate`). `TaskCompleted` entsteht nur beim Übergang nach `DONE`, durch „Erledigt“ (`complete`) ebenso wie durch eine Bearbeitung mit `status: DONE`: beide setzen Status und `completedAt` über dasselbe bedingte Update (`status <> 'DONE'`), sodass die Erledigung auch bei zwei gleichzeitigen Anfragen genau einmal gemeldet wird. Kalender (`calendar-sync.task`) und Webhooks (`webhooks.forward`: `task.created`, `task.completed`) folgen ausschließlich aus diesen Ereignissen; direkte Aufrufe gibt es für Aufgaben nicht mehr. Wiederkehrende Vorlagen erzeugt `TasksService.generateFromTemplates(now)` (lazy vor `GET /tasks`): je Vorlage ein Compare-and-Swap auf `nextDueDate` und `createInTx` in einer Transaktion, sodass von mehreren Replicas genau eine das Vorkommen anlegt und ein gescheiterter Schreibversuch es nicht verliert. Solche Aufgaben haben keinen Ersteller, ihr Audit-Eintrag keinen Benutzer, ihr Ereignis `actorUserId = null`. + +**Durchsichten und Varroa.** `InspectionsService` und `VarroaService` schreiben Datensatz, Audit-Eintrag und Ereignis in einer Transaktion: `create` → `InspectionRecorded` bzw. `VarroaMeasured`, `update` → `InspectionCorrected` bzw. `VarroaCorrected`, jeweils mit `{ colonyId }` (das Volk nach der Änderung, auch nach einer Umbuchung). `update` sperrt die Zeile zuerst (`lockRowForUpdate`) und liest „vorher“ danach über `tx`; auch die Tagesrate der Varroamessung wird aus dieser Zeile abgeleitet. Alle Folgen kommen aus Handlern: Regeln über `automation.inspection-rules` (nur `InspectionRecorded`; eine korrigierte Durchsicht wertet keine Regel aus) und `automation.varroa-threshold` (`VarroaMeasured` und `VarroaCorrected`, eine korrigierte Zählung kann die Schwelle überschreiten), Kalender über `calendar-sync.inspection` (beide Durchsicht-Ereignisse), Webhooks `inspection.created` und `varroa.measured` über `webhooks.forward`. Ein Handler wertet den Datensatz so aus, wie er bei der Zustellung in der Datenbank steht. + +**Behandlungen, Fütterungen und Ernten.** Anlegen schreibt Datensatz, Audit-Eintrag und `TreatmentRecorded`, `FeedingRecorded` bzw. `HarvestRecorded` in einer Transaktion, Ändern `TreatmentCorrected`, `FeedingCorrected` bzw. `HarvestCorrected`. Kalendereintrag (`calendar-sync.*`), Webhook (`treatment.created`, `feeding.created`, `harvest.created`) und Folgekontrolle (`automation.treatment-follow-up`, `automation.feeding-follow-up`) entstehen ausschließlich in den Handlern; Korrekturen lösen nur den Kalender-Handler aus, keine Folgekontrolle und keinen Webhook. Die Fälligkeit der Folgekontrolle gilt ab dem Datum der Maßnahme. `update` sperrt die Zeile und liest „vorher“ danach über `tx`; die Behandlung sperrt beim Anlegen das Volk und liest es über `tx`, bei Ernten läuft die Gewichtsprüfung unter der Sperre. + +**Völker.** `ColoniesService` und `WinterChecksService` schreiben Audit, Datenänderung und Ereignis in einer Transaktion. Ändert sich `status` oder `healthStatus` (update, merge je vereinigtem Volk, dissolve, reactivate, Auswinterungsverlust mit `markColonyDeceased`), entsteht `ColonyStatusChanged { from, to }`; beim Übergang von lebend nach beendet (`DISSOLVED`, `SOLD`, `DECEASED`, `MERGED`) zusätzlich `ColonyEnded { status }`. `automation.colony-health` hört auf `ColonyStatusChanged`. Die Operationen sperren das Volk (`lockColony`: Sperre, dann Neulesen über `tx`) und prüfen darunter, ob es noch lebt; `merge` sperrt alle vereinigten Völker mit einer Anweisung (`lockRowsForUpdate`). `colony.created` bleibt im Altpfad `WebhooksService.dispatch` (Annahme 11). + +**Bestand und Abfüllung.** Bestandsbuchungen kündigen sich über `StockChanged { bottlingBatchIds, productIds, rawMaterialKinds }` an. Das Ereignis entsteht in derselben Transaktion wie Bewegung, Audit-Eintrag und Datenänderung, über `StockService.recordChange(tx, change, actingUserId, aggregate)`. Eine leere Änderung erzeugt kein Ereignis, jede ID und jede Sorte steht nur einmal darin. +- Entnahme und Inventur hängen das Ereignis an die Bewegung (`BatchMovement`). Bei einer Rohstoff-Charge steht deren Sorte in `rawMaterialKinds`. +- Jede angenommene Shop-Buchung (`ShopStockRequest`, `actorUserId = null`) schreibt ihr eigenes Ereignis. Abgelehnte und wiederholte Anfragen schreiben keines. +- Abfüllungen schreiben beim Anlegen `BatchBottled { honeyBatchId }`, bei Korrektur und Löschung `BottlingCorrected { honeyBatchId, removed }`, jeweils zusammen mit `StockChanged` in einem `recordAll`, also mit einer Korrelations-ID. +- Herstellungschargen schreiben `StockChanged` mit dem Artikel und den verbrauchten bzw. beim Löschen zurückgegebenen Rohstoff-Sorten. + +Daraus folgen die Webhooks `batch.updated` und `stock.changed` (`webhooks.forward`; reine Rohstoff-Änderungen gehen nicht an den Shop) sowie die Rohstoff-Mindestmenge (`automation.raw-material-low`, je Sorte über `remainingByRawMaterialKind`). Korrekturen und Löschungen von Abfüllung, Herstellungscharge und Rohstoff-Charge sperren die Zeile (`lockRowForUpdate`) und lesen „vorher“ erst danach über `tx`; Mengenprüfung und Bewegungs-Check vor dem Löschen laufen ebenfalls unter dieser Sperre. Herstellungschargen sperren zuerst den Artikel (`StockService.lock`), dann die Charge: dieselbe Reihenfolge wie ein Shop-Verkauf, der den Artikel hält, während seine Bewegung die Charge referenziert. Reines Anlegen oder Ändern einer Rohstoff-Charge schreibt kein `StockChanged`; die Mindestmenge wird erst bei der nächsten Entnahme, Korrektur oder Verbrauchsbuchung dieser Sorte geprüft. + +**Sammelerfassung am Stand.** `POST /treatments/bulk`, `/feedings/bulk` und `/varroa-measurements/bulk` legen in einer Transaktion (`runBulk`, `src/common/bulk/run-bulk.ts`) je Volk einen eigenen Datensatz an: höchstens `BULK_MAX_COLONIES` = 100 Völker, Timeout `BULK_TRANSACTION_TIMEOUT_MS` = 15 s. Es gilt alles oder nichts: `assertLivingColonies` sperrt die Völker per `lockRowsForShare` (`SELECT … FOR SHARE`, aufsteigend nach id) und liest sie neu. Ist ein Volk unbekannt oder beendet, wird nichts gespeichert, und die Antwort 400 nennt die Völker in `invalidColonyIds`; der Client kann ohne sie erneut senden. Jeder Datensatz bekommt seinen eigenen Audit-Eintrag und sein eigenes `TreatmentRecorded`, `FeedingRecorded` bzw. `VarroaMeasured`; diese Ereignisse tragen optional `bulkId` (`RecordedForColony`), bei Einzelanlage fehlt das Feld. Kalender, Folgekontrolle, Varroa-Schwelle und Webhooks folgen daher je Volk unverändert aus den Ereignissen. Einzel- und Sammelanlage nutzen dieselbe Datenaufbereitung; bei Behandlungen kommt der Stand je Volk aus der gesperrten Zeile (Bestandsbuch). Die Chargennummer ist die Arzneimittel-Charge der Packung: einmal eingegeben, für alle Datensätze gleich, nie erzeugt. Die Gruppierung leistet `bulkId`. Varroa nimmt `entries` mit Milbenzahl je Volk (keine gemeinsame Milbenzahl), die Tagesrate wird je Eintrag wie bei der Einzelmessung abgeleitet. Die Einzel-Endpunkte akzeptieren weiter beendete Völker, nur die Sammelerfassung prüft auf lebende. + +**Zeilensperren.** `src/prisma/row-lock.ts` bietet `lockRowForUpdate` (eine Zeile, liefert, ob sie existiert), `lockRowsForUpdate` und `lockRowsForShare` (mehrere Zeilen mit einer Anweisung, aufsteigend nach id, damit sich überlappende Mengen nicht gegenseitig blockieren). Sie laufen nur mit einem Transaktions-Client, Tabellennamen kommen aus einer festen Liste (nie aus Eingaben). Muster für jedes Ändern: sperren, dann „vorher“ über `tx` neu lesen, nie die Vorab-Lesung verwenden. + **Tests.** e2e-Tests haben keinen Worker. Sie rufen nach der Anfrage `drainDomainEvents(app, [aggregateId])` aus `test/utils/domain-events.ts` auf. Das arbeitet nur die Ereignisse dieser Korrelationen ab, sodass parallele e2e-Dateien sich nicht stören. -``` + +### Webhook-Zustellung + +**Einreihen.** `WebhooksService.enqueueDeliveries(db, event, payload)` legt je aktivem Abonnement des Webhook-Ereignisses eine `WebhookDelivery` an und weckt den Worker mit `pg_notify('webhook_delivery', '')`. Gesendet wird dabei nichts. Der Body `JSON.stringify({ event, data })` wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. Mit einem Transaktions-Client committen Zeilen und Wecken zusammen mit dem Aufrufer. Zwei Wege führen dorthin: +- Der Handler `webhooks.forward` (`src/webhooks/webhook.handlers.ts`) übersetzt Ereignisse nach `DOMAIN_EVENT_WEBHOOKS`: `InspectionRecorded` → `inspection.created`, `VarroaMeasured` → `varroa.measured`, `TreatmentRecorded` → `treatment.created`, `FeedingRecorded` → `feeding.created`, `HarvestRecorded` → `harvest.created`, `BatchBottled` und `BottlingCorrected` → `batch.updated`, `StockChanged` → `stock.changed`, `TaskCreated` → `task.created`, `TaskCompleted` → `task.completed`. `data` ist wie bisher der Datensatz, gelesen über `tx`: die Ernte mit ihren Völkern, bei `batch.updated` die Honigcharge, bei `stock.changed` nur `{ bottlingBatchIds, productIds }` und nur, wenn eine der Listen nicht leer ist. Fehlt der Datensatz, wird nichts eingereiht. +- Der Altpfad `WebhooksService.dispatch(event, payload)` für Erzeuger, die noch kein Ereignis schreiben (`colony.created`, `batch.created`, `queen.created`, `queen.replaced`, `product.stock_changed`), reiht nach dem Commit über `runAfterCommit` ein und wirft nie. + +**Zustellung.** Die Worker-Schleife `webhook-deliveries` (alle 5 s und auf `webhook_delivery`) ruft `WebhookDeliveryWorker.releaseStaleClaims()` und dann `drain()` auf. Claim, Abschluss und Freigabe laufen über `outbox-claims.ts`: höchstens 50 fällige Zustellungen je Runde, älteste zuerst (`createdAt`), höchstens 20 Runden je Takt. Höchstens 5 Zustellungen laufen gleichzeitig. Jede wird mit Zeitstempel und ihrer ID signiert (`X-ApiCore-Signature: sha256=HMAC(secret, "..")`) und über `postWebhook` gesendet: keine Weiterleitungen, Ziel-IP beim Verbindungsaufbau geprüft, 5 s Leerlauf-Timeout und eine Frist von 10 s je Versuch. Bei Ablauf der Frist wird die Anfrage samt Verbindung abgebrochen, auch wenn der Empfänger seine Antwort nur tröpfchenweise sendet; so hält er weder einen der 5 Plätze noch einen Socket. `X-ApiCore-Delivery` ist die `WebhookDelivery.id` und bleibt über alle Wiederholungen gleich. Zugestellt wird mindestens einmal; Empfänger verwerfen Duplikate anhand dieser ID. + +**Ergebnis.** Nur eine `2xx`-Antwort gilt als zugestellt: `DELIVERED`, `deliveredAt = now()`. Jede andere Antwort, ein Netzwerkfehler und ein pausiertes Abonnement (`subscription inactive`, ohne zu senden) sind Fehlversuche nach derselben Regel wie bei Ereignissen: `attempts + 1`, Wartezeit 30 s, 2 min, 10 min, 1 h, danach 6 h, beim 8. Fehlversuch `FAILED`. Ein Abonnement, das innerhalb dieses Fensters wieder aktiviert wird, bekommt seine Zustellungen also noch. `lastStatusCode` hält den Status der letzten Antwort (ohne Antwort `null`), `lastError` einen festen Text (`Receiver answered HTTP `) oder die Meldung aus `loggableError()`; Antwortkörper, Secret und Payload stehen nie darin. Status-Code und Abschluss werden in einer Transaktion gesetzt, beide als Compare-and-Set auf `id` + `claimedAt`. Beim Herunterfahren holt `drain()` keine weitere Runde; die laufende wird zu Ende zugestellt. + +**Zustellprotokoll.** `GET /webhooks/:id/deliveries?limit=1..100` (Standard 50, Recht `webhooks:read`) liefert die neuesten Zustellungen eines Abonnements ohne Body: `id`, `event`, `status`, `attempts`, `lastStatusCode`, `lastError`, `createdAt`, `deliveredAt`. + +**Tests.** e2e-Tests rufen `deliverWebhooks(app, [subscriptionId])` aus `test/utils/webhooks.ts` auf. Das stellt nur die Zustellungen dieser Abonnements zu. + +### Jobs + +**Aufruf.** `node dist/cli.js [args]` (`src/cli.ts`, Rolle `job`) prüft wie `main.ts` und `worker.ts` zuerst Rolle und Umgebung (`resolveRole('cli')`, `assertStartupEnvironment`) und startet dann `JobsModule` per `createApplicationContext`: die Module, die die Jobs brauchen, ohne HTTP-Schicht und ohne Worker-Schleifen. Jobs (`JOB_NAMES` in `src/jobs/job-names.ts`): `daily-evaluation`, `task-templates`, `cleanup`, `app-backup`, `rekey`. Weitere Argumente gehen unverändert an den Job, erlaubt sind nur die aus `JOB_ALLOWED_ARGS` (heute einzig `rekey --dry-run`). Exit-Code `0` Lauf erfolgreich, `1` Lauf gescheitert (auch wenn er gar nicht erst aufgezeichnet werden konnte, etwa bei einem Modul- oder DI-Fehler – der Kontext startet mit `abortOnError: false`), `2` falscher Aufruf (kein oder unbekannter Job oder ein nicht erlaubtes Argument wie `cleanup --dry-run` oder `rekey --dry-rn` – dann wird nichts verbunden und kein `JobRun` geschrieben). Shutdown-Hooks gibt es keine: Ein abgebrochener Pod hinterlässt seinen Lauf als `RUNNING`, `cleanup` erkennt ihn (siehe unten). `JobRegistry` prüft beim Start, dass es zu jedem Namen genau einen Job gibt. + +**`JobRun`.** `JobRunService.run(job, work)` schreibt je Lauf eine Zeile: `RUNNING`, danach `SUCCEEDED` mit `result` (nur Zähler, Schalter, kurze Namen) oder `FAILED` mit `error` aus `loggableError()`; `finishedAt` setzt immer die DB (`now()`), auch bei abgebrochenen Läufen. Ein gescheiterter Lauf benachrichtigt die Inhaber des Job-Rechts (`JOB_FAILURE_PERMISSION`: `daily-evaluation` und `task-templates` → `automation:update`, `app-backup` → `backup:configure`, `cleanup` und `rekey` → `system:manage`) mit „Job „“ fehlgeschlagen“ (`WARNUNG`, `relatedType: 'JobRun'`, `relatedId` = Lauf). Höchstens eine solche Benachrichtigung je Job und UTC-Tag (DB-Uhr), sonst meldete die stündliche `app-backup`-Prüfung denselben Ausfall jede Stunde; Prüfung und Schreiben laufen in einer Transaktion hinter einem Advisory-Lock je Job. `recent(limit, job?)` und `latestPerJob()` lesen die Läufe für die System-Seite. `SystemModule` stellt `JobRunService` für API und Jobs bereit. + +**`cleanup`** (täglich 04:00). Alle Altersvergleiche laufen in SQL gegen `now()`; `PENDING`- und `PROCESSING`-Einträge bleiben immer stehen. +- Läufe, die seit mehr als 6 h `RUNNING` sind (`JOB_RUN_STALE_HOURS`, weit über jeder `activeDeadlineSeconds`), werden `FAILED` mit `error: 'aborted (pod ended without result)'`; je betroffenem Job geht die übliche Benachrichtigung raus, damit ein Job, der jedes Mal abgeschossen wird, nicht still scheitert. +- Idempotenz-Einträge nach 90 Tagen und verbrauchte Tokens 1 h nach ihrem Ablauf (`IdempotencyCleanupService`; die Stunde Puffer deckt Uhrabweichungen der api-Pods ab, die den Ablauf mit ihrer eigenen Uhr prüfen). +- `ThrottlerHit`-Zeilen, deren Fenster abgelaufen ist und die keine aktive Sperre tragen (`"expiresAt" < now()` und `"blockedUntil"` leer oder `< now()`); eine noch laufende Sperre bleibt so erhalten. +- `DONE`-Ereignisse und `DELIVERED`-Zustellungen nach 30 Tagen (`occurredAt` bzw. `createdAt`), `FAILED`-Einträge beider Tabellen nach 90 Tagen; die Deliveries eines Ereignisses gehen per `ON DELETE CASCADE` mit. 30 Tage liegen bewusst über dem Nachholfenster der täglichen Auswertung, sonst würde ein gelöschter `dedupeKey` ein Vorkommen erneut erzeugen. +- `JobRun`-Zeilen nach 90 Tagen. + +Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `throttlerHits`, `domainEvents.done`, `domainEvents.failed`, `webhookDeliveries.delivered`, `webhookDeliveries.failed`, `jobRuns.aborted`, `jobRuns.deleted`). + +**`task-templates`** (täglich 05:00) ruft `TasksService.generateFromTemplates()` auf und meldet `{ created }`. Wegen des Compare-and-Swap je Vorlage entsteht jedes Vorkommen genau einmal, auch bei zwei Läufen oder parallel zur Lazy-Erzeugung vor `GET /tasks`. + +**`daily-evaluation`** (täglich 04:30) erzeugt die zeitbasierten Ereignisse. Regeln und Werte stehen in `src/jobs/daily-evaluation.rules.ts`, eine Zeile je Typ in `DAILY_EVALUATION_RULES`; jede Regel nennt je Vorkommen das Ereignis und den UTC-Tag des fachlichen Ereignisses: +- `WithdrawalEnded` (Aggregat `Treatment`, Payload `colonyId`, `endedOn`): `(endDate ?? date) + withdrawalPeriodDays`, nur mit Wartezeit. +- `TaskDue` (`Task`, `dueOn`): `dueDate` offener Aufgaben (`OPEN`, `IN_PROGRESS`). +- `QueenAged` (`Queen`, `hatchYear`, `colonyId`): Königin `ACTIVE` mit `Jahr − hatchYear ≥ 2` (`QUEEN_AGED_MIN_YEARS`), Tag `-01-01`, also höchstens einmal im Jahr und nur im Fenster 1.–8. Januar; wer später im Jahr `ACTIVE` wird oder sein `hatchYear` erhält, bekommt in diesem Jahr kein Ereignis. +- `VarroaMeasurementDue` (`Colony`, `dueOn`, `lastMeasuredOn`) und `InspectionOverdue` (`Colony`, `dueOn`, `lastInspectedOn`): lebende Völker (`LIVING_COLONY_WHERE`), letzte Messung bzw. Durchsicht plus k × 28 bzw. 21 Tage (k ≥ 1; ohne Eintrag ab `createdAt`), jeweils das späteste solche Datum bis heute. Nur wenn sein Monat in März–Oktober liegt (`ACTIVE_SEASON_MONTHS`); ein Vorkommen in der Winterpause entfällt, es wird nicht auf den 1. März verschoben. + +„Heute“ ist der Tag der DB-Uhr (`SELECT now()`). Aufgezeichnet wird jedes Vorkommen, dessen Tag im Nachholfenster `[heute − 7, heute]` liegt (`CATCH_UP_DAYS`), mit `dedupeKey = "::"` (`dailyDedupeKey`). Jeder Lauf im Fenster wiederholt denselben Schlüssel, der eindeutige `dedupeKey` (`recordAll`, `skipDuplicates`) hält ein Ereignis je Vorkommen: ausgefallene Läufe bis 7 Tage werden nachgeholt, wiederholte erzeugen nichts. Je Typ eine Transaktion; das Ergebnis zählt die neuen Ereignisse je Typ. Scheitert eine Regel, laufen die übrigen weiter und ihre Ereignisse bleiben; der Lauf scheitert dann mit `Daily evaluation failed for: `. Handler für diese fünf Ereignisse gibt es noch nicht (spätere Stufe); bis dahin werden sie ohne Wirkung `DONE`, und `cleanup` löscht sie nach 30 Tagen. + +**`app-backup`** (stündlich) prüft, ob ein Backup fällig ist, und schreibt es dann: siehe Abschnitt 8, „Automatischer Zeitplan“. + +**`rekey`** (nur von Hand, CronJobs `apicore-job-rekey` und `apicore-job-rekey-dry-run` mit `suspend: true`): siehe Annahme 22. + +**Tests.** `test/jobs.e2e-spec.ts` ruft `runCli([...])` mit dem echten `JobsModule` auf, wie `node dist/cli.js`. `test/daily-evaluation.e2e-spec.ts` ruft `DailyEvaluationJob.evaluate(Tag)` mit Tagen im Jahr 2001 auf: Der Job wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine `JobRun`-Zeilen, die `jobs.e2e-spec.ts` zählt). `test/rekey.e2e-spec.ts` setzt nur `*_PREVIOUS`, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen. + +### System-Seite + +Die Seite „Verwaltung > System“ (Rechte `system:read` zum Ansehen, `system:manage` zum Wiederholen; nur Systemrollen) zeigt je Job den letzten Lauf (`JobRun`), die Warteschlange der Domänenereignisse (offen, in Arbeit, fehlgeschlagen, Alter des ältesten offenen Eintrags nach DB-Uhr) und die offenen und fehlgeschlagenen Webhook-Zustellungen. Routen: `GET /system/overview`, `GET /system/job-runs?job=&limit=1..100` (Standard 50), `GET /system/domain-events/failed`, `POST /system/domain-events/:id/retry` (204), `GET /system/webhook-deliveries/failed`, `POST /system/webhook-deliveries/:id/retry` (204). Wiederholen ist nur für `FAILED` möglich (409 sonst, 404 unbekannt): Versuche und `availableAt` werden in SQL (`now()`) zurückgesetzt, fehlgeschlagene Handler-Zustellungen eines Ereignisses wieder `PENDING`, ein Audit-Eintrag (UPDATE) geschrieben und der Worker per `pg_notify` geweckt. Die Ansichten enthalten nie Payload, Webhook-Body, Secrets oder vollständige URLs (nur den Host); `lastError` steht wie gespeichert (`loggableError()`). API-Schlüssel erreichen `/system` nie (Scope-Allowlist). diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index 85e5c23..b7ec885 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -15,7 +15,6 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", - "@nestjs/schedule": "^12.0.1", "@nestjs/swagger": "^11.4.7", "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.9.1", @@ -24,6 +23,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", + "cron": "^4.4.0", "helmet": "^8.3.0", "openid-client": "^6.8.4", "otplib": "^13.5.0", @@ -2480,22 +2480,6 @@ "@nestjs/core": "^11.0.0" } }, - "node_modules/@nestjs/schedule": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz", - "integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==", - "license": "MIT", - "dependencies": { - "cron": "4.4.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@nestjs/common": "^11.0.0 || ^12.0.0", - "@nestjs/core": "^11.0.0 || ^12.0.0" - } - }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -3603,9 +3587,9 @@ "license": "MIT" }, "node_modules/@types/luxon": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", - "integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==", + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz", + "integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==", "license": "MIT" }, "node_modules/@types/methods": { diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index 39ea567..3bbf95e 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -31,7 +31,6 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", - "@nestjs/schedule": "^12.0.1", "@nestjs/swagger": "^11.4.7", "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.9.1", @@ -40,6 +39,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", + "cron": "^4.4.0", "helmet": "^8.3.0", "openid-client": "^6.8.4", "otplib": "^13.5.0", @@ -96,7 +96,7 @@ "^.+\\.(t|j)s$": "ts-jest" }, "transformIgnorePatterns": [ - "node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)" + "node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron)/)" ], "collectCoverageFrom": [ "**/*.(t|j)s" diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 7d59c51..d4982fa 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -544,7 +544,7 @@ enum TaskTemplateRecurrenceType { } // Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task, -// sobald nextDueDate erreicht ist (siehe TasksService.generateDueTasks). +// sobald nextDueDate erreicht ist (siehe TasksService.generateFromTemplates). model TaskTemplate { id String @id @default(cuid()) title String diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index c04b74d..795aba7 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -5,7 +5,6 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard'; import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage'; import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module'; -import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; import { AuditModule } from './audit/audit.module'; @@ -53,16 +52,15 @@ import { SiteSettingsModule } from './site-settings/site-settings.module'; import { BackupModule } from './backup/backup.module'; import { ShopSyncModule } from './shop-sync/shop-sync.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; +import { SystemModule } from './system/system.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor'; -import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), - ScheduleModule.forRoot(), ThrottlerModule.forRootAsync({ imports: [ThrottlerStorageModule], inject: [PrismaThrottlerStorage], @@ -119,6 +117,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea BackupModule, ShopSyncModule, ApiKeysModule, + SystemModule, ], controllers: [], providers: [ @@ -128,7 +127,6 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, - IdempotencyCleanupService, ], }) export class AppModule {} diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 4d86b24..bb2f636 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -361,6 +361,15 @@ export const PERMISSION_CATALOG = [ [['manage', 'Anlegen, ansehen und widerrufen']], 3400, ), + ...group( + 'System', + 'system', + [ + ['read', 'Hintergrundjobs und Warteschlangen ansehen'], + ['manage', 'Fehlgeschlagene Ereignisse erneut anstoßen'], + ], + 3500, + ), ] as const satisfies readonly PermissionCatalogEntry[]; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; diff --git a/ApiCore-Backend/src/automation/automation.handlers.spec.ts b/ApiCore-Backend/src/automation/automation.handlers.spec.ts new file mode 100644 index 0000000..6111082 --- /dev/null +++ b/ApiCore-Backend/src/automation/automation.handlers.spec.ts @@ -0,0 +1,253 @@ +import { Prisma } from '../../generated/prisma'; +import type { + AnyDomainEventEnvelope, + DomainEventEnvelope, + DomainEventType, +} from '../domain-events/domain-event-types'; +import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; +import type { StockService } from '../stock/stock.service'; +import { AutomationEventHandlers } from './automation.handlers'; +import type { AutomationService } from './automation.service'; + +type RecordModel = + 'inspection' | 'varroaMeasurement' | 'treatment' | 'feeding' | 'colony'; + +const RECORD_MODELS: readonly RecordModel[] = [ + 'inspection', + 'varroaMeasurement', + 'treatment', + 'feeding', + 'colony', +]; + +function envelope( + type: T, + aggregateId: string, + payload: DomainEventEnvelope['payload'], +): AnyDomainEventEnvelope { + return { + id: 'e1', + type, + aggregateType: 'Inspection', + aggregateId, + payload, + actorUserId: 'u1', + correlationId: 'corr-1', + occurredAt: new Date('2026-10-13T08:00:00.000Z'), + attempts: 0, + } as unknown as AnyDomainEventEnvelope; +} + +describe('AutomationEventHandlers', () => { + let tx: Record; + let automation: Record< + | 'evaluateInspectionIn' + | 'evaluateVarroaMeasurementIn' + | 'evaluateTreatmentIn' + | 'evaluateFeedingIn' + | 'evaluateColonyHealthIn' + | 'evaluateRawMaterialLowIn', + jest.Mock + >; + let stock: { remainingByRawMaterialKind: jest.Mock }; + let handlers: AutomationEventHandlers; + + const asTx = () => tx as unknown as Prisma.TransactionClient; + + beforeEach(() => { + tx = Object.fromEntries( + RECORD_MODELS.map((model) => [ + model, + { findUnique: jest.fn().mockResolvedValue(null) }, + ]), + ) as Record; + automation = { + evaluateInspectionIn: jest.fn().mockResolvedValue(undefined), + evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined), + evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined), + evaluateFeedingIn: jest.fn().mockResolvedValue(undefined), + evaluateColonyHealthIn: jest.fn().mockResolvedValue(undefined), + evaluateRawMaterialLowIn: jest.fn().mockResolvedValue(undefined), + }; + stock = { remainingByRawMaterialKind: jest.fn() }; + handlers = new AutomationEventHandlers( + automation as unknown as AutomationService, + stock as unknown as StockService, + ); + }); + + it('registers the stable handler ids and event types', () => { + const registered = (method: keyof AutomationEventHandlers) => + domainEventHandlerMetadata( + Reflect.get(AutomationEventHandlers.prototype, method), + ); + + expect(registered('inspectionRules')).toEqual([ + { + types: ['InspectionRecorded'], + handlerId: 'automation.inspection-rules', + }, + ]); + expect(registered('varroaThreshold')).toEqual([ + { + types: ['VarroaMeasured', 'VarroaCorrected'], + handlerId: 'automation.varroa-threshold', + }, + ]); + expect(registered('treatmentFollowUp')).toEqual([ + { + types: ['TreatmentRecorded'], + handlerId: 'automation.treatment-follow-up', + }, + ]); + expect(registered('feedingFollowUp')).toEqual([ + { types: ['FeedingRecorded'], handlerId: 'automation.feeding-follow-up' }, + ]); + expect(registered('colonyHealth')).toEqual([ + { types: ['ColonyStatusChanged'], handlerId: 'automation.colony-health' }, + ]); + expect(registered('rawMaterialLow')).toEqual([ + { types: ['StockChanged'], handlerId: 'automation.raw-material-low' }, + ]); + }); + + describe('record handlers', () => { + const cases = [ + [ + 'inspectionRules', + 'InspectionRecorded', + 'inspection', + 'evaluateInspectionIn', + ], + [ + 'varroaThreshold', + 'VarroaMeasured', + 'varroaMeasurement', + 'evaluateVarroaMeasurementIn', + ], + [ + 'varroaThreshold', + 'VarroaCorrected', + 'varroaMeasurement', + 'evaluateVarroaMeasurementIn', + ], + [ + 'treatmentFollowUp', + 'TreatmentRecorded', + 'treatment', + 'evaluateTreatmentIn', + ], + ['feedingFollowUp', 'FeedingRecorded', 'feeding', 'evaluateFeedingIn'], + ] as const; + + const run = ( + method: (typeof cases)[number][0], + type: (typeof cases)[number][1], + ) => + handlers[method]( + envelope(type, 'record-1', { colonyId: 'colony-1' }) as never, + asTx(), + ); + + it.each(cases)( + 'handlers load the record via tx and evaluate it: %s on %s', + async (method, type, model, core) => { + const record = { id: 'record-1', colonyId: 'colony-1' }; + tx[model].findUnique.mockResolvedValueOnce(record); + + await run(method, type); + + expect(tx[model].findUnique).toHaveBeenCalledWith({ + where: { id: 'record-1' }, + }); + expect(automation[core]).toHaveBeenCalledWith(tx, record); + }, + ); + + it.each(cases)( + 'handlers load the record via tx and skip a missing one: %s on %s', + async (method, type, model, core) => { + await run(method, type); + + expect(tx[model].findUnique).toHaveBeenCalledTimes(1); + expect(automation[core]).not.toHaveBeenCalled(); + }, + ); + + it('lets an evaluation failure propagate, so the delivery is retried', async () => { + tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' }); + automation.evaluateInspectionIn.mockRejectedValue( + new Error('notifications down'), + ); + + await expect( + run('inspectionRules', 'InspectionRecorded'), + ).rejects.toThrow('notifications down'); + }); + }); + + describe('colony-health', () => { + const from = { status: 'ACTIVE', healthStatus: 'AVERAGE' } as const; + const to = { status: 'ACTIVE', healthStatus: 'CRITICAL' } as const; + const event = () => + envelope('ColonyStatusChanged', 'colony-1', { from, to }); + + it('hands the transition of the event to evaluateColonyHealthIn', async () => { + tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' }); + + await handlers.colonyHealth(event(), asTx()); + + expect(tx.colony.findUnique).toHaveBeenCalledWith({ + where: { id: 'colony-1' }, + select: { id: true }, + }); + // The rule decides on the snapshots of the event, not on the colony + // as it is now: a later change must not hide the transition. + expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith( + tx, + 'colony-1', + from, + to, + ); + }); + + it('skips a colony that no longer exists', async () => { + await handlers.colonyHealth(event(), asTx()); + + expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled(); + }); + }); + + describe('raw-material-low', () => { + const stockChanged = (rawMaterialKinds: ('BEESWAX' | 'PROPOLIS')[]) => + envelope('StockChanged', 'movement-1', { + bottlingBatchIds: [], + productIds: [], + rawMaterialKinds, + }); + + it('evaluates every touched kind once with what is left of it', async () => { + stock.remainingByRawMaterialKind.mockImplementation((kind: string) => + Promise.resolve(kind === 'BEESWAX' ? 400 : 75), + ); + + await handlers.rawMaterialLow( + stockChanged(['BEESWAX', 'PROPOLIS', 'BEESWAX']), + asTx(), + ); + + expect(stock.remainingByRawMaterialKind).toHaveBeenCalledTimes(2); + expect(automation.evaluateRawMaterialLowIn.mock.calls).toEqual([ + [tx, 'BEESWAX', 400], + [tx, 'PROPOLIS', 75], + ]); + }); + + it('does nothing for a change without raw material', async () => { + await handlers.rawMaterialLow(stockChanged([]), asTx()); + + expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled(); + expect(automation.evaluateRawMaterialLowIn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ApiCore-Backend/src/automation/automation.handlers.ts b/ApiCore-Backend/src/automation/automation.handlers.ts new file mode 100644 index 0000000..e95e0d3 --- /dev/null +++ b/ApiCore-Backend/src/automation/automation.handlers.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@nestjs/common'; +import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; +import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; +import { Prisma } from '../../generated/prisma'; +import { StockService } from '../stock/stock.service'; +import { AutomationService } from './automation.service'; + +type Tx = Prisma.TransactionClient; + +/** + * Evaluates the automation rules on domain events. Pure DB writer: it reads + * the record the event is about through the delivery transaction and lets + * AutomationService write task, warning and audit entries in it. A record + * deleted before the event was dispatched is skipped. Errors propagate, so + * the delivery is rolled back as a whole and retried. + */ +@Injectable() +export class AutomationEventHandlers { + constructor( + private readonly automation: AutomationService, + private readonly stock: StockService, + ) {} + + @OnDomainEvent('InspectionRecorded', 'automation.inspection-rules') + async inspectionRules( + event: AnyDomainEventEnvelope<'InspectionRecorded'>, + tx: Tx, + ): Promise { + const inspection = await tx.inspection.findUnique({ + where: { id: event.aggregateId }, + }); + if (inspection === null) { + return; + } + await this.automation.evaluateInspectionIn(tx, inspection); + } + + // A correction can lift a count over the threshold, so it is evaluated + // like a new measurement; the open-task check keeps it to one task. + @OnDomainEvent( + ['VarroaMeasured', 'VarroaCorrected'], + 'automation.varroa-threshold', + ) + async varroaThreshold( + event: AnyDomainEventEnvelope<'VarroaMeasured' | 'VarroaCorrected'>, + tx: Tx, + ): Promise { + const measurement = await tx.varroaMeasurement.findUnique({ + where: { id: event.aggregateId }, + }); + if (measurement === null) { + return; + } + await this.automation.evaluateVarroaMeasurementIn(tx, measurement); + } + + @OnDomainEvent('TreatmentRecorded', 'automation.treatment-follow-up') + async treatmentFollowUp( + event: AnyDomainEventEnvelope<'TreatmentRecorded'>, + tx: Tx, + ): Promise { + const treatment = await tx.treatment.findUnique({ + where: { id: event.aggregateId }, + }); + if (treatment === null) { + return; + } + await this.automation.evaluateTreatmentIn(tx, treatment); + } + + @OnDomainEvent('FeedingRecorded', 'automation.feeding-follow-up') + async feedingFollowUp( + event: AnyDomainEventEnvelope<'FeedingRecorded'>, + tx: Tx, + ): Promise { + const feeding = await tx.feeding.findUnique({ + where: { id: event.aggregateId }, + }); + if (feeding === null) { + return; + } + await this.automation.evaluateFeedingIn(tx, feeding); + } + + // Decides on the snapshots the event carries, not on the colony as it is + // now: a later edit must not hide the transition into CRITICAL. + @OnDomainEvent('ColonyStatusChanged', 'automation.colony-health') + async colonyHealth( + event: AnyDomainEventEnvelope<'ColonyStatusChanged'>, + tx: Tx, + ): Promise { + const colony = await tx.colony.findUnique({ + where: { id: event.aggregateId }, + select: { id: true }, + }); + if (colony === null) { + return; + } + await this.automation.evaluateColonyHealthIn( + tx, + colony.id, + event.payload.from, + event.payload.to, + ); + } + + // Per kind the change touched, what is left of it across all its lots. + @OnDomainEvent('StockChanged', 'automation.raw-material-low') + async rawMaterialLow( + event: AnyDomainEventEnvelope<'StockChanged'>, + tx: Tx, + ): Promise { + for (const kind of new Set(event.payload.rawMaterialKinds)) { + const remaining = await this.stock.remainingByRawMaterialKind(kind); + await this.automation.evaluateRawMaterialLowIn(tx, kind, remaining); + } + } +} diff --git a/ApiCore-Backend/src/automation/automation.module.ts b/ApiCore-Backend/src/automation/automation.module.ts index 6c66f55..d0972ef 100644 --- a/ApiCore-Backend/src/automation/automation.module.ts +++ b/ApiCore-Backend/src/automation/automation.module.ts @@ -1,12 +1,20 @@ import { Module } from '@nestjs/common'; import { NotificationsModule } from '../notifications/notifications.module'; +import { StockModule } from '../stock/stock.module'; +import { TasksModule } from '../tasks/tasks.module'; import { AutomationController } from './automation.controller'; +import { AutomationEventHandlers } from './automation.handlers'; import { AutomationService } from './automation.service'; +/** + * Automation rules and the handlers that evaluate them on domain events. + * Rule tasks go through TasksService (audit entry, TaskCreated - and with + * it calendar entry and task.created webhook), so no WebhooksService here. + */ @Module({ - imports: [NotificationsModule], + imports: [TasksModule, NotificationsModule, StockModule], controllers: [AutomationController], - providers: [AutomationService], + providers: [AutomationService, AutomationEventHandlers], 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 index 3fd7109..4f46ca6 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -3,74 +3,116 @@ 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'; -import { AutomationTriggerType, Prisma } from '../../generated/prisma'; +import { TasksService } from '../tasks/tasks.service'; +import { + AutomationTriggerType, + Prisma, + RawMaterialKind, +} from '../../generated/prisma'; + +type Rule = { + id: string; + triggerType: string; + active: boolean; + config: unknown; + actionTemplate: string; +}; describe('AutomationService', () => { let service: AutomationService; + // The transaction client: evaluations read and write only through it. + let tx: { + automationRule: { + createMany: jest.Mock; + findMany: jest.Mock; + findUnique: jest.Mock; + }; + task: { findFirst: jest.Mock }; + $queryRaw: jest.Mock; + }; let prisma: { automationRule: { - upsert: jest.Mock; + createMany: jest.Mock; findMany: jest.Mock; findUnique: jest.Mock; update: jest.Mock; }; automationSequence: { upsert: jest.Mock }; - task: { create: jest.Mock; findFirst: jest.Mock }; $transaction: jest.Mock; - $queryRaw: jest.Mock; + // Only the root client has $connect - assertTransactionClient tells + // the two apart by it. + $connect: jest.Mock; }; let auditLog: { record: jest.Mock }; let notifications: { record: jest.Mock }; - let webhooks: { dispatch: jest.Mock }; + let tasks: { createInTx: jest.Mock }; - const queenlessRule = { + const queenlessRule: Rule = { id: 'rule-1', triggerType: 'QUEENLESS_NO_EGGS', active: true, config: {}, actionTemplate: 'Kontrolle empfohlen', }; - const feedRule = { + const feedRule: Rule = { id: 'rule-2', triggerType: 'FEED_STATUS_CRITICAL', active: true, config: {}, actionTemplate: 'Fütterung erforderlich', }; - const varroaRule = { + const varroaRule: Rule = { id: 'rule-3', triggerType: 'VARROA_THRESHOLD', active: true, config: { thresholdMitesPerDay: 10 }, actionTemplate: 'Behandlung prüfen', }; + const queenlessInspection = { + id: 'inspection-1', + colonyId: 'colony-1', + queenSeen: false, + eggsPresent: false, + feedStatus: null, + }; + + const asTx = () => tx as unknown as Prisma.TransactionClient; beforeEach(() => { + tx = { + automationRule: { + createMany: jest.fn().mockResolvedValue({ count: 0 }), + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + }, + task: { findFirst: jest.fn().mockResolvedValue(null) }, + $queryRaw: jest.fn().mockResolvedValue([]), + }; prisma = { automationRule: { - upsert: jest.fn().mockResolvedValue(undefined), + createMany: jest.fn().mockResolvedValue({ count: 0 }), findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), }, automationSequence: { upsert: jest.fn() }, - task: { - create: jest.fn().mockResolvedValue({ id: 'task-1' }), - findFirst: jest.fn().mockResolvedValue(null), - }, - // Interactive transactions run against the same mocks. - $transaction: jest.fn((work: (tx: unknown) => unknown) => work(prisma)), - $queryRaw: jest.fn().mockResolvedValue([]), + $transaction: jest.fn((work: (client: unknown) => unknown) => work(tx)), + $connect: jest.fn(), }; - auditLog = { record: jest.fn() }; - notifications = { record: jest.fn() }; - webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + notifications = { record: jest.fn().mockResolvedValue({ id: 'n-1' }) }; + tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) }; service = new AutomationService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, notifications as unknown as NotificationsService, - webhooks as unknown as WebhooksService, + tasks as unknown as TasksService, + ); + }); + + it('sends no webhook itself: task.created follows from the TaskCreated event', () => { + expect(Reflect.getMetadata('design:paramtypes', AutomationService)).toEqual( + [PrismaService, AuditLogService, NotificationsService, TasksService], ); }); @@ -81,7 +123,7 @@ describe('AutomationService', () => { ); }); - it('findAll seeds all default rules before returning them', async () => { + it('findAll seeds every missing default rule in one statement before returning them', async () => { prisma.automationRule.findMany.mockResolvedValue([ queenlessRule, varroaRule, @@ -90,8 +132,14 @@ describe('AutomationService', () => { const result = await service.findAll(); - expect(prisma.automationRule.upsert).toHaveBeenCalledTimes( - Object.values(AutomationTriggerType).length, + expect(prisma.automationRule.createMany).toHaveBeenCalledTimes(1); + const [[seed]] = prisma.automationRule.createMany.mock.calls as [ + [{ data: { triggerType: string }[]; skipDuplicates: boolean }], + ]; + // Existing rules keep their settings: duplicates are skipped, not updated. + expect(seed.skipDuplicates).toBe(true); + expect(seed.data.map((rule) => rule.triggerType).sort()).toEqual( + [...Object.values(AutomationTriggerType)].sort(), ); expect(result).toHaveLength(3); }); @@ -99,102 +147,64 @@ describe('AutomationService', () => { // Rule evaluation runs after the triggering record is committed. A failure // here must not turn that request into an error - the client would retry // and store the inspection or treatment a second time. - describe('post-commit evaluation', () => { + describe('legacy wrappers (post-commit evaluation)', () => { let errorSpy: jest.SpyInstance; beforeEach(() => { errorSpy = jest .spyOn(Logger.prototype, 'error') .mockImplementation(() => undefined); - prisma.automationRule.upsert.mockRejectedValue(new Error('db down')); }); afterEach(() => { errorSpy.mockRestore(); }); - const colony = { id: 'colony-1', healthStatus: 'GOOD' }; - const evaluations: [string, string, (s: AutomationService) => unknown][] = [ - [ - 'evaluateInspection', - 'Inspection inspection-1', - (s) => - s.evaluateInspection({ - id: 'inspection-1', - colonyId: 'colony-1', - } as never), - ], - [ - 'evaluateVarroaMeasurement', - 'VarroaMeasurement measurement-1', - (s) => - s.evaluateVarroaMeasurement({ - id: 'measurement-1', - colonyId: 'colony-1', - } as never), - ], + const check = { id: 'check-1', colonyId: 'colony-1' }; + const item = { id: 'item-1', quantity: 1, minThreshold: 5 }; + // [legacy method, log subject, call, core it delegates to, core args after tx] + const wrappers: [ + string, + string, + (s: AutomationService) => Promise, + keyof AutomationService, + unknown[], + ][] = [ [ 'evaluateSwarmControlCheck', 'SwarmControlCheck check-1', - (s) => - s.evaluateSwarmControlCheck({ - id: 'check-1', - colonyId: 'colony-1', - } as never), - ], - [ - 'evaluateColonyHealth', - 'Colony colony-1', - (s) => - s.evaluateColonyHealth( - colony as never, - { ...colony, healthStatus: 'CRITICAL' } as never, - ), + (s) => s.evaluateSwarmControlCheck(check as never), + 'evaluateSwarmControlCheckIn', + [check], ], [ 'evaluateStockItem', 'StockItem item-1', - (s) => - s.evaluateStockItem({ - id: 'item-1', - quantity: 1, - minThreshold: 5, - } as never), - ], - [ - 'evaluateRawMaterialLow', - 'RawMaterialKind BEESWAX', - (s) => s.evaluateRawMaterialLow('BEESWAX', 100), - ], - [ - 'evaluateTreatment', - 'Treatment treatment-1', - (s) => - s.evaluateTreatment({ - id: 'treatment-1', - colonyId: 'colony-1', - date: new Date('2026-08-01'), - endDate: null, - product: 'Ameisensäure', - } as never), - ], - [ - 'evaluateFeeding', - 'Feeding feeding-1', - (s) => - s.evaluateFeeding({ - id: 'feeding-1', - colonyId: 'colony-1', - date: new Date('2026-09-01'), - amount: 5, - unit: 'l', - } as never), + (s) => s.evaluateStockItem(item as never), + 'evaluateStockItemIn', + [item], ], ]; - it.each(evaluations)( + it.each(wrappers)( + '%s runs the shared core in one transaction', + async (_name, _subject, evaluate, core, args) => { + const coreSpy = jest + .spyOn(service, core as 'evaluateInspectionIn') + .mockResolvedValue(undefined); + + await evaluate(service); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(coreSpy).toHaveBeenCalledWith(tx, ...args); + }, + ); + + it.each(wrappers)( '%s never throws and logs the failure with the record', async (_name, subject, evaluate) => { + tx.automationRule.createMany.mockRejectedValue(new Error('db down')); + await expect(evaluate(service)).resolves.toBeUndefined(); expect(errorSpy).toHaveBeenCalledTimes(1); @@ -204,53 +214,234 @@ describe('AutomationService', () => { }, ); + it.each(wrappers)( + '%s never throws when the transaction cannot even start', + async (_name, subject, evaluate) => { + prisma.$transaction.mockRejectedValue(new Error('pool exhausted')); + + await expect(evaluate(service)).resolves.toBeUndefined(); + + const [message] = errorSpy.mock.calls[0] as [string]; + expect(message).toContain(subject); + }, + ); + it('still throws from generateNumber, which runs before the write', async () => { + prisma.automationRule.createMany.mockRejectedValue(new Error('db down')); + await expect( service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'), ).rejects.toThrow('db down'); expect(errorSpy).not.toHaveBeenCalled(); }); - }); - describe('evaluateInspection', () => { - it('creates a task and notification when queen not seen and no eggs present', async () => { - prisma.automationRule.findMany.mockResolvedValue([queenlessRule]); + it('leaves no task, notification or audit entry when the notification write fails (one rollback)', async () => { + // A transaction fake: writes through tx are staged and kept only when + // the work resolves - like Postgres, a rejected callback keeps none. + const committed: string[] = []; + let staged: string[] = []; + prisma.$transaction.mockImplementation( + async (work: (client: unknown) => Promise) => { + staged = []; + const result = await work(tx); + committed.push(...staged); + return result; + }, + ); + tx.automationRule.findUnique.mockResolvedValue({ + ...queenlessRule, + triggerType: 'SWARM_MOOD_HIGH', + }); + tasks.createInTx.mockImplementation((client: unknown) => { + staged.push(client === tx ? 'task' : 'task outside tx'); + return Promise.resolve({ id: 'task-1' }); + }); + notifications.record.mockRejectedValue(new Error('notifications down')); + auditLog.record.mockImplementation((_entry: unknown, client: unknown) => { + staged.push(client === tx ? 'audit' : 'audit outside tx'); + return Promise.resolve(); + }); - await service.evaluateInspection({ - id: 'inspection-1', + await service.evaluateSwarmControlCheck({ + id: 'check-1', colonyId: 'colony-1', - queenSeen: false, - eggsPresent: false, - feedStatus: null, + swarmMood: 'HIGH', } as never); - expect(prisma.task.create).toHaveBeenCalledTimes(1); - const [[taskArg]] = prisma.task.create.mock.calls as [ - [{ data: { relatedId: string; sourceInspectionId?: string } }], + expect(tasks.createInTx).toHaveBeenCalledTimes(1); + expect(committed).toEqual([]); + expect(auditLog.record).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledTimes(1); + const [message] = errorSpy.mock.calls[0] as [string]; + expect(message).toContain('notifications down'); + }); + }); + + describe('triggerActionIn (via the …In cores)', () => { + beforeEach(() => { + tx.automationRule.findMany.mockResolvedValue([queenlessRule]); + }); + + it('triggerActionIn creates the task through TasksService.createInTx on the handler transaction', async () => { + await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + + // System task: no acting user. Audit entry, TaskCreated - and with it + // calendar entry and task.created webhook - come from createInTx. + expect(tasks.createInTx).toHaveBeenCalledWith( + tx, + { + title: 'Kontrolle empfohlen', + priority: 'HIGH', + relatedType: 'COLONY', + relatedId: 'colony-1', + sourceInspectionId: 'inspection-1', + sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', + description: undefined, + dueDate: undefined, + }, + null, + ); + expect(notifications.record).toHaveBeenCalledWith( + { + priority: 'WARNUNG', + message: 'Kontrolle empfohlen', + relatedType: 'COLONY', + relatedId: 'colony-1', + }, + tx, + ); + expect(auditLog.record).toHaveBeenCalledWith( + { + entityType: 'AutomationRule', + entityId: 'rule-1', + action: 'CREATE', + afterData: { + taskId: 'task-1', + relatedType: 'COLONY', + relatedId: 'colony-1', + triggerType: 'QUEENLESS_NO_EGGS', + }, + }, + tx, + ); + // Nothing of the evaluation runs on the root client. + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); + }); + + it('lets a failed notification write propagate, so the delivery is rolled back and retried', async () => { + notifications.record.mockRejectedValue(new Error('notifications down')); + + await expect( + service.evaluateInspectionIn(asTx(), queenlessInspection as never), + ).rejects.toThrow('notifications down'); + expect(auditLog.record).not.toHaveBeenCalled(); + }); + + it('still creates at most one open task per rule and object', async () => { + tx.task.findFirst.mockResolvedValue({ id: 'open-task' }); + + await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + + expect(tx.task.findFirst).toHaveBeenCalledWith({ + where: { + sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', + relatedType: 'COLONY', + relatedId: 'colony-1', + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + select: { id: true }, + }); + expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(notifications.record).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + }); + + it('checks for an open task and creates one under a lock on the rule row', async () => { + await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + + const [sql, ...values] = tx.$queryRaw.mock.calls[0] as [ + TemplateStringsArray, + ...unknown[], ]; - expect(taskArg.data.relatedId).toBe('colony-1'); - expect(taskArg.data.sourceInspectionId).toBe('inspection-1'); + expect(sql.join('?')).toMatch( + /SELECT id FROM "AutomationRule" WHERE id = \? FOR UPDATE/, + ); + expect(values).toEqual(['rule-1']); + // Lock first, then check, then create - all on the one transaction. + const [lockOrder] = tx.$queryRaw.mock.invocationCallOrder; + const [checkOrder] = tx.task.findFirst.mock.invocationCallOrder; + const [createOrder] = tasks.createInTx.mock.invocationCallOrder; + expect(lockOrder).toBeLessThan(checkOrder); + expect(checkOrder).toBeLessThan(createOrder); + }); + + it('seeds the default rules on the given transaction client', async () => { + await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + + expect(tx.automationRule.createMany).toHaveBeenCalledWith( + expect.objectContaining({ skipDuplicates: true }), + ); + expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); + }); + + it('refuses the root client: lock and duplicate check need a transaction', async () => { + await expect( + service.evaluateInspectionIn( + prisma as unknown as Prisma.TransactionClient, + queenlessInspection as never, + ), + ).rejects.toThrow('needs the transaction client'); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + it('lets a failing createInTx propagate and writes nothing else', async () => { + tasks.createInTx.mockRejectedValue(new Error('tasks down')); + + await expect( + service.evaluateInspectionIn(asTx(), queenlessInspection as never), + ).rejects.toThrow('tasks down'); + expect(notifications.record).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + }); + }); + + describe('evaluateInspectionIn', () => { + it('reads only the active inspection rules', async () => { + await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + + expect(tx.automationRule.findMany).toHaveBeenCalledWith({ + where: { + triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] }, + active: true, + }, + }); + }); + + it('creates a task when queen not seen and no eggs present', async () => { + tx.automationRule.findMany.mockResolvedValue([queenlessRule]); + + await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + + expect(tasks.createInTx).toHaveBeenCalledTimes(1); expect(notifications.record).toHaveBeenCalledTimes(1); }); it('does not trigger when the queen was seen', async () => { - prisma.automationRule.findMany.mockResolvedValue([queenlessRule]); + tx.automationRule.findMany.mockResolvedValue([queenlessRule]); - await service.evaluateInspection({ - id: 'inspection-2', - colonyId: 'colony-1', + await service.evaluateInspectionIn(asTx(), { + ...queenlessInspection, queenSeen: true, - eggsPresent: false, - feedStatus: null, } as never); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); it('triggers when feed status is kritisch, case-insensitive', async () => { - prisma.automationRule.findMany.mockResolvedValue([feedRule]); + tx.automationRule.findMany.mockResolvedValue([feedRule]); - await service.evaluateInspection({ + await service.evaluateInspectionIn(asTx(), { id: 'inspection-3', colonyId: 'colony-1', queenSeen: true, @@ -258,59 +449,104 @@ describe('AutomationService', () => { feedStatus: 'Kritisch', } as never); - expect(prisma.task.create).toHaveBeenCalledTimes(1); + expect(tasks.createInTx).toHaveBeenCalledTimes(1); + const [[, input]] = tasks.createInTx.mock.calls as [ + [unknown, { sourceAutomationTrigger: string }], + ]; + expect(input.sourceAutomationTrigger).toBe('FEED_STATUS_CRITICAL'); }); it('does not trigger a deactivated rule', async () => { - prisma.automationRule.findMany.mockResolvedValue([]); + tx.automationRule.findMany.mockResolvedValue([]); - await service.evaluateInspection({ - id: 'inspection-4', - colonyId: 'colony-1', - queenSeen: false, - eggsPresent: false, + await service.evaluateInspectionIn(asTx(), { + ...queenlessInspection, feedStatus: 'kritisch', } as never); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); }); - describe('evaluateVarroaMeasurement', () => { - it('creates a task when the calculated value meets the threshold', async () => { - prisma.automationRule.findUnique.mockResolvedValue(varroaRule); - - await service.evaluateVarroaMeasurement({ + describe('evaluateVarroaMeasurementIn', () => { + const naturalFall = (calculatedValue: number | null) => + ({ id: 'measurement-1', colonyId: 'colony-1', method: 'NATURAL_MITE_FALL', - calculatedValue: 12, + calculatedValue, miteCount: null, - } as never); + }) as never; - expect(prisma.task.create).toHaveBeenCalledTimes(1); + it('creates a task when the calculated value meets the threshold', async () => { + tx.automationRule.findUnique.mockResolvedValue(varroaRule); + + await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(12)); + + expect(tx.automationRule.findUnique).toHaveBeenCalledWith({ + where: { triggerType: 'VARROA_THRESHOLD' }, + }); + expect(tasks.createInTx).toHaveBeenCalledTimes(1); }); it('does not trigger when below the threshold', async () => { - prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + tx.automationRule.findUnique.mockResolvedValue(varroaRule); - await service.evaluateVarroaMeasurement({ - id: 'measurement-2', - colonyId: 'colony-1', - method: 'NATURAL_MITE_FALL', - calculatedValue: 5, - miteCount: null, - } as never); + await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(5)); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + it('does not trigger a deactivated rule', async () => { + tx.automationRule.findUnique.mockResolvedValue({ + ...varroaRule, + active: false, + }); + + await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(50)); + + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + // varroaThresholdOf is the one source of the default 10: a hand-edited + // config can never switch the check off or make every count trigger. + it.each([ + ['zero', 0], + ['negative', -3], + ['a string', '5'], + ])( + 'falls back to the default threshold 10 for a %s config value', + async (_label, thresholdMitesPerDay) => { + tx.automationRule.findUnique.mockResolvedValue({ + ...varroaRule, + config: { thresholdMitesPerDay }, + }); + + await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(9)); + expect(tasks.createInTx).not.toHaveBeenCalled(); + + await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(10)); + expect(tasks.createInTx).toHaveBeenCalledTimes(1); + }, + ); + + it('uses the default threshold 10 for a cleared config', async () => { + tx.automationRule.findUnique.mockResolvedValue({ + ...varroaRule, + config: null, + }); + + await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(10)); + + expect(tasks.createInTx).toHaveBeenCalledTimes(1); }); it('ignores a raw board count without a per-day rate', async () => { // 15 mites over an unknown number of days says nothing about the // per-day threshold - comparing them would be a unit error. - prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + tx.automationRule.findUnique.mockResolvedValue(varroaRule); - await service.evaluateVarroaMeasurement({ + await service.evaluateVarroaMeasurementIn(asTx(), { id: 'measurement-3', colonyId: 'colony-1', method: 'NATURAL_MITE_FALL', @@ -318,13 +554,13 @@ describe('AutomationService', () => { miteCount: 15, } as never); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); it('ignores sample-based methods, whose values use another unit', async () => { - prisma.automationRule.findUnique.mockResolvedValue(varroaRule); + tx.automationRule.findUnique.mockResolvedValue(varroaRule); - await service.evaluateVarroaMeasurement({ + await service.evaluateVarroaMeasurementIn(asTx(), { id: 'measurement-4', colonyId: 'colony-1', method: 'WASHING_METHOD', @@ -332,68 +568,12 @@ describe('AutomationService', () => { miteCount: 30, } as never); - expect(prisma.task.create).not.toHaveBeenCalled(); - }); - - it('adds no second task while one for the same colony is still open', async () => { - prisma.automationRule.findUnique.mockResolvedValue(varroaRule); - prisma.task.findFirst.mockResolvedValue({ id: 'open-task' }); - - await service.evaluateVarroaMeasurement({ - id: 'measurement-5', - colonyId: 'colony-1', - method: 'NATURAL_MITE_FALL', - calculatedValue: 25, - miteCount: 25, - } as never); - - expect(prisma.task.create).not.toHaveBeenCalled(); - expect(notifications.record).not.toHaveBeenCalled(); - expect(prisma.task.findFirst).toHaveBeenCalledWith({ - where: { - sourceAutomationTrigger: 'VARROA_THRESHOLD', - relatedType: 'COLONY', - relatedId: 'colony-1', - status: { in: ['OPEN', 'IN_PROGRESS'] }, - }, - select: { id: true }, - }); - }); - - it('checks for an open task and creates one under a lock on the rule row', async () => { - prisma.automationRule.findUnique.mockResolvedValue(varroaRule); - - await service.evaluateVarroaMeasurement({ - id: 'measurement-6', - colonyId: 'colony-1', - method: 'NATURAL_MITE_FALL', - calculatedValue: 25, - miteCount: 25, - } as never); - - expect(prisma.$transaction).toHaveBeenCalledTimes(1); - const [sql, ...values] = prisma.$queryRaw.mock.calls[0] as [ - TemplateStringsArray, - ...unknown[], - ]; - expect(sql.join('?')).toMatch( - /SELECT id FROM "AutomationRule" WHERE id = \? FOR UPDATE/, - ); - expect(values).toEqual(['rule-3']); - // Lock first, then check, then create - all in that one transaction. - const [lockOrder] = prisma.$queryRaw.mock.invocationCallOrder; - const [checkOrder] = prisma.task.findFirst.mock.invocationCallOrder; - const [createOrder] = prisma.task.create.mock.invocationCallOrder; - expect(lockOrder).toBeLessThan(checkOrder); - expect(checkOrder).toBeLessThan(createOrder); - // Webhook and warning follow the commit, outside the lock. - const [dispatchOrder] = webhooks.dispatch.mock.invocationCallOrder; - expect(dispatchOrder).toBeGreaterThan(createOrder); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); }); - describe('evaluateSwarmControlCheck', () => { - const swarmRule = { + describe('evaluateSwarmControlCheckIn', () => { + const swarmRule: Rule = { id: 'rule-4', triggerType: 'SWARM_MOOD_HIGH', active: true, @@ -402,64 +582,120 @@ describe('AutomationService', () => { }; it('creates a task when swarm mood is HIGH', async () => { - prisma.automationRule.findUnique.mockResolvedValue(swarmRule); + tx.automationRule.findUnique.mockResolvedValue(swarmRule); - await service.evaluateSwarmControlCheck({ + await service.evaluateSwarmControlCheckIn(asTx(), { id: 'check-1', colonyId: 'colony-1', swarmMood: 'HIGH', } as never); - expect(prisma.task.create).toHaveBeenCalledTimes(1); + expect(tasks.createInTx).toHaveBeenCalledTimes(1); }); it('does not trigger for a low swarm mood', async () => { - prisma.automationRule.findUnique.mockResolvedValue(swarmRule); + tx.automationRule.findUnique.mockResolvedValue(swarmRule); - await service.evaluateSwarmControlCheck({ + await service.evaluateSwarmControlCheckIn(asTx(), { id: 'check-2', colonyId: 'colony-1', swarmMood: 'LOW', } as never); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); }); - describe('evaluateColonyHealth', () => { - const healthRule = { + describe('evaluateColonyHealthIn', () => { + const healthRule: Rule = { id: 'rule-5', triggerType: 'COLONY_HEALTH_CRITICAL', active: true, config: {}, actionTemplate: 'Kontrolle erforderlich', }; + const state = (healthStatus: string | null, status = 'ACTIVE') => + ({ status, healthStatus }) as never; - it('creates a task on the transition into CRITICAL', async () => { - prisma.automationRule.findUnique.mockResolvedValue(healthRule); - - await service.evaluateColonyHealth( - { id: 'colony-1', healthStatus: 'AVERAGE' } as never, - { id: 'colony-1', healthStatus: 'CRITICAL' } as never, - ); - - expect(prisma.task.create).toHaveBeenCalledTimes(1); + beforeEach(() => { + tx.automationRule.findUnique.mockResolvedValue(healthRule); }); - it('does not re-trigger when already CRITICAL before the update', async () => { - prisma.automationRule.findUnique.mockResolvedValue(healthRule); + it.each([ + ['AVERAGE', 'CRITICAL', 1], + [null, 'CRITICAL', 1], + ['CRITICAL', 'CRITICAL', 0], + ['CRITICAL', 'GOOD', 0], + ['GOOD', 'AVERAGE', 0], + ['GOOD', null, 0], + ])( + 'colony-health fires only on the transition into CRITICAL (%s -> %s: %i task)', + async (from, to, expected) => { + await service.evaluateColonyHealthIn( + asTx(), + 'colony-1', + state(from), + state(to), + ); - await service.evaluateColonyHealth( - { id: 'colony-1', healthStatus: 'CRITICAL' } as never, - { id: 'colony-1', healthStatus: 'CRITICAL' } as never, + expect(tasks.createInTx).toHaveBeenCalledTimes(expected); + }, + ); + + // ColonyStatusChanged fires on every status or health change: without + // the transition the rule must cost nothing - no seeding write, no read. + it.each([ + ['CRITICAL', 'CRITICAL'], + ['GOOD', 'AVERAGE'], + ['CRITICAL', null], + ])( + 'writes and reads nothing without the transition (%s -> %s)', + async (from, to) => { + await service.evaluateColonyHealthIn( + asTx(), + 'colony-1', + state(from), + state(to), + ); + + expect(tx.automationRule.createMany).not.toHaveBeenCalled(); + expect(tx.automationRule.findUnique).not.toHaveBeenCalled(); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + }, + ); + + it('does not re-trigger when only the status changes while CRITICAL', async () => { + await service.evaluateColonyHealthIn( + asTx(), + 'colony-1', + state('CRITICAL', 'ACTIVE'), + state('CRITICAL', 'QUEENLESS'), ); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + it('hangs the task on the colony', async () => { + await service.evaluateColonyHealthIn( + asTx(), + 'colony-1', + state('GOOD'), + state('CRITICAL'), + ); + + const [[, input]] = tasks.createInTx.mock.calls as [ + [unknown, { relatedType: string; relatedId: string }], + ]; + expect(input).toMatchObject({ + relatedType: 'COLONY', + relatedId: 'colony-1', + sourceAutomationTrigger: 'COLONY_HEALTH_CRITICAL', + }); }); }); - describe('evaluateStockItem', () => { - const stockRule = { + describe('evaluateStockItemIn', () => { + const stockRule: Rule = { id: 'rule-6', triggerType: 'STOCK_ITEM_LOW', active: true, @@ -468,84 +704,218 @@ describe('AutomationService', () => { }; it('creates a task when quantity drops below minThreshold', async () => { - prisma.automationRule.findUnique.mockResolvedValue(stockRule); + tx.automationRule.findUnique.mockResolvedValue(stockRule); - await service.evaluateStockItem({ + await service.evaluateStockItemIn(asTx(), { id: 'item-1', quantity: 2, minThreshold: 5, } as never); - expect(prisma.task.create).toHaveBeenCalledTimes(1); + const [[, input]] = tasks.createInTx.mock.calls as [ + [unknown, { relatedType: string; relatedId: string }], + ]; + expect(input).toMatchObject({ + relatedType: 'STORAGE', + relatedId: 'item-1', + }); }); it('does not trigger when minThreshold is not set', async () => { - prisma.automationRule.findUnique.mockResolvedValue(stockRule); + tx.automationRule.findUnique.mockResolvedValue(stockRule); - await service.evaluateStockItem({ + await service.evaluateStockItemIn(asTx(), { id: 'item-2', quantity: 2, minThreshold: null, } as never); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); + + // Every stock movement evaluates its item: while it is not below its + // minimum the rule must cost nothing - no seeding write, no read. + it.each([ + ['without a minimum', 2, null], + ['at the minimum', 5, 5], + ['above the minimum', 9, 5], + ])( + 'writes and reads nothing %s', + async (_label, quantity, minThreshold) => { + await service.evaluateStockItemIn(asTx(), { + id: 'item-3', + quantity, + minThreshold, + } as never); + + expect(tx.automationRule.createMany).not.toHaveBeenCalled(); + expect(tx.automationRule.findUnique).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }, + ); }); - describe('evaluateRawMaterialLow', () => { - const rawRule = { + describe('evaluateRawMaterialLowIn', () => { + const rawRule: Rule = { id: 'rule-7', triggerType: 'RAW_MATERIAL_LOW', active: true, config: { minGrams: { BEESWAX: 1000 } }, actionTemplate: 'Rohstoff unter Mindestmenge', }; + const evaluate = (kind: RawMaterialKind, grams: number) => + service.evaluateRawMaterialLowIn(asTx(), kind, grams); it('creates one task per kind when the kind drops below its minimum', async () => { - prisma.automationRule.findUnique.mockResolvedValue(rawRule); + tx.automationRule.findUnique.mockResolvedValue(rawRule); - await service.evaluateRawMaterialLow('BEESWAX', 400); + await evaluate('BEESWAX', 400); - const [createArgs] = prisma.task.create.mock.calls[0] as [unknown]; - expect(createArgs).toMatchObject({ - data: { - relatedType: 'GENERAL', - relatedId: 'BEESWAX', - sourceAutomationTrigger: 'RAW_MATERIAL_LOW', - description: - 'Bienenwachs: noch 400 g auf Lager, Mindestmenge 1000 g.', - }, + const [[, input]] = tasks.createInTx.mock.calls as [[unknown, unknown]]; + expect(input).toMatchObject({ + relatedType: 'GENERAL', + relatedId: 'BEESWAX', + sourceAutomationTrigger: 'RAW_MATERIAL_LOW', + description: 'Bienenwachs: noch 400 g auf Lager, Mindestmenge 1000 g.', }); }); it('does not trigger at or above the minimum', async () => { - prisma.automationRule.findUnique.mockResolvedValue(rawRule); + tx.automationRule.findUnique.mockResolvedValue(rawRule); - await service.evaluateRawMaterialLow('BEESWAX', 1000); + await evaluate('BEESWAX', 1000); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); it('does not trigger for a kind without a minimum', async () => { - prisma.automationRule.findUnique.mockResolvedValue(rawRule); + tx.automationRule.findUnique.mockResolvedValue(rawRule); - await service.evaluateRawMaterialLow('PROPOLIS', 0); + await evaluate('PROPOLIS', 0); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); it('does not trigger when the rule is inactive', async () => { - prisma.automationRule.findUnique.mockResolvedValue({ + tx.automationRule.findUnique.mockResolvedValue({ ...rawRule, active: false, }); - await service.evaluateRawMaterialLow('BEESWAX', 0); + await evaluate('BEESWAX', 0); - expect(prisma.task.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); }); + describe('follow-up checks', () => { + const followUpRule = (triggerType: string): Rule => ({ + id: 'rule-9', + triggerType, + active: true, + config: { dueInDays: 14 }, + actionTemplate: 'Kontrollieren', + }); + + it('evaluateTreatmentIn dates the check from the end of the treatment', async () => { + tx.automationRule.findUnique.mockResolvedValue( + followUpRule('TREATMENT_FOLLOW_UP'), + ); + + await service.evaluateTreatmentIn(asTx(), { + id: 'treatment-1', + colonyId: 'colony-1', + date: new Date('2026-08-01T00:00:00.000Z'), + endDate: new Date('2026-08-10T00:00:00.000Z'), + product: 'Ameisensäure', + } as never); + + expect(tx.automationRule.findUnique).toHaveBeenCalledWith({ + where: { triggerType: 'TREATMENT_FOLLOW_UP' }, + }); + const [[, input]] = tasks.createInTx.mock.calls as [[unknown, unknown]]; + expect(input).toMatchObject({ + relatedType: 'COLONY', + relatedId: 'colony-1', + sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP', + dueDate: new Date('2026-08-24T00:00:00.000Z'), + description: 'Nach Behandlung mit Ameisensäure am 10.08.2026.', + }); + }); + + it('evaluateFeedingIn dates the check from the feeding day', async () => { + tx.automationRule.findUnique.mockResolvedValue( + followUpRule('FEEDING_FOLLOW_UP'), + ); + + await service.evaluateFeedingIn(asTx(), { + id: 'feeding-1', + colonyId: 'colony-1', + date: new Date('2026-09-01T00:00:00.000Z'), + amount: 5, + unit: 'l', + } as never); + + const [[, input]] = tasks.createInTx.mock.calls as [[unknown, unknown]]; + expect(input).toMatchObject({ + sourceAutomationTrigger: 'FEEDING_FOLLOW_UP', + dueDate: new Date('2026-09-15T00:00:00.000Z'), + description: 'Nach Fütterung (5 l) am 01.09.2026.', + }); + }); + + it('creates no follow-up while the rule is off', async () => { + tx.automationRule.findUnique.mockResolvedValue({ + ...followUpRule('FEEDING_FOLLOW_UP'), + active: false, + }); + + await service.evaluateFeedingIn(asTx(), { + id: 'feeding-1', + colonyId: 'colony-1', + date: new Date('2026-09-01T00:00:00.000Z'), + amount: 5, + unit: 'l', + } as never); + + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + const rootClient = () => prisma as unknown as Prisma.TransactionClient; + it.each([ + [ + 'evaluateTreatmentIn', + () => + service.evaluateTreatmentIn(rootClient(), { + id: 'treatment-1', + colonyId: 'colony-1', + date: new Date('2026-08-01T00:00:00.000Z'), + endDate: null, + product: 'Ameisensäure', + } as never), + ], + [ + 'evaluateFeedingIn', + () => + service.evaluateFeedingIn(rootClient(), { + id: 'feeding-1', + colonyId: 'colony-1', + date: new Date('2026-09-01T00:00:00.000Z'), + amount: 5, + unit: 'l', + } as never), + ], + ])( + '%s names itself when it gets the root client', + async (entryPoint, evaluate) => { + await expect(evaluate()).rejects.toThrow( + `AutomationService.${entryPoint} needs the transaction client`, + ); + expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); + }, + ); + }); + describe('update', () => { it('records an audit log entry with before/after data', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 30951c0..5196e2a 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -1,13 +1,14 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { assertTransactionClient, DbClient } from '../prisma/db-client'; import { runAfterCommit } from '../common/after-commit'; import { AuditLogService } from '../audit/audit-log.service'; import { NotificationsService } from '../notifications/notifications.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; +import { TasksService } from '../tasks/tasks.service'; +import type { ColonyStateSnapshot } from '../domain-events/domain-event-types'; import { AutomationRule, AutomationTriggerType, - Colony, Inspection, Feeding, Prisma, @@ -20,6 +21,9 @@ import { } from '../../generated/prisma'; import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; import { formatGeneratedNumber } from './number-format'; +import { varroaThresholdOf } from './varroa-threshold'; + +type Tx = Prisma.TransactionClient; /** The rules that fill in a batch/lot number instead of creating tasks. */ export type NumberFormatTrigger = @@ -171,29 +175,33 @@ export class AutomationService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly notifications: NotificationsService, - private readonly webhooks: WebhooksService, + private readonly tasks: TasksService, ) {} - 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: { + /** + * Creates the rules that do not exist yet, on the given client - inside a + * handler or legacy transaction too. One INSERT … ON CONFLICT DO NOTHING: + * existing rules keep their settings, and their rows are not locked + * (an upsert would hold all of them until the evaluation commits). + */ + private async ensureDefaults(db: DbClient): Promise { + const triggerTypes = Object.keys(DEFAULT_RULES) as AutomationTriggerType[]; + await db.automationRule.createMany({ + data: triggerTypes.map((triggerType) => { + const defaults = DEFAULT_RULES[triggerType]; + return { triggerType, active: defaults.active ?? true, config: defaults.config as Prisma.InputJsonValue, actionTemplate: defaults.actionTemplate, - }, - }); - } + }; + }), + skipDuplicates: true, + }); } async findAll(): Promise { - await this.ensureDefaults(); + await this.ensureDefaults(this.prisma); return this.prisma.automationRule.findMany({ orderBy: { triggerType: 'asc' }, include: { @@ -244,141 +252,140 @@ export class AutomationService { return rule; } - /** - * Every evaluate*() runs after the triggering record is committed and is - * called by the domain services without a guard - so it never throws (see - * runAfterCommit). A failed evaluation is logged, the request succeeds. - */ - private evaluateAfterCommit( - entityType: string, - entityId: string, - evaluate: () => Promise, - ): Promise { - return runAfterCommit( - this.logger, - { action: 'Automation rule evaluation', entityType, entityId }, - evaluate, - ); - } + // ------------------------------------------------------------------------- + // Rule evaluation + // + // One core per trigger, on the caller's transaction: the domain event + // handlers (AutomationEventHandlers) call it with their delivery + // transaction, the legacy wrappers below with a transaction of their own. + // ------------------------------------------------------------------------- - async evaluateInspection(inspection: Inspection): Promise { - await this.evaluateAfterCommit('Inspection', inspection.id, async () => { - 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 evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise { + assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn'); + await this.ensureDefaults(tx); + const rules = await tx.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.triggerActionIn( + tx, + queenlessRule, + 'COLONY', + inspection.colonyId, + inspection.id, + ); + } + + const feedRule = ruleByType.get('FEED_STATUS_CRITICAL'); + if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') { + await this.triggerActionIn( + tx, + feedRule, + 'COLONY', + inspection.colonyId, + inspection.id, + ); + } } - async evaluateVarroaMeasurement( + async evaluateVarroaMeasurementIn( + tx: Tx, measurement: VarroaMeasurement, ): Promise { - await this.evaluateAfterCommit( - 'VarroaMeasurement', - measurement.id, - async () => { - 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); - } - }, + assertTransactionClient( + tx, + 'AutomationService.evaluateVarroaMeasurementIn', ); + await this.ensureDefaults(tx); + const rule = await tx.automationRule.findUnique({ + where: { triggerType: 'VARROA_THRESHOLD' }, + }); + // null for an inactive rule; the one source of the default value. + const threshold = varroaThresholdOf(rule); + if (!rule || threshold === null) { + 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 value = measurement.calculatedValue; + if (value !== null && value >= threshold) { + await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId); + } } - async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise { - await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => { - 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); - } + async evaluateSwarmControlCheckIn( + tx: Tx, + check: SwarmControlCheck, + ): Promise { + assertTransactionClient( + tx, + 'AutomationService.evaluateSwarmControlCheckIn', + ); + await this.ensureDefaults(tx); + const rule = await tx.automationRule.findUnique({ + where: { triggerType: 'SWARM_MOOD_HIGH' }, }); + if (!rule || !rule.active) { + return; + } + if (check.swarmMood === 'HIGH') { + await this.triggerActionIn(tx, 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.evaluateAfterCommit('Colony', after.id, async () => { - 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 evaluateColonyHealthIn( + tx: Tx, + colonyId: string, + from: ColonyStateSnapshot, + to: ColonyStateSnapshot, + ): Promise { + assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn'); + // ColonyStatusChanged comes with every status or health edit: decide on + // the snapshots first, so most of them cost no statement at all. + if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') { + return; + } + await this.ensureDefaults(tx); + const rule = await tx.automationRule.findUnique({ + where: { triggerType: 'COLONY_HEALTH_CRITICAL' }, }); + if (!rule || !rule.active) { + return; + } + await this.triggerActionIn(tx, rule, 'COLONY', colonyId); } - async evaluateStockItem(item: StockItem): Promise { - await this.evaluateAfterCommit('StockItem', item.id, async () => { - 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 evaluateStockItemIn(tx: Tx, item: StockItem): Promise { + assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn'); + // Every movement of the item comes here: only one below its minimum + // needs the rule. + if (item.minThreshold === null || item.quantity >= item.minThreshold) { + return; + } + await this.ensureDefaults(tx); + const rule = await tx.automationRule.findUnique({ + where: { triggerType: 'STOCK_ITEM_LOW' }, }); + if (!rule || !rule.active) { + return; + } + await this.triggerActionIn(tx, rule, 'STORAGE', item.id); } /** @@ -388,73 +395,112 @@ export class AutomationService { * the remedy - so it hangs on GENERAL with the kind as relatedId, which * keeps one open task per kind. */ - async evaluateRawMaterialLow( + async evaluateRawMaterialLowIn( + tx: Tx, kind: RawMaterialKind, remainingGrams: number, ): Promise { - await this.evaluateAfterCommit('RawMaterialKind', kind, async () => { - await this.ensureDefaults(); - const rule = await this.prisma.automationRule.findUnique({ - where: { triggerType: 'RAW_MATERIAL_LOW' }, - }); - if (!rule || !rule.active) { - return; - } - const config = rule.config as { - minGrams?: Partial>; - } | null; - const minGrams = config?.minGrams?.[kind]; - if (typeof minGrams !== 'number' || remainingGrams >= minGrams) { - return; - } - await this.triggerAction(rule, 'GENERAL', kind, undefined, { - description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`, - }); + assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn'); + await this.ensureDefaults(tx); + const rule = await tx.automationRule.findUnique({ + where: { triggerType: 'RAW_MATERIAL_LOW' }, + }); + if (!rule || !rule.active) { + return; + } + const config = rule.config as { + minGrams?: Partial>; + } | null; + const minGrams = config?.minGrams?.[kind]; + if (typeof minGrams !== 'number' || remainingGrams >= minGrams) { + return; + } + await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, { + description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`, }); } - async evaluateTreatment(treatment: Treatment): Promise { - await this.evaluateAfterCommit('Treatment', treatment.id, () => - this.evaluateFollowUp( - 'TREATMENT_FOLLOW_UP', - treatment.colonyId, - treatment.endDate ?? treatment.date, - `Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`, - ), + async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise { + assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn'); + const measureDate = treatment.endDate ?? treatment.date; + await this.evaluateFollowUpIn( + tx, + 'TREATMENT_FOLLOW_UP', + treatment.colonyId, + measureDate, + `Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`, ); } - async evaluateFeeding(feeding: Feeding): Promise { - await this.evaluateAfterCommit('Feeding', feeding.id, () => - this.evaluateFollowUp( - 'FEEDING_FOLLOW_UP', - feeding.colonyId, - feeding.date, - `Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`, - ), + async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise { + assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn'); + await this.evaluateFollowUpIn( + tx, + '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( + /** + * A check counted from the measure's own date, not from today. Its public + * callers check the transaction client, so an error names them. + */ + private async evaluateFollowUpIn( + tx: Tx, triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP', colonyId: string, measureDate: Date, description: string, ): Promise { - await this.ensureDefaults(); - const rule = await this.prisma.automationRule.findUnique({ + await this.ensureDefaults(tx); + const rule = await tx.automationRule.findUnique({ where: { triggerType }, }); if (!rule || !rule.active) { return; } - await this.triggerAction(rule, 'COLONY', colonyId, undefined, { + await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, { baseDate: measureDate, description, }); } + // ------------------------------------------------------------------------- + // Legacy paths + // + // Only SWARM_MOOD_HIGH and STOCK_ITEM_LOW remain: the swarm control and + // stock modules record no event for them yet. Called after the write is + // committed, without a guard - so they never throw (see runAfterCommit): a + // failed evaluation is logged, the request succeeds. Each runs the same + // core as the handlers, in one transaction of its own. + // ------------------------------------------------------------------------- + + private evaluateAfterCommit( + entityType: string, + entityId: string, + evaluate: (tx: Tx) => Promise, + ): Promise { + return runAfterCommit( + this.logger, + { action: 'Automation rule evaluation', entityType, entityId }, + () => this.prisma.$transaction((tx) => evaluate(tx)), + ); + } + + async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise { + await this.evaluateAfterCommit('SwarmControlCheck', check.id, (tx) => + this.evaluateSwarmControlCheckIn(tx, check), + ); + } + + async evaluateStockItem(item: StockItem): Promise { + await this.evaluateAfterCommit('StockItem', item.id, (tx) => + this.evaluateStockItemIn(tx, item), + ); + } + // Returns null (never throws) when the rule is inactive or has no format // configured, so the caller can fall back to requiring manual entry. // @@ -466,7 +512,7 @@ export class AutomationService { triggerType: NumberFormatTrigger, options: { scope?: string; vars?: Record } = {}, ): Promise { - await this.ensureDefaults(); + await this.ensureDefaults(this.prisma); const rule = await this.prisma.automationRule.findUnique({ where: { triggerType }, }); @@ -547,75 +593,76 @@ export class AutomationService { } /** - * Leaves a suggestion task (never a record of work done) plus a warning. + * Leaves a suggestion task (never a record of work done) plus a warning, + * all on the caller's transaction: task, its audit entry and TaskCreated + * (TasksService.createInTx - the task.created webhook and the calendar + * entry follow from that event), the warning and the rule's audit entry. + * A failing write rolls back all of it; a handler delivery is retried. * * 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. * - * Check and create run in one transaction under a lock on the rule's row: - * two evaluations at the same moment (parallel requests, several pods) - * would otherwise both see "no open task" and both create one. The lock - * is per rule and held for two short statements; webhook, warning and - * audit entry follow after the commit. + * The check runs under a lock on the rule's row, held until the caller + * commits: two evaluations at the same moment (parallel requests, a + * handler and a legacy path, several pods) would otherwise both see + * "no open task" and both create one. */ - private async triggerAction( + private async triggerActionIn( + tx: Tx, rule: AutomationRule, relatedType: TaskRelatedType, relatedId: string, sourceInspectionId?: string, options: { baseDate?: Date; description?: string } = {}, ): Promise { - const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; - const config = rule.config as { dueInDays?: unknown } | null; - const task = await this.prisma.$transaction(async (tx) => { - await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`; - const alreadyOpen = await tx.task.findFirst({ - where: { - sourceAutomationTrigger: rule.triggerType, - relatedType, - relatedId, - status: { in: ['OPEN', 'IN_PROGRESS'] }, - }, - select: { id: true }, - }); - if (alreadyOpen) { - return null; - } - return tx.task.create({ - data: { - title, - priority: 'HIGH', - relatedType, - relatedId, - sourceInspectionId, - sourceAutomationTrigger: rule.triggerType, - description: options.description, - dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate), - }, - }); - }); - if (!task) { - return; - } - 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, + await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`; + const alreadyOpen = await tx.task.findFirst({ + where: { + sourceAutomationTrigger: rule.triggerType, relatedType, relatedId, - triggerType: rule.triggerType, + 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.tasks.createInTx( + tx, + { + title, + priority: 'HIGH', + relatedType, + relatedId, + sourceInspectionId, + sourceAutomationTrigger: rule.triggerType, + description: options.description, + dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate), + }, + null, + ); + await this.notifications.record( + { priority: 'WARNUNG', message: title, relatedType, relatedId }, + tx, + ); + await this.auditLog.record( + { + entityType: 'AutomationRule', + entityId: rule.id, + action: 'CREATE', + afterData: { + taskId: task.id, + relatedType, + relatedId, + triggerType: rule.triggerType, + }, + }, + tx, + ); } } diff --git a/ApiCore-Backend/src/backup/backup-due.spec.ts b/ApiCore-Backend/src/backup/backup-due.spec.ts new file mode 100644 index 0000000..9bb2fb7 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-due.spec.ts @@ -0,0 +1,82 @@ +import { runInNewContext } from 'node:vm'; +import { BACKUP_SCHEDULE_TIME_ZONE, isBackupDue } from './backup-due'; + +describe('isBackupDue', () => { + it('reads the schedule in Europe/Berlin by default', () => { + expect(BACKUP_SCHEDULE_TIME_ZONE).toBe('Europe/Berlin'); + }); + + it('is due when there is no backup yet', () => { + expect( + isBackupDue('0 3 * * *', null, new Date('2026-10-13T00:30:00Z')), + ).toBe(true); + }); + + it('is not due again on the same day for "0 3 * * *"', () => + expect( + isBackupDue( + '0 3 * * *', + new Date('2026-10-12T01:00:00Z'), + new Date('2026-10-13T00:30:00Z'), + ), + ).toBe(false)); + + it('is due after the next 03:00 Europe/Berlin', () => + expect( + isBackupDue( + '0 3 * * *', + new Date('2026-10-12T01:00:00Z'), + new Date('2026-10-13T01:05:00Z'), + ), + ).toBe(true)); + + it('is due exactly at the next scheduled time', () => + expect( + isBackupDue( + '0 3 * * *', + new Date('2026-10-12T01:00:00Z'), + new Date('2026-10-13T01:00:00Z'), + ), + ).toBe(true)); + + it('counts a backup written after the scheduled time (e.g. "Jetzt sichern") as covering it', () => + expect( + isBackupDue( + '0 3 * * *', + new Date('2026-10-13T01:10:00Z'), + new Date('2026-10-13T02:00:00Z'), + ), + ).toBe(false)); + + it('stays due after a missed run until a backup exists', () => + expect( + isBackupDue( + '0 3 * * *', + new Date('2026-10-10T01:00:00Z'), + new Date('2026-10-13T05:00:00Z'), + ), + ).toBe(true)); + + it('uses the given time zone', () => { + const lastBackupAt = new Date('2026-10-12T03:00:00Z'); + const now = new Date('2026-10-13T02:00:00Z'); + + // Next 03:00 UTC is 03:00Z, next 03:00 Berlin was 01:00Z. + expect(isBackupDue('0 3 * * *', lastBackupAt, now, 'UTC')).toBe(false); + expect(isBackupDue('0 3 * * *', lastBackupAt, now)).toBe(true); + }); + + // fs.Stats.mtime is created by Node's core; under Jest it is not an + // instance of the test's Date (cron checks `instanceof Date`). + it('accepts a Date from another realm', () => { + const foreign = runInNewContext('new Date("2026-10-12T01:00:00Z")') as Date; + + expect( + isBackupDue('0 3 * * *', foreign, new Date('2026-10-13T00:30:00Z')), + ).toBe(false); + }); + + it('rejects an invalid schedule even without a backup', () => { + expect(() => isBackupDue('every night', null, new Date())).toThrow(); + }); +}); diff --git a/ApiCore-Backend/src/backup/backup-due.ts b/ApiCore-Backend/src/backup/backup-due.ts new file mode 100644 index 0000000..23d9979 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-due.ts @@ -0,0 +1,34 @@ +import { CronTime } from 'cron'; + +/** + * The time zone a backup schedule is read in - the same as the CronJobs' + * `timeZone`, so "0 3 * * *" means 03:00 in Germany, summer and winter. + */ +export const BACKUP_SCHEDULE_TIME_ZONE = 'Europe/Berlin'; + +/** + * Whether the hourly app-backup check has to write a backup: true while no + * backup exists, otherwise once the first scheduled time after the newest + * backup has come. A backup written after a scheduled time (a manual + * "Jetzt sichern", or a late run) covers that time; a missed or failed run + * stays due until a backup exists. Throws on an invalid schedule, also + * without a backup, so a broken schedule fails the run instead of being + * skipped silently. + */ +export function isBackupDue( + cronSchedule: string, + lastBackupAt: Date | null, + now: Date, + timeZone: string = BACKUP_SCHEDULE_TIME_ZONE, +): boolean { + const schedule = new CronTime(cronSchedule, timeZone); + if (lastBackupAt === null) { + return true; + } + // A copy: fs.Stats.mtime comes from another realm than cron's Date check. + const next = schedule.getNextDateFrom( + new Date(lastBackupAt.getTime()), + timeZone, + ); + return next.toMillis() <= now.getTime(); +} diff --git a/ApiCore-Backend/src/backup/backup-scheduler.service.spec.ts b/ApiCore-Backend/src/backup/backup-scheduler.service.spec.ts index a5b918f..13f1efb 100644 --- a/ApiCore-Backend/src/backup/backup-scheduler.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup-scheduler.service.spec.ts @@ -1,9 +1,12 @@ import { Logger } from '@nestjs/common'; -import { SchedulerRegistry } from '@nestjs/schedule'; -import { CronJob } from 'cron'; import * as path from 'node:path'; -import { Prisma } from '../../generated/prisma'; -import { BackupSchedulerService } from './backup-scheduler.service'; +import { JobRun } from '../../generated/prisma'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobRunService } from '../system/job-run.service'; +import { + APP_BACKUP_LOCK_TIMEOUT_MS, + BackupSchedulerService, +} from './backup-scheduler.service'; import { BackupService } from './backup.service'; import { BackupSettingsService } from './backup-settings.service'; @@ -17,12 +20,30 @@ jest.mock('node:fs/promises', () => ({ import * as fs from 'node:fs/promises'; +type RawCall = [readonly string[], ...unknown[]]; + +const SEALED = { + format: 'apicore-backup', + exportedAt: '2026-10-13T01:05:00.000Z', + ciphertext: 'x'.repeat(2000), +}; + describe('BackupSchedulerService', () => { const originalEnv = { ...process.env }; const directory = path.resolve('/data/backups'); let service: BackupSchedulerService; let backupService: { exportSealed: jest.Mock }; + let settings: { get: jest.Mock }; + let tx: { + $executeRaw: jest.Mock, RawCall>; + $queryRaw: jest.Mock, RawCall>; + }; + let prisma: { $transaction: jest.Mock }; + let jobRuns: { recent: jest.Mock }; let error: jest.SpyInstance; + let warn: jest.SpyInstance; + + const sqlText = (call: RawCall): string => call[0].join('?'); beforeEach(() => { jest.clearAllMocks(); @@ -31,33 +52,43 @@ describe('BackupSchedulerService', () => { delete process.env.BACKUP_RETENTION_COUNT; delete process.env.BACKUP_CRON_SCHEDULE; error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); - backupService = { - exportSealed: jest.fn().mockResolvedValue({ - format: 'apicore-backup', - exportedAt: '2026-10-12T03:00:00.000Z', - ciphertext: 'x'.repeat(2000), - }), - }; - const settings = { + backupService = { exportSealed: jest.fn().mockResolvedValue(SEALED) }; + settings = { get: jest.fn().mockResolvedValue({ storagePath: null, cronSchedule: null, retentionCount: null, }), }; + tx = { + $executeRaw: jest.fn, RawCall>().mockResolvedValue(1), + $queryRaw: jest + .fn, RawCall>() + .mockResolvedValue([{ now: new Date('2026-10-13T01:05:00Z') }]), + }; + prisma = { + $transaction: jest.fn((work: (client: typeof tx) => Promise) => + work(tx), + ), + }; + jobRuns = { recent: jest.fn().mockResolvedValue([]) }; service = new BackupSchedulerService( backupService as unknown as BackupService, settings as unknown as BackupSettingsService, - {} as SchedulerRegistry, + prisma as unknown as PrismaService, + jobRuns as unknown as JobRunService, ); (fs.mkdir as jest.Mock).mockResolvedValue(undefined); (fs.writeFile as jest.Mock).mockResolvedValue(undefined); (fs.readdir as jest.Mock).mockResolvedValue([]); + (fs.unlink as jest.Mock).mockResolvedValue(undefined); }); afterEach(() => { error.mockRestore(); + warn.mockRestore(); process.env = { ...originalEnv }; }); @@ -72,6 +103,19 @@ describe('BackupSchedulerService', () => { }); } + function jobRun(overrides: Partial = {}): JobRun { + return { + id: 'run-1', + job: 'app-backup', + status: 'SUCCEEDED', + startedAt: new Date('2026-10-13T01:00:02.000Z'), + finishedAt: new Date('2026-10-13T01:00:09.000Z'), + result: { written: true }, + error: null, + ...overrides, + }; + } + describe('getStatus', () => { it('reports the import limit and the size of the newest backup', async () => { process.env.BACKUP_MAX_IMPORT_BYTES = '1000'; @@ -129,6 +173,49 @@ describe('BackupSchedulerService', () => { }), ); }); + + it('reports the newest app-backup job run', async () => { + jobRuns.recent.mockResolvedValue([ + jobRun({ + status: 'FAILED', + finishedAt: new Date('2026-10-13T01:00:05.000Z'), + result: null, + error: 'EACCES: permission denied', + }), + ]); + + const status = await service.getStatus(); + + expect(jobRuns.recent).toHaveBeenCalledWith(1, 'app-backup'); + expect(status.lastJobRun).toEqual({ + status: 'FAILED', + startedAt: '2026-10-13T01:00:02.000Z', + finishedAt: '2026-10-13T01:00:05.000Z', + error: 'EACCES: permission denied', + }); + }); + + it('reports a running job run without an end and the run also without a path', async () => { + delete process.env.BACKUP_STORAGE_PATH; + jobRuns.recent.mockResolvedValue([ + jobRun({ status: 'RUNNING', finishedAt: null, result: null }), + ]); + + const status = await service.getStatus(); + + expect(status.lastJobRun).toEqual({ + status: 'RUNNING', + startedAt: '2026-10-13T01:00:02.000Z', + finishedAt: null, + error: null, + }); + }); + + it('reports no job run before the first one', async () => { + const status = await service.getStatus(); + + expect(status.lastJobRun).toBeNull(); + }); }); describe('runNow', () => { @@ -151,48 +238,188 @@ describe('BackupSchedulerService', () => { expect(fs.writeFile).toHaveBeenCalledTimes(1); expect(error).not.toHaveBeenCalled(); }); + + // A human asked for it: no due check, no wait for the schedule lock. + it('always writes, without the schedule lock', async () => { + onDisk([ + { + name: 'apicore-backup-new.json', + size: 900, + mtime: new Date().toISOString(), + }, + ]); + + await service.runNow(); + + expect(fs.writeFile).toHaveBeenCalledTimes(1); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); }); - // The export reads every table through Prisma; a Prisma message quotes - // query values, so a failed scheduled run logs name, code and model only. - describe('scheduled run', () => { - it('logs a failed scheduled backup without the Prisma message', async () => { - const registry = { - doesExist: jest.fn().mockReturnValue(false), - addCronJob: jest.fn(), - deleteCronJob: jest.fn(), - }; - const scheduled = new BackupSchedulerService( - backupService as unknown as BackupService, + describe('runIfDue', () => { + const lastNight = '2026-10-12T01:00:00.000Z'; + + it('writes nothing without a path', async () => { + delete process.env.BACKUP_STORAGE_PATH; + + const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z')); + + expect(result).toEqual({ written: false, reason: 'no-path' }); + expect(backupService.exportSealed).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('writes nothing while the newest backup covers the last scheduled time', async () => { + onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]); + + const result = await service.runIfDue(new Date('2026-10-13T00:30:00Z')); + + expect(result).toEqual({ written: false, reason: 'not-due' }); + expect(backupService.exportSealed).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(fs.unlink).not.toHaveBeenCalled(); + }); + + it('writes and prunes once the next scheduled time has passed', async () => { + process.env.BACKUP_RETENTION_COUNT = '1'; + onDisk([ + { name: 'apicore-backup-a.json', size: 900, mtime: lastNight }, { - get: jest.fn().mockResolvedValue({ - storagePath: null, - cronSchedule: null, - retentionCount: null, - }), - } as unknown as BackupSettingsService, - registry as unknown as SchedulerRegistry, - ); - backupService.exportSealed.mockRejectedValue( - new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', { - code: 'P2010', - clientVersion: '7.9.1', - meta: { modelName: 'Task' }, - }), - ); - await scheduled.reload(); - const [[, job]] = registry.addCronJob.mock.calls as [[string, CronJob]]; - await job.stop(); + name: 'apicore-backup-b.json', + size: 800, + mtime: '2026-10-11T01:00:00.000Z', + }, + ]); - await job.fireOnTick(); - await new Promise((resolve) => setImmediate(resolve)); + const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z')); - const failure = error.mock.calls.find(([message]) => - String(message).startsWith('Scheduled backup failed'), - ) as [string, unknown]; - expect(failure[0]).toContain('P2010'); - expect(failure[0]).not.toContain('Mustermann'); - expect(failure[1]).toBeUndefined(); + expect(fs.writeFile).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + written: true, + fileName: 'apicore-backup-2026-10-13T01-05-00-000Z.json', + sizeBytes: Buffer.byteLength(JSON.stringify(SEALED)), + pruned: 1, + }); + expect(fs.unlink).toHaveBeenCalledWith( + path.join(directory, 'apicore-backup-b.json'), + ); + }); + + it('writes the first backup into an empty directory', async () => { + const result = await service.runIfDue(new Date('2026-10-13T00:30:00Z')); + + expect(result).toEqual(expect.objectContaining({ written: true })); + expect(fs.writeFile).toHaveBeenCalledTimes(1); + }); + + it('reads the schedule in Europe/Berlin', async () => { + process.env.BACKUP_CRON_SCHEDULE = '0 3 * * *'; + onDisk([ + { + name: 'apicore-backup-a.json', + size: 900, + mtime: '2026-10-12T03:00:00.000Z', + }, + ]); + + // 03:00 Berlin = 01:00Z has passed, 03:00 UTC has not. + const result = await service.runIfDue(new Date('2026-10-13T02:00:00Z')); + + expect(result.written).toBe(true); + }); + + it('does not count a file it could not delete as pruned', async () => { + process.env.BACKUP_RETENTION_COUNT = '1'; + onDisk([ + { name: 'apicore-backup-a.json', size: 900, mtime: lastNight }, + { + name: 'apicore-backup-b.json', + size: 800, + mtime: '2026-10-11T01:00:00.000Z', + }, + { + name: 'apicore-backup-c.json', + size: 700, + mtime: '2026-10-10T01:00:00.000Z', + }, + ]); + (fs.unlink as jest.Mock) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('EBUSY')); + + const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z')); + + expect(result).toEqual(expect.objectContaining({ pruned: 1 })); + expect(warn).toHaveBeenCalledTimes(1); + }); + + // Two job pods (a CronJob run and a manual `kubectl create job`) must + // not both find the backup due: the check and the write happen under + // one database lock, so the second one sees the first one's file. + it('checks and writes only while holding the schedule lock', async () => { + await service.runIfDue(new Date('2026-10-13T01:05:00Z')); + + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + timeout: APP_BACKUP_LOCK_TIMEOUT_MS, + maxWait: expect.any(Number) as number, + }); + expect(tx.$executeRaw).toHaveBeenCalledTimes(1); + const [lockCall] = tx.$executeRaw.mock.calls; + expect(sqlText(lockCall)).toContain('pg_advisory_xact_lock'); + expect(lockCall.slice(1)).toEqual(['app-backup:schedule']); + const lockedAt = tx.$executeRaw.mock.invocationCallOrder[0]; + expect( + (fs.readdir as jest.Mock).mock.invocationCallOrder[0], + ).toBeGreaterThan(lockedAt); + expect( + (fs.writeFile as jest.Mock).mock.invocationCallOrder[0], + ).toBeGreaterThan(lockedAt); + }); + + it('takes the time from the database clock when none is given', async () => { + onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]); + tx.$queryRaw.mockResolvedValue([ + { now: new Date('2026-10-13T00:30:00Z') }, + ]); + + // The pod's own clock (2026-10-14) would say due; the database's says no. + jest.useFakeTimers({ now: new Date('2026-10-14T12:00:00Z') }); + try { + await expect(service.runIfDue()).resolves.toEqual({ + written: false, + reason: 'not-due', + }); + } finally { + jest.useRealTimers(); + } + expect(sqlText(tx.$queryRaw.mock.calls[0])).toContain('now()'); + }); + + it('gives up the lock as soon as the backup is not due', async () => { + onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]); + + await service.runIfDue(new Date('2026-10-13T00:30:00Z')); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(fs.mkdir).not.toHaveBeenCalled(); + }); + + it('fails the run when the export fails', async () => { + backupService.exportSealed.mockRejectedValue(new Error('export broke')); + + await expect( + service.runIfDue(new Date('2026-10-13T01:05:00Z')), + ).rejects.toThrow('export broke'); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('fails the run on an invalid schedule instead of skipping silently', async () => { + process.env.BACKUP_CRON_SCHEDULE = 'every night'; + + await expect( + service.runIfDue(new Date('2026-10-13T01:05:00Z')), + ).rejects.toThrow(); + expect(fs.writeFile).not.toHaveBeenCalled(); }); }); }); diff --git a/ApiCore-Backend/src/backup/backup-scheduler.service.ts b/ApiCore-Backend/src/backup/backup-scheduler.service.ts index 5ab1aa8..270b8c8 100644 --- a/ApiCore-Backend/src/backup/backup-scheduler.service.ts +++ b/ApiCore-Backend/src/backup/backup-scheduler.service.ts @@ -1,32 +1,51 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { SchedulerRegistry } from '@nestjs/schedule'; -import { CronJob } from 'cron'; +import { Injectable, Logger } from '@nestjs/common'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { JobRunStatus, Prisma } from '../../generated/prisma'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobRunService } from '../system/job-run.service'; import { BackupService } from './backup.service'; import { BackupSettingsService } from './backup-settings.service'; -import { loggableError } from '../common/loggable-error'; -import { - assertBackupCron, - resolveGuiBackupPath, -} from './backup-schedule-rules'; +import { isBackupDue } from './backup-due'; +import { resolveGuiBackupPath } from './backup-schedule-rules'; import { backupMaxImportBytes, exceedsBackupImportLimit, formatMegabytes, } from './backup-import-limit'; -const SCHEDULED_JOB_NAME = 'automatic-backup'; const DEFAULT_CRON_SCHEDULE = '0 3 * * *'; const DEFAULT_RETENTION_COUNT = 14; const BACKUP_FILE_PREFIX = 'apicore-backup-'; const BACKUP_FILE_SUFFIX = '.json'; -// Guards against two replicas (deployment runs with replicas: 2, no shared -// DB table for scheduling state) both firing the same cron tick: if the most -// recent backup file on this shared/mounted path is younger than this, a -// concurrent replica already handled the tick, so this one skips. -const DUPLICATE_RUN_GUARD_MS = 2 * 60 * 1000; +/** + * Advisory lock (pg_advisory_xact_lock on its hash) around the due check + * and the write of a scheduled backup. + */ +const APP_BACKUP_LOCK = 'app-backup:schedule'; + +/** + * How long a scheduled backup may hold the lock: the activeDeadlineSeconds + * of the app-backup CronJob (3600 s), after which the pod is gone anyway. + */ +export const APP_BACKUP_LOCK_TIMEOUT_MS = 60 * 60 * 1000; + +/** How long to wait for a free pooled connection to take the lock on. */ +const APP_BACKUP_LOCK_MAX_WAIT_MS = 10_000; + +/** What one run of the app-backup job did (JobRun.result). */ +export type AppBackupResult = + | { written: true; fileName: string; sizeBytes: number; pruned: number } + | { written: false; reason: 'no-path' | 'not-due' }; + +/** The newest app-backup job run, as GET /backup/schedule shows it. */ +export interface BackupJobRunStatus { + status: JobRunStatus; + startedAt: string; + finishedAt: string | null; + error: string | null; +} export interface BackupScheduleStatus { enabled: boolean; @@ -48,6 +67,9 @@ export interface BackupScheduleStatus { cronSchedule: boolean; retentionCount: boolean; }; + // The newest run of the hourly app-backup job (CronJob), null before the + // first one. lastRunAt above is the newest backup file instead. + lastJobRun: BackupJobRunStatus | null; } interface EffectiveBackupSettings { @@ -57,20 +79,23 @@ interface EffectiveBackupSettings { envOverrides: BackupScheduleStatus['envOverrides']; } +/** + * Automatic backups. The schedule is not a timer in this process: the + * app-backup CronJob runs every hour and calls runIfDue(), which reads path, + * schedule and retention afresh (env before GUI), so a GUI change applies + * from the next hourly check on. runNow() is the "Jetzt sichern" button. + */ @Injectable() -export class BackupSchedulerService implements OnModuleInit { +export class BackupSchedulerService { private readonly logger = new Logger(BackupSchedulerService.name); constructor( private readonly backupService: BackupService, private readonly backupSettingsService: BackupSettingsService, - private readonly schedulerRegistry: SchedulerRegistry, + private readonly prisma: PrismaService, + private readonly jobRuns: JobRunService, ) {} - async onModuleInit(): Promise { - await this.reload(); - } - private async effectiveSettings(): Promise { const envPath = process.env.BACKUP_STORAGE_PATH; const hasEnvPath = Boolean(envPath && envPath.trim().length > 0); @@ -106,55 +131,10 @@ export class BackupSchedulerService implements OnModuleInit { }; } - // Re-registers (or tears down) the cron job to match the current effective - // settings. Called at boot and again after a GUI settings update, so an - // admin's schedule/path/retention change takes effect immediately without - // a pod restart. - async reload(): Promise { - const settings = await this.effectiveSettings(); - if (!settings.envOverrides.cronSchedule) { - try { - assertBackupCron(settings.cronSchedule); - } catch { - // Keep whatever is running instead of silently ending backups. - this.logger.error( - `Ungültiger Backup-Zeitplan "${settings.cronSchedule}" - bisheriger Zeitplan bleibt aktiv`, - ); - return; - } - } - if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) { - this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME); - } - - if (!settings.path) { - this.logger.log( - 'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert', - ); - return; - } - - const directory = settings.path; - const job = CronJob.from({ - cronTime: settings.cronSchedule, - onTick: () => { - // Prisma errors by name/code/model only (see loggableError). - this.runScheduled(directory).catch((error: unknown) => { - const { message, stack } = loggableError(error); - this.logger.error(`Scheduled backup failed: ${message}`, stack); - }); - }, - start: true, - }); - this.schedulerRegistry.addCronJob(SCHEDULED_JOB_NAME, job); - this.logger.log( - `Automatische Backups aktiv: schedule="${settings.cronSchedule}" path="${directory}" retention=${settings.retentionCount}`, - ); - } - async getStatus(): Promise { const settings = await this.effectiveSettings(); const importLimitBytes = backupMaxImportBytes(); + const lastJobRun = await this.lastJobRun(); if (!settings.path) { return { enabled: false, @@ -166,6 +146,7 @@ export class BackupSchedulerService implements OnModuleInit { lastBackupSizeBytes: null, lastBackupExceedsImportLimit: false, envOverrides: settings.envOverrides, + lastJobRun, }; } @@ -185,11 +166,25 @@ export class BackupSchedulerService implements OnModuleInit { lastBackupSizeBytes !== null && exceedsBackupImportLimit(lastBackupSizeBytes), envOverrides: settings.envOverrides, + lastJobRun, }; } - // Manual trigger (e.g. "Jetzt sichern" button) - always runs, no - // duplicate-tick guard, since a human explicitly asked for it. + private async lastJobRun(): Promise { + const [run] = await this.jobRuns.recent(1, 'app-backup'); + if (!run) { + return null; + } + return { + status: run.status, + startedAt: run.startedAt.toISOString(), + finishedAt: run.finishedAt?.toISOString() ?? null, + error: run.error, + }; + } + + // Manual trigger ("Jetzt sichern") - always writes: no due check and no + // schedule lock, since a human explicitly asked for it. async runNow(): Promise<{ fileName: string }> { const settings = await this.effectiveSettings(); if (!settings.path) { @@ -197,32 +192,61 @@ export class BackupSchedulerService implements OnModuleInit { 'Kein Backup-Pfad konfiguriert - weder per Umgebungsvariable noch über die GUI', ); } - const filePath = await this.writeBackup(settings.path); + const { fileName } = await this.writeBackup(settings.path); await this.pruneOldBackups(settings.path, settings.retentionCount); // Only the name: the server's directory layout stays on the server. - return { fileName: path.basename(filePath) }; + return { fileName }; } - private async runScheduled(directory: string): Promise { + /** + * One hourly check of the app-backup job: writes a backup if one is due + * (isBackupDue against the newest backup file, schedule in + * Europe/Berlin) and prunes beyond the retention. Check and write run + * under one database advisory lock, so a second job pod that checks at + * the same time waits and then finds the first pod's file - two pods + * never both write. Failures are thrown and fail the job run. + */ + async runIfDue(now?: Date): Promise { const settings = await this.effectiveSettings(); - const files = await this.listBackupFiles(directory); - const mostRecent = files[0]; - if ( - mostRecent && - Date.now() - mostRecent.mtime.getTime() < DUPLICATE_RUN_GUARD_MS - ) { - this.logger.log( - 'Skipping scheduled backup - another replica already wrote one for this tick', - ); - return; + if (!settings.path) { + return { written: false, reason: 'no-path' }; } - - const filePath = await this.writeBackup(directory); - this.logger.log(`Scheduled backup written to ${filePath}`); - await this.pruneOldBackups(directory, settings.retentionCount); + const directory = settings.path; + return this.prisma.$transaction( + async (tx): Promise => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${APP_BACKUP_LOCK}, 0))`; + const [newest] = await this.listBackupFiles(directory); + // The database clock, like every other time comparison of the + // platform: all job pods see the same time. + const checkedAt = now ?? (await this.databaseNow(tx)); + if ( + !isBackupDue(settings.cronSchedule, newest?.mtime ?? null, checkedAt) + ) { + return { written: false, reason: 'not-due' }; + } + const { fileName, sizeBytes } = await this.writeBackup(directory); + this.logger.log(`Scheduled backup ${fileName} written`); + const pruned = await this.pruneOldBackups( + directory, + settings.retentionCount, + ); + return { written: true, fileName, sizeBytes, pruned }; + }, + { + timeout: APP_BACKUP_LOCK_TIMEOUT_MS, + maxWait: APP_BACKUP_LOCK_MAX_WAIT_MS, + }, + ); } - private async writeBackup(directory: string): Promise { + private async databaseNow(tx: Prisma.TransactionClient): Promise { + const [row] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`; + return row.now; + } + + private async writeBackup( + directory: string, + ): Promise<{ fileName: string; sizeBytes: number }> { const sealed = await this.backupService.exportSealed(); await fs.mkdir(directory, { recursive: true, mode: 0o700 }); const timestamp = sealed.exportedAt.replace(/[:.]/g, '-'); @@ -241,26 +265,30 @@ export class BackupSchedulerService implements OnModuleInit { `Backup ${path.basename(filePath)} ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern (BACKUP_MAX_IMPORT_BYTES erhöhen)`, ); } - return filePath; + return { fileName: path.basename(filePath), sizeBytes }; } + /** Deletes all but the newest `retentionCount` backups; returns how many. */ private async pruneOldBackups( directory: string, retentionCount: number, - ): Promise { + ): Promise { const files = await this.listBackupFiles(directory); const toDelete = files.slice(retentionCount); - await Promise.all( + const deleted = await Promise.all( toDelete.map((file) => - fs - .unlink(file.filePath) - .catch((error) => + fs.unlink(file.filePath).then( + () => true, + (error: unknown) => { this.logger.warn( `Could not delete old backup ${file.filePath}: ${String(error)}`, - ), - ), + ); + return false; + }, + ), ), ); + return deleted.filter(Boolean).length; } private async listBackupFiles( diff --git a/ApiCore-Backend/src/backup/backup.controller.spec.ts b/ApiCore-Backend/src/backup/backup.controller.spec.ts index d8ad956..2465bd1 100644 --- a/ApiCore-Backend/src/backup/backup.controller.spec.ts +++ b/ApiCore-Backend/src/backup/backup.controller.spec.ts @@ -11,7 +11,8 @@ describe('BackupController', () => { const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES; let controller: BackupController; let backupService: { importFile: jest.Mock; exportSealed: jest.Mock }; - let scheduler: { reload: jest.Mock }; + let scheduler: Record; + let settings: { update: jest.Mock }; let reauth: { verify: jest.Mock }; const actor = { id: 'admin-1' } as AuthenticatedUser; const file = { @@ -28,12 +29,13 @@ describe('BackupController', () => { ciphertext: 'x'.repeat(2000), }), }; - scheduler = { reload: jest.fn().mockResolvedValue(undefined) }; + scheduler = {}; + settings = { update: jest.fn() }; reauth = { verify: jest.fn().mockResolvedValue(undefined) }; controller = new BackupController( backupService as unknown as BackupService, scheduler as unknown as BackupSchedulerService, - {} as BackupSettingsService, + settings as unknown as BackupSettingsService, reauth as unknown as ReauthService, ); }); @@ -46,23 +48,29 @@ describe('BackupController', () => { } }); - it('re-registers the backup schedule after an import, since it may restore other backup settings', async () => { - await controller.import(file, { password: 'secret' }, actor); - - expect(backupService.importFile).toHaveBeenCalledTimes(1); - expect(scheduler.reload).toHaveBeenCalledTimes(1); - expect(scheduler.reload.mock.invocationCallOrder[0]).toBeGreaterThan( - backupService.importFile.mock.invocationCallOrder[0], - ); - }); - - it('leaves the schedule alone when the import fails', async () => { - backupService.importFile.mockRejectedValue(new Error('broken file')); - + // The app-backup job reads the settings on every run, so nothing in the + // API has to be told about a restore or a settings change. The scheduler + // double has no methods: any call on it fails these tests. + it('imports without touching the schedule', async () => { await expect( controller.import(file, { password: 'secret' }, actor), - ).rejects.toThrow('broken file'); - expect(scheduler.reload).not.toHaveBeenCalled(); + ).resolves.toEqual({ imported: {}, skipped: [] }); + + expect(backupService.importFile).toHaveBeenCalledTimes(1); + }); + + it('saves settings without touching the schedule', async () => { + const saved = { id: 'singleton', cronSchedule: '0 4 * * *' }; + settings.update.mockResolvedValue(saved); + + await expect( + controller.updateSettings({ cronSchedule: '0 4 * * *' }, actor), + ).resolves.toBe(saved); + + expect(settings.update).toHaveBeenCalledWith( + { cronSchedule: '0 4 * * *' }, + 'admin-1', + ); }); describe('export', () => { diff --git a/ApiCore-Backend/src/backup/backup.controller.ts b/ApiCore-Backend/src/backup/backup.controller.ts index 5461e0b..b62f8e2 100644 --- a/ApiCore-Backend/src/backup/backup.controller.ts +++ b/ApiCore-Backend/src/backup/backup.controller.ts @@ -65,13 +65,12 @@ export class BackupController { @RequirePermission('backup:configure') @Patch('settings') - async updateSettings( + updateSettings( @Body() dto: UpdateBackupSettingsDto, @CurrentUser() actor: AuthenticatedUser, ): Promise { - const settings = await this.backupSettingsService.update(dto, actor.id); - await this.backupSchedulerService.reload(); - return settings; + // Read afresh by the next hourly app-backup check. + return this.backupSettingsService.update(dto, actor.id); } @RequirePermission('backup:export') @@ -129,9 +128,7 @@ export class BackupController { } catch { throw new BadRequestException('Datei ist kein gültiges JSON'); } - const summary = await this.backupService.importFile(parsed, actor.id); - // The backup may have restored the schedule's settings too. - await this.backupSchedulerService.reload(); - return summary; + // Restored backup settings apply from the next hourly app-backup check. + return this.backupService.importFile(parsed, actor.id); } } diff --git a/ApiCore-Backend/src/backup/backup.module.ts b/ApiCore-Backend/src/backup/backup.module.ts index 540df2b..7c95dbb 100644 --- a/ApiCore-Backend/src/backup/backup.module.ts +++ b/ApiCore-Backend/src/backup/backup.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { MulterModule } from '@nestjs/platform-express'; import { ReauthModule } from '../auth/reauth/reauth.module'; +import { SystemModule } from '../system/system.module'; import { BackupController } from './backup.controller'; import { BackupService } from './backup.service'; import { BackupSchedulerService } from './backup-scheduler.service'; @@ -10,6 +11,8 @@ import { backupMaxImportBytes } from './backup-import-limit'; @Module({ imports: [ ReauthModule, + // JobRunService: GET /backup/schedule shows the newest app-backup run. + SystemModule, // Upload limit of POST /backup/import. A factory rather than a constant // in the decorator: it runs once the app starts, after ConfigModule has // loaded .env, so the limit is the same one exports are checked against. @@ -19,5 +22,7 @@ import { backupMaxImportBytes } from './backup-import-limit'; ], controllers: [BackupController], providers: [BackupService, BackupSchedulerService, BackupSettingsService], + // For the app-backup job (JobsModule, dist/cli.js). + exports: [BackupSchedulerService], }) export class BackupModule {} diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts index 9dc489c..061eab2 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.spec.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -6,7 +6,7 @@ import { import { BottlingService } from './bottling.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; import { AutomationService } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; @@ -22,6 +22,32 @@ function applyAmountChange( return current + (change.increment ?? 0) - (change.decrement ?? 0); } +/** + * What one bottling write records in a single recordAll - so both share a + * correlation id: its honey batch event and StockChanged for its jars. + */ +function bottlingEvents(honeyEvent: { type: string; payload: unknown }) { + return [ + { + ...honeyEvent, + aggregateType: 'BottlingBatch', + aggregateId: 'b1', + actorUserId: 'user-1', + }, + { + type: 'StockChanged', + aggregateType: 'BottlingBatch', + aggregateId: 'b1', + payload: { + bottlingBatchIds: ['b1'], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: 'user-1', + }, + ]; +} + describe('BottlingService', () => { let service: BottlingService; let prisma: { @@ -36,28 +62,44 @@ describe('BottlingService', () => { $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; + let events: { recordAll: jest.Mock }; let tx: { - bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock }; + $queryRaw: jest.Mock; + product: { findUnique: jest.Mock }; + bottlingBatch: { + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; honeyBatch: { findUnique: jest.Mock; update: jest.Mock; updateMany: jest.Mock; }; }; - let webhooks: { dispatch: jest.Mock }; let stock: { remainingByBottling: jest.Mock; - notify: jest.Mock; + stockChangedEvent: jest.Mock; hasMovements: jest.Mock; }; // Honey batch rows as the transaction sees them - the tests assert the // resulting amounts instead of the exact sequence of UPDATE statements. let honeyBatches: Map; + // The order of lock and reads inside the transaction. + let steps: string[]; beforeEach(() => { honeyBatches = new Map(); + steps = []; tx = { + $queryRaw: jest.fn(() => { + steps.push('lock'); + return Promise.resolve([]); + }), + product: { findUnique: jest.fn() }, bottlingBatch: { + findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), @@ -123,22 +165,45 @@ describe('BottlingService', () => { ), }; auditLog = { record: jest.fn() }; - webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + events = { recordAll: jest.fn() }; const automation = { generateNumber: jest.fn().mockResolvedValue(null) }; stock = { remainingByBottling: jest.fn().mockResolvedValue(new Map()), - notify: jest.fn().mockResolvedValue(undefined), + // As StockService builds it - covered by its own spec. + stockChangedEvent: jest.fn( + ( + payload: unknown, + actorUserId: string | null, + aggregate: { type: string; id: string }, + ) => ({ + type: 'StockChanged', + aggregateType: aggregate.type, + aggregateId: aggregate.id, + payload, + actorUserId, + }), + ), hasMovements: jest.fn().mockResolvedValue(false), }; service = new BottlingService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, - webhooks as unknown as WebhooksService, + events as unknown as DomainEventsService, automation as unknown as AutomationService, stock as unknown as StockService, ); }); + /** The bottling row as the transaction reads it after locking. */ + function lockedRows(...rows: Record[]) { + for (const row of rows) { + tx.bottlingBatch.findUnique.mockImplementationOnce(() => { + steps.push('read'); + return Promise.resolve(row); + }); + } + } + it('throws NotFoundException when the honey batch does not exist', async () => { prisma.honeyBatch.findUnique.mockResolvedValueOnce(null); await expect( @@ -185,6 +250,7 @@ describe('BottlingService', () => { honeyBatches.set('h1', { amount: 25 }); tx.bottlingBatch.create.mockResolvedValueOnce({ id: 'b1', + honeyBatchId: 'h1', totalAmount: 25, }); prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ @@ -211,6 +277,42 @@ describe('BottlingService', () => { entityType: 'BottlingBatch', action: 'CREATE', }), + tx, + ); + }); + + it('bottling create records BatchBottled and StockChanged together', async () => { + prisma.honeyBatch.findUnique.mockResolvedValueOnce({ + id: 'h1', + amount: 25, + }); + honeyBatches.set('h1', { amount: 25 }); + tx.bottlingBatch.create.mockResolvedValueOnce({ + id: 'b1', + honeyBatchId: 'h1', + totalAmount: 5, + }); + prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' }); + + await service.create( + { + honeyBatchId: 'h1', + date: '2026-01-01', + jarSize: 500, + jarCount: 10, + bestBeforeDate: '2027-01-01', + lotNumber: 'L-1', + }, + 'user-1', + ); + + expect(events.recordAll).toHaveBeenCalledTimes(1); + expect(events.recordAll).toHaveBeenCalledWith( + tx, + bottlingEvents({ + type: 'BatchBottled', + payload: { honeyBatchId: 'h1' }, + }), ); }); @@ -238,9 +340,10 @@ describe('BottlingService', () => { ).rejects.toBeInstanceOf(BadRequestException); expect(tx.bottlingBatch.create).not.toHaveBeenCalled(); expect(honeyBatches.get('h1')?.amount).toBe(3); + expect(events.recordAll).not.toHaveBeenCalled(); }); - it('update writes an audit log entry with before and after data', async () => { + it('update writes an audit log entry with before and after data in its transaction', async () => { const before = { id: 'b1', lotNumber: 'L-1', @@ -251,18 +354,12 @@ describe('BottlingService', () => { totalAmount: 5, }; const updated = { ...before, lotNumber: 'L-2' }; - prisma.bottlingBatch.findUnique - .mockResolvedValueOnce(before) - .mockResolvedValueOnce(updated); + lockedRows(before, updated); const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); // Without movements the remaining stock equals the jar count. expect(result).toEqual({ ...updated, remainingCount: 10 }); - expect(stock.notify).toHaveBeenCalledWith({ - bottlingBatchIds: ['b1'], - productIds: [], - }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'BottlingBatch', @@ -272,16 +369,125 @@ describe('BottlingService', () => { beforeData: { ...before, remainingCount: 10 }, afterData: { ...updated, remainingCount: 10 }, }), + tx, + ); + }); + + it('update takes "before" from the row locked in its transaction, never from a read made earlier', async () => { + // A read outside the transaction would see this stale copy. + prisma.bottlingBatch.findUnique.mockResolvedValue({ + id: 'b1', + lotNumber: 'STALE', + honeyBatchId: 'h-stale', + honeyBatch: { id: 'h-stale', amount: 0 }, + jarSize: 250, + jarCount: 1, + totalAmount: 0.25, + }); + const locked = { + id: 'b1', + lotNumber: 'L-1', + honeyBatchId: 'h1', + honeyBatch: { id: 'h1', amount: 10 }, + jarSize: 500, + jarCount: 10, + totalAmount: 5, + }; + lockedRows(locked, { ...locked, lotNumber: 'L-2' }); + + await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); + + expect(steps.slice(0, 2)).toEqual(['lock', 'read']); + expect(prisma.bottlingBatch.findUnique).not.toHaveBeenCalled(); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + beforeData: { ...locked, remainingCount: 10 }, + }), + tx, + ); + // Nothing moved: the stale honey batch is never touched. + expect(tx.honeyBatch.updateMany).not.toHaveBeenCalled(); + }); + + it('update records BottlingCorrected and StockChanged in its transaction', async () => { + const before = { + id: 'b1', + honeyBatchId: 'h1', + honeyBatch: { id: 'h1', amount: 10 }, + jarSize: 500, + jarCount: 10, + totalAmount: 5, + }; + lockedRows(before, { ...before, lotNumber: 'L-2' }); + + await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); + + expect(events.recordAll).toHaveBeenCalledWith( + tx, + bottlingEvents({ + type: 'BottlingCorrected', + payload: { honeyBatchId: 'h1', removed: false }, + }), ); }); it('rejects updating to a product that does not exist', async () => { - prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' }); - prisma.product.findUnique.mockResolvedValueOnce(null); + lockedRows({ + id: 'b1', + honeyBatchId: 'h1', + honeyBatch: { id: 'h1', amount: 10 }, + jarSize: 500, + jarCount: 10, + totalAmount: 5, + }); + tx.product.findUnique.mockResolvedValueOnce(null); await expect( service.update('b1', { productId: 'missing' }, 'user-1'), + ).rejects.toThrow(new NotFoundException('Product not found')); + expect(tx.bottlingBatch.update).not.toHaveBeenCalled(); + expect(events.recordAll).not.toHaveBeenCalled(); + }); + + it('update reports an unknown bottling before it checks the product', async () => { + lockedRows(null as unknown as Record); + tx.product.findUnique.mockResolvedValue(null); + prisma.product.findUnique.mockResolvedValue(null); + + await expect( + service.update('missing', { productId: 'also-missing' }, 'user-1'), + ).rejects.toThrow(new NotFoundException('Bottling batch not found')); + expect(tx.product.findUnique).not.toHaveBeenCalled(); + expect(prisma.product.findUnique).not.toHaveBeenCalled(); + }); + + it('update refuses an article that keeps stock in production batches', async () => { + lockedRows({ + id: 'b1', + honeyBatchId: 'h1', + honeyBatch: { id: 'h1', amount: 10 }, + jarSize: 500, + jarCount: 10, + totalAmount: 5, + }); + tx.product.findUnique.mockResolvedValueOnce({ + id: 'candle', + kind: 'MANUFACTURED', + }); + + await expect( + service.update('b1', { productId: 'candle' }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.bottlingBatch.update).not.toHaveBeenCalled(); + }); + + it('update throws NotFoundException for an unknown bottling batch', async () => { + lockedRows(null as unknown as Record); + + await expect( + service.update('missing', { lotNumber: 'L-2' }, 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); + expect(events.recordAll).not.toHaveBeenCalled(); }); it('clears the product assignment when productId is null', async () => { @@ -294,9 +500,7 @@ describe('BottlingService', () => { totalAmount: 5, productId: 'p1', }; - prisma.bottlingBatch.findUnique - .mockResolvedValueOnce(before) - .mockResolvedValueOnce({ ...before, productId: null }); + lockedRows(before, { ...before, productId: null }); await service.update('b1', { productId: null }, 'user-1'); @@ -317,9 +521,7 @@ describe('BottlingService', () => { jarCount: 20, totalAmount: 10, }; - prisma.bottlingBatch.findUnique - .mockResolvedValueOnce(before) - .mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 }); + lockedRows(before, { ...before, jarCount: 10, totalAmount: 5 }); honeyBatches.set('h1', { amount: 0 }); await service.update('b1', { jarCount: 10 }, 'user-1'); @@ -333,7 +535,7 @@ describe('BottlingService', () => { }); it('rejects a corrected amount that exceeds the honey batch capacity', async () => { - prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ + lockedRows({ id: 'b1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 0 }, @@ -345,6 +547,8 @@ describe('BottlingService', () => { await expect( service.update('b1', { jarCount: 40 }, 'user-1'), ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.bottlingBatch.update).not.toHaveBeenCalled(); + expect(events.recordAll).not.toHaveBeenCalled(); }); it('moves the amount between honey batches when the honey batch changes', async () => { @@ -356,10 +560,11 @@ describe('BottlingService', () => { jarCount: 10, totalAmount: 5, }; - prisma.bottlingBatch.findUnique - .mockResolvedValueOnce(before) - .mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' }); - prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 }); + lockedRows(before, { + ...before, + honeyBatchId: 'h2', + honeyBatch: { id: 'h2', amount: 3 }, + }); honeyBatches.set('h1', { amount: 2 }); honeyBatches.set('h2', { amount: 8 }); @@ -367,21 +572,33 @@ describe('BottlingService', () => { expect(honeyBatches.get('h1')?.amount).toBe(7); expect(honeyBatches.get('h2')).toEqual({ amount: 3, status: 'BOTTLED' }); + // Receivers hear about the honey batch the bottling now draws on. + expect(events.recordAll).toHaveBeenCalledWith( + tx, + expect.arrayContaining([ + expect.objectContaining({ + type: 'BottlingCorrected', + payload: { honeyBatchId: 'h2', removed: false }, + }), + ]), + ); }); - it('remove gives the amount back and records a DELETE audit entry', async () => { + it('remove gives the amount back and records a DELETE audit entry in its transaction', async () => { const before = { id: 'b1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 2 }, + jarCount: 10, totalAmount: 5, }; - prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before); + lockedRows(before); honeyBatches.set('h1', { amount: 2 }); const result = await service.remove('b1', 'user-1'); expect(result).toEqual({ id: 'b1' }); + expect(steps.slice(0, 2)).toEqual(['lock', 'read']); expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({ where: { id: 'b1' }, }); @@ -391,17 +608,21 @@ describe('BottlingService', () => { entityType: 'BottlingBatch', entityId: 'b1', action: 'DELETE', - beforeData: before, + beforeData: { ...before, remainingCount: 10 }, }), + tx, ); - expect(webhooks.dispatch).toHaveBeenCalledWith( - 'batch.updated', - before.honeyBatch, + expect(events.recordAll).toHaveBeenCalledWith( + tx, + bottlingEvents({ + type: 'BottlingCorrected', + payload: { honeyBatchId: 'h1', removed: true }, + }), ); }); it('remove refuses a bottling whose jars were already sold or withdrawn', async () => { - prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ + lockedRows({ id: 'b1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 2 }, @@ -412,7 +633,13 @@ describe('BottlingService', () => { await expect(service.remove('b1', 'user-1')).rejects.toBeInstanceOf( ConflictException, ); + // Checked under the row lock, through the transaction. + expect(stock.hasMovements).toHaveBeenCalledWith( + { kind: 'bottling', id: 'b1' }, + tx, + ); expect(tx.bottlingBatch.delete).not.toHaveBeenCalled(); + expect(events.recordAll).not.toHaveBeenCalled(); }); it('refuses to bottle onto an article that keeps stock in production batches', async () => { @@ -442,7 +669,7 @@ describe('BottlingService', () => { }); it('remove throws NotFoundException for an unknown bottling batch', async () => { - prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null); + lockedRows(null as unknown as Record); await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf( NotFoundException, diff --git a/ApiCore-Backend/src/bottling/bottling.service.ts b/ApiCore-Backend/src/bottling/bottling.service.ts index d02b9e5..5e4dff7 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.ts @@ -5,8 +5,11 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { DbClient } from '../prisma/db-client'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import type { AnyNewDomainEvent } from '../domain-events/domain-event-types'; import { AutomationService } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; import { BottlingBatch, Prisma } from '../../generated/prisma'; @@ -99,12 +102,18 @@ async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise { } } +/** + * Bottlings of a honey batch. Every write records its audit entry and its + * events in its own transaction: BatchBottled or BottlingCorrected (the + * honey batch's amount changed - batch.updated webhook) and StockChanged + * (the bottling's jars - stock.changed webhook). + */ @Injectable() export class BottlingService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly webhooks: WebhooksService, + private readonly events: DomainEventsService, private readonly automation: AutomationService, private readonly stock: StockService, ) {} @@ -124,15 +133,17 @@ export class BottlingService { })); } - async findById(id: string) { - const bottlingBatch = await this.prisma.bottlingBatch.findUnique({ + /** With `tx` it reads what the caller's transaction sees. */ + async findById(id: string, tx?: Tx) { + const db: DbClient = tx ?? this.prisma; + const bottlingBatch = await db.bottlingBatch.findUnique({ where: { id }, include: bottlingBatchInclude, }); if (!bottlingBatch) { throw new NotFoundException('Bottling batch not found'); } - const remaining = await this.stock.remainingByBottling([id]); + const remaining = await this.stock.remainingByBottling([id], tx); return { ...bottlingBatch, remainingCount: remaining.get(id) ?? bottlingBatch.jarCount, @@ -140,8 +151,12 @@ export class BottlingService { } /** Honey is bottled; other articles keep their stock in production batches. */ - private async assertBottledProduct(productId: string): Promise { - const product = await this.prisma.product.findUnique({ + private async assertBottledProduct( + productId: string, + tx?: Tx, + ): Promise { + const db: DbClient = tx ?? this.prisma; + const product = await db.product.findUnique({ where: { id: productId }, }); if (!product) { @@ -186,7 +201,7 @@ export class BottlingService { // time (double tap, two devices) must not both pass the check and // overwrite each other's deduction. await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount); - return tx.bottlingBatch.create({ + const created = await tx.bottlingBatch.create({ data: { honeyBatchId: dto.honeyBatchId, productId: dto.productId, @@ -199,24 +214,28 @@ export class BottlingService { userId: actingUserId, }, }); + await this.auditLog.record( + { + entityType: 'BottlingBatch', + entityId: created.id, + action: 'CREATE', + userId: actingUserId, + afterData: created, + }, + tx, + ); + await this.recordEvents(tx, { + type: 'BatchBottled', + aggregateType: 'BottlingBatch', + aggregateId: created.id, + payload: { honeyBatchId: created.honeyBatchId }, + actorUserId: actingUserId, + }); + return created; }, ); - await this.auditLog.record({ - entityType: 'BottlingBatch', - entityId: bottlingBatch.id, - action: 'CREATE', - userId: actingUserId, - afterData: bottlingBatch, - }); - - const created = await this.findById(bottlingBatch.id); - await this.webhooks.dispatch('batch.updated', created.honeyBatch); - await this.stock.notify({ - bottlingBatchIds: [created.id], - productIds: [], - }); - return created; + return this.findById(bottlingBatch.id); } /** @@ -225,43 +244,48 @@ export class BottlingService { * of the affected honey batch(es): the previously bottled amount is given * back before the corrected amount is taken again, so a correction never * double-charges the source batch. + * + * Everything that depends on the current bottling - defaults, amount + * check, audit "before" - is decided on the row locked in the + * transaction, so a concurrent correction cannot slip in between. An + * unknown bottling is reported (404) before anything about the request. */ async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) { - const before = await this.findById(id); - - if (dto.productId) { - await this.assertBottledProduct(dto.productId); - } - - const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId; - const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId; - const jarSize = dto.jarSize ?? before.jarSize; - const jarCount = dto.jarCount ?? before.jarCount; - const totalAmount = roundKg((jarSize * jarCount) / 1000); - - const targetHoneyBatch = movedToOtherHoneyBatch - ? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } }) - : before.honeyBatch; - if (!targetHoneyBatch) { - throw new NotFoundException('Honey batch not found'); - } - - // Early, friendly check against the amounts read above. The booking - // itself happens atomically inside the transaction below. - const availableOnTarget = - targetHoneyBatch.amount === null - ? null - : roundKg( - targetHoneyBatch.amount + - (movedToOtherHoneyBatch ? 0 : before.totalAmount), - ); - if (availableOnTarget !== null && totalAmount > availableOnTarget) { - throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); - } - // A duplicate lotNumber surfaces as Prisma P2002 and is turned into a // 409 with a field-specific message by AllExceptionsFilter. - await this.prisma.$transaction(async (tx) => { + return this.prisma.$transaction(async (tx) => { + await lockRowForUpdate(tx, 'BottlingBatch', id); + const before = await this.findById(id, tx); + if (dto.productId) { + await this.assertBottledProduct(dto.productId, tx); + } + + const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId; + const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId; + const jarSize = dto.jarSize ?? before.jarSize; + const jarCount = dto.jarCount ?? before.jarCount; + const totalAmount = roundKg((jarSize * jarCount) / 1000); + + const targetHoneyBatch = movedToOtherHoneyBatch + ? await tx.honeyBatch.findUnique({ where: { id: honeyBatchId } }) + : before.honeyBatch; + if (!targetHoneyBatch) { + throw new NotFoundException('Honey batch not found'); + } + + // Early, friendly check; the booking below is still conditional on + // the stored amount (takeFromHoneyBatch). + const availableOnTarget = + targetHoneyBatch.amount === null + ? null + : roundKg( + targetHoneyBatch.amount + + (movedToOtherHoneyBatch ? 0 : before.totalAmount), + ); + if (availableOnTarget !== null && totalAmount > availableOnTarget) { + throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); + } + const amountChanged = movedToOtherHoneyBatch || totalAmount !== before.totalAmount; if (amountChanged) { @@ -291,21 +315,30 @@ export class BottlingService { lotNumber: dto.lotNumber, }, }); - }); - const bottlingBatch = await this.findById(id); - await this.auditLog.record({ - entityType: 'BottlingBatch', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: bottlingBatch, + const bottlingBatch = await this.findById(id, tx); + await this.auditLog.record( + { + entityType: 'BottlingBatch', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: bottlingBatch, + }, + tx, + ); + // Receivers hear about the honey batch the bottling draws on now; a + // corrected jar count changes the remaining stock as well. + await this.recordEvents(tx, { + type: 'BottlingCorrected', + aggregateType: 'BottlingBatch', + aggregateId: id, + payload: { honeyBatchId, removed: false }, + actorUserId: actingUserId, + }); + return bottlingBatch; }); - await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch); - // A corrected jar count changes the remaining stock as well. - await this.stock.notify({ bottlingBatchIds: [id], productIds: [] }); - return bottlingBatch; } /** @@ -318,30 +351,63 @@ export class BottlingService { * write a fact that never happened. */ async remove(id: string, actingUserId: string) { - const before = await this.findById(id); - // Once jars were sold or withdrawn, the bottling is history: deleting it - // would erase where they came from. Wrong stock is fixed by an inventory - // correction instead. - if (await this.stock.hasMovements({ kind: 'bottling', id })) { - throw new ConflictException( - 'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen', - ); - } - await this.prisma.$transaction(async (tx) => { + // Shop sales and withdrawals lock the same row, so the movement check + // below cannot be raced. + await lockRowForUpdate(tx, 'BottlingBatch', id); + const before = await this.findById(id, tx); + // Once jars were sold or withdrawn, the bottling is history: deleting + // it would erase where they came from. Wrong stock is fixed by an + // inventory correction instead. + if (await this.stock.hasMovements({ kind: 'bottling', id }, tx)) { + throw new ConflictException( + 'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen', + ); + } await tx.bottlingBatch.delete({ where: { id } }); await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount); + await this.auditLog.record( + { + entityType: 'BottlingBatch', + entityId: id, + action: 'DELETE', + userId: actingUserId, + beforeData: before, + }, + tx, + ); + await this.recordEvents(tx, { + type: 'BottlingCorrected', + aggregateType: 'BottlingBatch', + aggregateId: id, + payload: { honeyBatchId: before.honeyBatchId, removed: true }, + actorUserId: actingUserId, + }); }); - - await this.auditLog.record({ - entityType: 'BottlingBatch', - entityId: id, - action: 'DELETE', - userId: actingUserId, - beforeData: before, - }); - await this.webhooks.dispatch('batch.updated', before.honeyBatch); - await this.stock.notify({ bottlingBatchIds: [id], productIds: [] }); return { id }; } + + /** + * The honey batch event of a bottling write and StockChanged for the + * bottling's jars, in the caller's transaction - in one recordAll, so + * both carry the correlation id of the one action they describe. + */ + private async recordEvents( + tx: Tx, + event: AnyNewDomainEvent<'BatchBottled' | 'BottlingCorrected'>, + ): Promise { + const stockChanged = this.stock.stockChangedEvent( + { + bottlingBatchIds: [event.aggregateId], + productIds: [], + rawMaterialKinds: [], + }, + event.actorUserId ?? null, + { type: 'BottlingBatch', id: event.aggregateId }, + ); + await this.events.recordAll( + tx, + stockChanged === null ? [event] : [event, stockChanged], + ); + } } diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts new file mode 100644 index 0000000..15b35d4 --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts @@ -0,0 +1,115 @@ +import { CalendarSyncService } from './calendar-sync.service'; +import { CalendarSyncEventHandlers } from './calendar-sync.handlers'; +import type { + AnyDomainEventEnvelope, + DomainEventType, +} from '../domain-events/domain-event-types'; +import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; +import { Prisma } from '../../generated/prisma'; + +describe('CalendarSyncEventHandlers', () => { + let handlers: CalendarSyncEventHandlers; + let calendarSync: { enqueueIn: jest.Mock }; + const tx = {} as Prisma.TransactionClient; + + const envelope = ( + type: T, + aggregateId: string, + payload: Record, + ) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope; + + beforeEach(() => { + calendarSync = { enqueueIn: jest.fn().mockResolvedValue(undefined) }; + handlers = new CalendarSyncEventHandlers( + calendarSync as unknown as CalendarSyncService, + ); + }); + + it.each([ + ['inspection', 'INSPECTION', 'InspectionRecorded'], + ['inspection', 'INSPECTION', 'InspectionCorrected'], + ['treatment', 'TREATMENT', 'TreatmentRecorded'], + ['treatment', 'TREATMENT', 'TreatmentCorrected'], + ['feeding', 'FEEDING', 'FeedingRecorded'], + ['feeding', 'FEEDING', 'FeedingCorrected'], + ['harvest', 'HARVEST', 'HarvestRecorded'], + ['harvest', 'HARVEST', 'HarvestCorrected'], + ] as const)( + '%s handler enqueues %s on %s through tx', + async (method, sourceType, type) => { + await handlers[method](envelope(type, 'src-1', {}) as never, tx); + + expect(calendarSync.enqueueIn).toHaveBeenCalledWith( + tx, + sourceType, + 'src-1', + ); + }, + ); + + it.each(['TaskCreated', 'TaskUpdated', 'TaskCompleted'] as const)( + 'task handler enqueues the task on %s', + async (type) => { + await handlers.task( + envelope(type, 'task-1', { dueDate: '2027-05-01' }), + tx, + ); + + expect(calendarSync.enqueueIn).toHaveBeenCalledWith(tx, 'TASK', 'task-1'); + }, + ); + + it('TaskCreated without a due date enqueues nothing', async () => { + await handlers.task( + envelope('TaskCreated', 'task-1', { dueDate: null }), + tx, + ); + + expect(calendarSync.enqueueIn).not.toHaveBeenCalled(); + }); + + it('TaskUpdated without a due date still enqueues, so the worker removes the event', async () => { + await handlers.task( + envelope('TaskUpdated', 'task-1', { dueDate: null, status: 'OPEN' }), + tx, + ); + + expect(calendarSync.enqueueIn).toHaveBeenCalledWith(tx, 'TASK', 'task-1'); + }); + + it('lets an enqueue failure propagate so the delivery is retried', async () => { + calendarSync.enqueueIn.mockRejectedValue(new Error('db down')); + + await expect( + handlers.inspection(envelope('InspectionRecorded', 'i-1', {}), tx), + ).rejects.toThrow('db down'); + }); + + it('registers the stable handler ids and event types', () => { + const registered = (method: keyof CalendarSyncEventHandlers) => + domainEventHandlerMetadata( + Object.getOwnPropertyDescriptor( + CalendarSyncEventHandlers.prototype, + method, + )?.value as object, + ); + + expect(registered('inspection')).toEqual([ + { + types: ['InspectionRecorded', 'InspectionCorrected'], + handlerId: 'calendar-sync.inspection', + }, + ]); + expect(registered('treatment')[0].handlerId).toBe( + 'calendar-sync.treatment', + ); + expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding'); + expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest'); + expect(registered('task')).toEqual([ + { + types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'], + handlerId: 'calendar-sync.task', + }, + ]); + }); +}); diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts new file mode 100644 index 0000000..8aacc51 --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; +import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; +import { Prisma } from '../../generated/prisma'; +import { CalendarSyncService } from './calendar-sync.service'; + +/** + * Puts calendar-relevant records into the M365 outbox. Pure DB writer: the + * Graph call itself happens in CalendarSyncService.processOutbox (worker + * loop). Errors propagate, so a failed delivery is retried. + */ +@Injectable() +export class CalendarSyncEventHandlers { + constructor(private readonly calendarSync: CalendarSyncService) {} + + @OnDomainEvent( + ['InspectionRecorded', 'InspectionCorrected'], + 'calendar-sync.inspection', + ) + async inspection( + event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>, + tx: Prisma.TransactionClient, + ): Promise { + await this.calendarSync.enqueueIn(tx, 'INSPECTION', event.aggregateId); + } + + @OnDomainEvent( + ['TreatmentRecorded', 'TreatmentCorrected'], + 'calendar-sync.treatment', + ) + async treatment( + event: AnyDomainEventEnvelope<'TreatmentRecorded' | 'TreatmentCorrected'>, + tx: Prisma.TransactionClient, + ): Promise { + await this.calendarSync.enqueueIn(tx, 'TREATMENT', event.aggregateId); + } + + @OnDomainEvent( + ['FeedingRecorded', 'FeedingCorrected'], + 'calendar-sync.feeding', + ) + async feeding( + event: AnyDomainEventEnvelope<'FeedingRecorded' | 'FeedingCorrected'>, + tx: Prisma.TransactionClient, + ): Promise { + await this.calendarSync.enqueueIn(tx, 'FEEDING', event.aggregateId); + } + + @OnDomainEvent( + ['HarvestRecorded', 'HarvestCorrected'], + 'calendar-sync.harvest', + ) + async harvest( + event: AnyDomainEventEnvelope<'HarvestRecorded' | 'HarvestCorrected'>, + tx: Prisma.TransactionClient, + ): Promise { + await this.calendarSync.enqueueIn(tx, 'HARVEST', event.aggregateId); + } + + // Updated and completed tasks are enqueued even without a due date or + // when done: the worker then removes an event created earlier. + @OnDomainEvent( + ['TaskCreated', 'TaskUpdated', 'TaskCompleted'], + 'calendar-sync.task', + ) + async task( + event: AnyDomainEventEnvelope< + 'TaskCreated' | 'TaskUpdated' | 'TaskCompleted' + >, + tx: Prisma.TransactionClient, + ): Promise { + if (event.type === 'TaskCreated' && event.payload.dueDate === null) { + return; + } + await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId); + } +} diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts index 99e3adb..bbc0272 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts @@ -1,18 +1,20 @@ import { Global, Module } from '@nestjs/common'; import { CalendarSyncController } from './calendar-sync.controller'; +import { CalendarSyncEventHandlers } from './calendar-sync.handlers'; import { CalendarSyncService } from './calendar-sync.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { GraphClientService } from './graph-client.service'; -// @Global(): CalendarSyncService.enqueue()/cancel() are called from ~6 -// otherwise-unrelated domain modules (Tasks, Inspections, Treatments, -// Feedings, Harvests, Queens) - matches WebhooksModule's own @Global() -// rationale rather than having each of those six modules import this one. +// @Global(): CalendarSyncService.enqueue() is called from otherwise-unrelated +// domain modules (Inspections, Treatments, Feedings, Harvests, Queens) until +// they record domain events - matches WebhooksModule's own @Global() +// rationale rather than having each of those modules import this one. @Global() @Module({ controllers: [CalendarSyncController], providers: [ CalendarSyncService, + CalendarSyncEventHandlers, CalendarSyncSettingsService, GraphClientService, ], diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts index 0e85d1f..ae77bf6 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts @@ -151,8 +151,8 @@ describe('CalendarSyncService', () => { }); }); - // enqueue()/cancel() run after the source record is committed: a failure - // must not fail that request (the client would retry and store it twice). + // enqueue() runs after the source record is committed: a failure must not + // fail that request (the client would retry and store it twice). describe('post-commit calls', () => { let errorSpy: jest.SpyInstance; @@ -178,61 +178,50 @@ describe('CalendarSyncService', () => { expect(message).toContain('INSPECTION inspection-1'); expect(message).toContain('db down'); }); - - it('cancel never throws and logs the failure with the record', async () => { - prisma.calendarSyncOutboxEntry.findUnique.mockRejectedValue( - new Error('db down'), - ); - - await expect(service.cancel('TASK', 'task-1')).resolves.toBeUndefined(); - - expect(errorSpy).toHaveBeenCalledTimes(1); - const [message] = errorSpy.mock.calls[0] as [string]; - expect(message).toContain('TASK task-1'); - expect(message).toContain('db down'); - }); }); - describe('cancel', () => { - it('does nothing when no outbox row exists', async () => { - prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null); + describe('enqueueIn', () => { + it('writes through the given client and lets errors propagate (handler retry)', async () => { + const txUpsert = jest.fn().mockResolvedValue(undefined); + const tx = { + calendarSyncOutboxEntry: { upsert: txUpsert }, + } as unknown as Prisma.TransactionClient; - await service.cancel('TASK', 'task-1'); + await service.enqueueIn(tx, 'TASK', 'task-1'); - expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled(); - }); - - it('deletes the Graph event and clears the row when a graphEventId exists', async () => { - prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({ - id: 'entry-1', - graphEventId: 'graph-event-1', + expect(txUpsert).toHaveBeenCalledWith({ + where: { + sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' }, + }, + create: { sourceType: 'TASK', sourceId: 'task-1' }, + update: { + status: 'PENDING', + attempts: 0, + lastError: null, + revision: { increment: 1 }, + }, }); + expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); - await service.cancel('TASK', 'task-1'); - - expect(graphClient.deleteEvent).toHaveBeenCalledWith( - 'group-1', - 'graph-event-1', + txUpsert.mockRejectedValue(new Error('tx failed')); + await expect(service.enqueueIn(tx, 'TASK', 'task-1')).rejects.toThrow( + 'tx failed', ); - expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ - where: { id: 'entry-1' }, - data: { status: 'SKIPPED', graphEventId: null }, - }); }); - it('still clears the row when the Graph delete fails', async () => { - prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({ - id: 'entry-1', - graphEventId: 'graph-event-1', + it('does nothing when sync is disabled', async () => { + settingsService.get.mockResolvedValue({ + ...enabledSettings, + enabled: false, }); - graphClient.deleteEvent.mockRejectedValue(new Error('network down')); + const txUpsert = jest.fn(); + const tx = { + calendarSyncOutboxEntry: { upsert: txUpsert }, + } as unknown as Prisma.TransactionClient; - await service.cancel('TASK', 'task-1'); + await service.enqueueIn(tx, 'TASK', 'task-1'); - expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ - where: { id: 'entry-1' }, - data: { status: 'SKIPPED', graphEventId: null }, - }); + expect(txUpsert).not.toHaveBeenCalled(); }); }); @@ -241,6 +230,7 @@ describe('CalendarSyncService', () => { id: 'task-1', title: 'Beute prüfen', dueDate: new Date('2026-03-07'), + status: 'OPEN', }; /** The outcome write: only lands if the row is unchanged since the claim. */ @@ -441,7 +431,78 @@ describe('CalendarSyncService', () => { await service.processOutbox(); expect(graphClient.createEvent).not.toHaveBeenCalled(); - expectSettled({ status: 'SKIPPED' }); + expect(graphClient.deleteEvent).not.toHaveBeenCalled(); + expectSettled({ status: 'SKIPPED', graphEventId: null }); + }); + + it('deletes the Graph event of a task that is DONE and settles the entry SKIPPED', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + pendingEntry({ graphEventId: 'graph-event-1' }), + ]); + prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' }); + + await service.processOutbox(); + + expect(graphClient.deleteEvent).toHaveBeenCalledWith( + 'group-1', + 'graph-event-1', + ); + expect(graphClient.createEvent).not.toHaveBeenCalled(); + expect(graphClient.updateEvent).not.toHaveBeenCalled(); + expectSettled({ status: 'SKIPPED', graphEventId: null }); + }); + + it('treats a CANCELLED task like a DONE one', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + pendingEntry({ graphEventId: 'graph-event-1' }), + ]); + prisma.task.findUnique.mockResolvedValue({ + ...dueTask, + status: 'CANCELLED', + }); + + await service.processOutbox(); + + expect(graphClient.deleteEvent).toHaveBeenCalledTimes(1); + expectSettled({ status: 'SKIPPED', graphEventId: null }); + }); + + it.each([404, 410])( + 'treats %i on delete as already deleted', + async (status) => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + pendingEntry({ graphEventId: 'graph-event-1' }), + ]); + prisma.task.findUnique.mockResolvedValue({ + ...dueTask, + status: 'DONE', + }); + graphClient.deleteEvent.mockRejectedValue( + new GraphRequestError('deleteEvent', status, 'gone'), + ); + + await service.processOutbox(); + + expectSettled({ status: 'SKIPPED', graphEventId: null }); + }, + ); + + it('counts any other delete error as a failed attempt', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }), + ]); + prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' }); + graphClient.deleteEvent.mockRejectedValue( + new GraphRequestError('deleteEvent', 503, 'busy'), + ); + + await service.processOutbox(); + + expectSettled({ + status: 'PENDING', + attempts: 2, + lastError: expect.any(String) as string, + }); }); it('only lets go of the claim when the row changed before SKIPPED could be written', async () => { diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts index f0d91f3..feb3246 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; +import { DbClient } from '../prisma/db-client'; import { PrismaService } from '../prisma/prisma.service'; import { runAfterCommit } from '../common/after-commit'; import { loggableError } from '../common/loggable-error'; @@ -106,8 +107,8 @@ export class CalendarSyncService { return flagBySourceType[sourceType]; } - // enqueue()/cancel() follow a committed write in the domain services and - // are called without a guard - so they never throw (see runAfterCommit). + // enqueue() follows a committed write in the domain services and is + // called without a guard - so it never throws (see runAfterCommit). private afterCommit( action: string, sourceType: CalendarSyncSourceType, @@ -121,70 +122,48 @@ export class CalendarSyncService { ); } - // Called from the ~6 domain services right after a create/update that - // should appear on the M365 calendar. Upserts rather than always - // inserting: re-enqueuing an already-synced row (e.g. a Task's due date - // changed) resets it to PENDING while keeping its graphEventId, so the - // worker updates the existing Graph event instead of creating a duplicate. - // The revision counts up so a worker syncing the row right now notices the - // newer state instead of marking it SYNCED. + // The core of every enqueue: upserts rather than always inserting, so + // re-enqueuing an already-synced row (e.g. a Task's due date changed) + // resets it to PENDING while keeping its graphEventId - the worker then + // updates the existing Graph event instead of creating a duplicate. The + // revision counts up so a worker syncing the row right now notices the + // newer state instead of marking it SYNCED. Written through `db` (the + // handler's delivery transaction) and not guarded: a failure propagates, + // so the domain event delivery is retried. + async enqueueIn( + db: DbClient, + sourceType: CalendarSyncSourceType, + sourceId: string, + ): Promise { + const settings = await this.settingsService.get(); + if (!settings.enabled) { + return; + } + if (!this.isSourceTypeEnabled(settings, sourceType)) { + return; + } + await db.calendarSyncOutboxEntry.upsert({ + where: { sourceType_sourceId: { sourceType, sourceId } }, + create: { sourceType, sourceId }, + update: { + status: 'PENDING', + attempts: 0, + lastError: null, + revision: { increment: 1 }, + }, + }); + } + + // Legacy path for sources that do not record a domain event yet + // (inspections, treatments, feedings and harvests until their producer + // tasks; queen assignments), called right after a committed write: never + // throws. async enqueue( sourceType: CalendarSyncSourceType, sourceId: string, ): Promise { - await this.afterCommit( - 'Calendar sync enqueue', - sourceType, - sourceId, - async () => { - const settings = await this.settingsService.get(); - if (!settings.enabled) { - return; - } - if (!this.isSourceTypeEnabled(settings, sourceType)) { - return; - } - await this.prisma.calendarSyncOutboxEntry.upsert({ - where: { sourceType_sourceId: { sourceType, sourceId } }, - create: { sourceType, sourceId }, - update: { - status: 'PENDING', - attempts: 0, - lastError: null, - revision: { increment: 1 }, - }, - }); - }, - ); - } - - // Called when a source record no longer belongs on the calendar (e.g. a - // Task is completed). Best-effort: a failed Graph delete is logged, not - // thrown, since the outbox row is cleared either way and a future enqueue - // for the same (sourceType, sourceId) will simply create a fresh event. - async cancel( - sourceType: CalendarSyncSourceType, - sourceId: string, - ): Promise { - await this.afterCommit( - 'Calendar sync cancel', - sourceType, - sourceId, - async () => { - const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({ - where: { sourceType_sourceId: { sourceType, sourceId } }, - }); - if (!entry) { - return; - } - if (entry.graphEventId) { - await this.deleteGraphEvent(entry.graphEventId); - } - await this.prisma.calendarSyncOutboxEntry.update({ - where: { id: entry.id }, - data: { status: 'SKIPPED', graphEventId: null }, - }); - }, + await this.afterCommit('Calendar sync enqueue', sourceType, sourceId, () => + this.enqueueIn(this.prisma, sourceType, sourceId), ); } @@ -288,7 +267,11 @@ export class CalendarSyncService { entry.sourceId, ); if (!input) { - await this.settle(entry, { status: 'SKIPPED' }); + // Nothing to show any more: an event created earlier must go. + if (entry.graphEventId) { + await this.removeGraphEvent(groupId, entry.graphEventId); + } + await this.settle(entry, { status: 'SKIPPED', graphEventId: null }); return; } @@ -312,6 +295,25 @@ export class CalendarSyncService { } } + // Unlike deleteGraphEvent, a failure counts as a failed attempt (the + // caller's catch): the event must really be gone before the entry is + // SKIPPED. 404/410 mean it already is. + private async removeGraphEvent( + groupId: string, + graphEventId: string, + ): Promise { + try { + await this.graphClient.deleteEvent(groupId, graphEventId); + } catch (error: unknown) { + const alreadyGone = + error instanceof GraphRequestError && + EVENT_GONE_STATUSES.includes(error.status); + if (!alreadyGone) { + throw error; + } + } + } + /** The row exactly as claimed: still ours, PENDING, nothing enqueued since. */ private unchangedSinceClaim( entry: ClaimedEntry, @@ -426,7 +428,12 @@ export class CalendarSyncService { const task = await this.prisma.task.findUnique({ where: { id: sourceId }, }); - if (!task || !task.dueDate) { + if ( + !task || + !task.dueDate || + task.status === 'DONE' || + task.status === 'CANCELLED' + ) { return null; } return { title: task.title, date: task.dueDate }; diff --git a/ApiCore-Backend/src/cli.spec.ts b/ApiCore-Backend/src/cli.spec.ts new file mode 100644 index 0000000..0dfc35e --- /dev/null +++ b/ApiCore-Backend/src/cli.spec.ts @@ -0,0 +1,61 @@ +import { bootstrapCli } from './cli'; +import * as runCliModule from './jobs/run-cli'; + +const VALID_ENV: NodeJS.ProcessEnv = { + DATABASE_URL: 'postgresql://job:secret@db/app', + JWT_ACCESS_SECRET: 'a'.repeat(32), +}; + +describe('bootstrapCli', () => { + let runCli: jest.SpyInstance; + + beforeEach(() => { + runCli = jest.spyOn(runCliModule, 'runCli').mockResolvedValue(0); + }); + + afterEach(() => jest.restoreAllMocks()); + + // A misconfigured CronJob pod must fail before any module connects. + it.each([ + [ + 'a role of another entry point', + { ...VALID_ENV, APICORE_ROLE: 'worker' }, + 'APICORE_ROLE=worker cannot start dist/cli.js', + ], + [ + '"all" in production', + { ...VALID_ENV, NODE_ENV: 'production', APICORE_ROLE: 'all' }, + 'APICORE_ROLE=all is for local development and tests only', + ], + [ + 'no role in production', + { ...VALID_ENV, NODE_ENV: 'production' }, + 'APICORE_ROLE is required in production', + ], + [ + 'a missing DATABASE_URL', + { JWT_ACCESS_SECRET: VALID_ENV.JWT_ACCESS_SECRET }, + 'Missing required environment variable(s): DATABASE_URL', + ], + ])( + 'refuses %s before running a job', + async (_case, env: NodeJS.ProcessEnv, message: string) => { + await expect(bootstrapCli(['cleanup'], env)).rejects.toThrow(message); + + expect(runCli).not.toHaveBeenCalled(); + }, + ); + + it('runs the named job with its arguments and returns the exit code', async () => { + runCli.mockResolvedValue(1); + + await expect( + bootstrapCli(['rekey', '--dry-run'], { + ...VALID_ENV, + APICORE_ROLE: 'job', + }), + ).resolves.toBe(1); + + expect(runCli).toHaveBeenCalledWith(['rekey', '--dry-run']); + }); +}); diff --git a/ApiCore-Backend/src/cli.ts b/ApiCore-Backend/src/cli.ts new file mode 100644 index 0000000..7aacb50 --- /dev/null +++ b/ApiCore-Backend/src/cli.ts @@ -0,0 +1,26 @@ +import { CliExitCode, runCli } from './jobs/run-cli'; +import { resolveRole } from './runtime/role'; +import { assertStartupEnvironment } from './runtime/startup-checks'; + +/** + * Entry point of the job role: `node dist/cli.js [args]`, one run per + * CronJob pod. Role and environment are checked like in main.ts and + * worker.ts, before any module connects. Resolves with the exit code: + * 0 succeeded, 1 failed, 2 wrong call. `env` is for tests; the modules + * themselves read process.env. + */ +export async function bootstrapCli( + argv: readonly string[] = process.argv.slice(2), + env: NodeJS.ProcessEnv = process.env, +): Promise { + const role = resolveRole('cli', env); + assertStartupEnvironment(role, env); + return runCli(argv); +} + +// Started as `node dist/cli.js`; importing this file starts nothing. +if (require.main === module) { + void bootstrapCli().then((code) => { + process.exitCode = code; + }); +} diff --git a/ApiCore-Backend/src/colonies/colonies.module.ts b/ApiCore-Backend/src/colonies/colonies.module.ts index 528c8a6..18cbd42 100644 --- a/ApiCore-Backend/src/colonies/colonies.module.ts +++ b/ApiCore-Backend/src/colonies/colonies.module.ts @@ -1,10 +1,8 @@ import { Module } from '@nestjs/common'; -import { AutomationModule } from '../automation/automation.module'; import { ColoniesController } from './colonies.controller'; import { ColoniesService } from './colonies.service'; @Module({ - imports: [AutomationModule], controllers: [ColoniesController], providers: [ColoniesService], exports: [ColoniesService], diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index eb57ccd..880a59f 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -3,11 +3,12 @@ import { ColoniesService } from './colonies.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; -import { AutomationService } from '../automation/automation.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; describe('ColoniesService', () => { let service: ColoniesService; let tx: { + $queryRaw: jest.Mock; colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; colonyApiaryHistory: { create: jest.Mock }; colonyLineage: { create: jest.Mock }; @@ -27,6 +28,7 @@ describe('ColoniesService', () => { $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; + let events: { record: jest.Mock; recordAll: jest.Mock }; const parentColony = { id: 'colony-1', @@ -38,6 +40,7 @@ describe('ColoniesService', () => { beforeEach(() => { tx = { + $queryRaw: jest.fn().mockResolvedValue([]), colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() }, colonyApiaryHistory: { create: jest.fn() }, colonyLineage: { create: jest.fn() }, @@ -62,19 +65,30 @@ describe('ColoniesService', () => { callback(tx), ), }; + // The locked re-read sees what the pre-read saw unless a test says otherwise. + tx.colony.findUnique.mockImplementation( + (args: unknown) => prisma.colony.findUnique(args) as Promise, + ); auditLog = { record: jest.fn().mockResolvedValue(undefined) }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; - const automation = { - evaluateColonyHealth: jest.fn().mockResolvedValue(undefined), + events = { + record: jest.fn().mockResolvedValue(undefined), + recordAll: jest.fn().mockResolvedValue(0), }; service = new ColoniesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, - automation as unknown as AutomationService, + events as unknown as DomainEventsService, ); }); + function transitionTypes(): string[] { + return ( + events.recordAll.mock.calls as [unknown, { type: string }[]][] + ).flatMap(([, recorded]) => recorded.map((event) => event.type)); + } + it('findById throws NotFoundException when missing', async () => { prisma.colony.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( @@ -137,7 +151,11 @@ describe('ColoniesService', () => { status: 'ACTIVE', currentQueenId: 'queen-2', }); - tx.colony.update.mockResolvedValue({}); + tx.colony.update.mockResolvedValue({ + id: 'colony-3', + status: 'MERGED', + healthStatus: null, + }); tx.colonyLineage.create.mockResolvedValue({}); await service.merge( @@ -242,6 +260,7 @@ describe('ColoniesService', () => { expect(result.status).toBe('DISSOLVED'); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'DEACTIVATE' }), + tx, ); }); @@ -320,7 +339,7 @@ describe('ColoniesService', () => { await expect( service.reactivate('colony-1', 'user-1'), ).rejects.toBeInstanceOf(ConflictException); - expect(prisma.colony.update).not.toHaveBeenCalled(); + expect(tx.colony.update).not.toHaveBeenCalled(); }, ); @@ -330,7 +349,7 @@ describe('ColoniesService', () => { status: 'DISSOLVED', deactivatedAt: new Date(), }); - prisma.colony.update.mockResolvedValue({ + tx.colony.update.mockResolvedValue({ ...parentColony, status: 'ACTIVE', deactivatedAt: null, @@ -339,13 +358,15 @@ describe('ColoniesService', () => { const result = await service.reactivate('colony-1', 'user-1'); expect(result.status).toBe('ACTIVE'); - expect(prisma.colony.update).toHaveBeenCalledWith({ + expect(tx.colony.update).toHaveBeenCalledWith({ where: { id: 'colony-1' }, data: { status: 'ACTIVE', deactivatedAt: null }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'UPDATE' }), + tx, ); + expect(transitionTypes()).toEqual(['ColonyStatusChanged']); }); it('writes an apiary-history entry when an edit corrects the apiary', async () => { @@ -375,4 +396,215 @@ describe('ColoniesService', () => { expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled(); }); + + it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => { + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + healthStatus: null, + }); + tx.colony.update.mockResolvedValue({ + ...parentColony, + healthStatus: 'CRITICAL', + }); + + await service.update('colony-1', { healthStatus: 'CRITICAL' }, 'user-1'); + + expect(events.recordAll).toHaveBeenCalledWith(tx, [ + expect.objectContaining({ + type: 'ColonyStatusChanged', + aggregateType: 'Colony', + aggregateId: 'colony-1', + actorUserId: 'user-1', + payload: { + from: { status: 'ACTIVE', healthStatus: null }, + to: { status: 'ACTIVE', healthStatus: 'CRITICAL' }, + }, + }), + ]); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'UPDATE' }), + tx, + ); + }); + + it('update records no event when status and health stay the same', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.update.mockResolvedValue(parentColony); + + await service.update('colony-1', { notes: 'x' }, 'user-1'); + + expect(transitionTypes()).toEqual([]); + }); + + it('dissolve records ColonyStatusChanged and ColonyEnded { status: "DISSOLVED" }', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.update.mockResolvedValue({ + ...parentColony, + status: 'DISSOLVED', + }); + + await service.dissolve('colony-1', 'user-1'); + + expect(events.recordAll).toHaveBeenCalledWith(tx, [ + expect.objectContaining({ type: 'ColonyStatusChanged' }), + expect.objectContaining({ + type: 'ColonyEnded', + aggregateId: 'colony-1', + payload: { status: 'DISSOLVED' }, + }), + ]); + }); + + it('merge records ColonyEnded "MERGED" for every merged colony', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.findUnique.mockImplementation( + ({ where }: { where: { id: string } }) => + Promise.resolve({ + id: where.id, + status: 'ACTIVE', + healthStatus: null, + currentQueenId: null, + }), + ); + tx.colony.update.mockImplementation( + ({ where }: { where: { id: string } }) => + Promise.resolve({ id: where.id, status: 'MERGED', healthStatus: null }), + ); + + await service.merge( + 'colony-1', + { mergedColonyIds: ['colony-3', 'colony-4'] }, + 'user-1', + ); + + const ended = ( + events.recordAll.mock.calls as [ + unknown, + { type: string; aggregateId: string; payload: { status?: string } }[], + ][] + ) + .flatMap(([, recorded]) => recorded) + .filter((event) => event.type === 'ColonyEnded'); + expect(ended.map((event) => event.aggregateId)).toEqual([ + 'colony-3', + 'colony-4', + ]); + expect(ended.map((event) => event.payload.status)).toEqual([ + 'MERGED', + 'MERGED', + ]); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }), + tx, + ); + }); + + it('writes the audit entry of create, move and split inside the transaction', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.create.mockResolvedValue({ id: 'colony-2' }); + tx.colony.update.mockResolvedValue({ + ...parentColony, + apiaryId: 'apiary-2', + }); + + await service.create( + { code: 'V-9', apiaryId: 'apiary-1', colonyType: 'PRODUCTION_COLONY' }, + 'user-1', + ); + await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'); + await service.split( + 'colony-1', + { code: 'V-1-A', colonyType: 'NUCLEUS_COLONY' }, + 'user-1', + ); + + const calls = auditLog.record.mock.calls as [unknown, unknown][]; + expect(calls).toHaveLength(3); + expect(calls.every(([, client]) => client === tx)).toBe(true); + }); + + it('locks the colony row before re-reading it in update, move, dissolve and reactivate', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.update.mockResolvedValue(parentColony); + + await service.update('colony-1', { notes: 'x' }, 'user-1'); + await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'); + await service.dissolve('colony-1', 'user-1'); + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + status: 'DISSOLVED', + }); + await service.reactivate('colony-1', 'user-1'); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(4); + }); + + it('uses the locked row, not the pre-read, as "before" for event and audit', async () => { + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + healthStatus: null, + }); + // Another request made the colony CRITICAL between pre-read and lock. + tx.colony.findUnique.mockResolvedValue({ + ...parentColony, + healthStatus: 'CRITICAL', + }); + tx.colony.update.mockResolvedValue({ + ...parentColony, + healthStatus: 'GOOD', + }); + + await service.update('colony-1', { healthStatus: 'GOOD' }, 'user-1'); + + expect(events.recordAll).toHaveBeenCalledWith(tx, [ + expect.objectContaining({ + payload: { + from: { status: 'ACTIVE', healthStatus: 'CRITICAL' }, + to: { status: 'ACTIVE', healthStatus: 'GOOD' }, + }, + }), + ]); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + beforeData: { ...parentColony, healthStatus: 'CRITICAL' }, + }), + tx, + ); + }); + + it('rejects dissolving a colony that ended between pre-read and lock', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.findUnique.mockResolvedValue({ + ...parentColony, + status: 'SOLD', + }); + + await expect(service.dissolve('colony-1', 'user-1')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(tx.colony.update).not.toHaveBeenCalled(); + }); + + it('locks every merged colony', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.findUnique.mockImplementation( + ({ where }: { where: { id: string } }) => + Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }), + ); + tx.colony.update.mockImplementation( + ({ where }: { where: { id: string } }) => + Promise.resolve({ id: where.id, status: 'MERGED', healthStatus: null }), + ); + + await service.merge( + 'colony-1', + { mergedColonyIds: ['colony-3', 'colony-4'] }, + 'user-1', + ); + + // One multi-row statement, not one per colony. + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]]; + expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE'); + }); }); diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 0917fc7..30e690a 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -7,7 +7,7 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; -import { AutomationService } from '../automation/automation.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; import { Colony, ColonyStatus } from '../../generated/prisma'; import { CreateColonyDto } from './dto/create-colony.dto'; import { UpdateColonyDto } from './dto/update-colony.dto'; @@ -15,6 +15,8 @@ import { MoveColonyDto } from './dto/move-colony.dto'; import { SplitColonyDto } from './dto/split-colony.dto'; import { MergeColonyDto } from './dto/merge-colony.dto'; import { isLivingColonyStatus } from './colony-status'; +import { lockRowsForUpdate } from '../prisma/row-lock'; +import { colonyTransitionEvents, lockColony } from './colony-events'; export interface ColonyFilter { status?: ColonyStatus; @@ -27,7 +29,7 @@ export class ColoniesService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, - private readonly automation: AutomationService, + private readonly events: DomainEventsService, ) {} async findAll(filter: ColonyFilter = {}): Promise { @@ -77,14 +79,22 @@ export class ColoniesService { async create(dto: CreateColonyDto, actingUserId: string): Promise { await this.assertApiaryActive(dto.apiaryId); - const colony = await this.prisma.colony.create({ data: { ...dto } }); - await this.auditLog.record({ - entityType: 'Colony', - entityId: colony.id, - action: 'CREATE', - userId: actingUserId, - afterData: colony, + const colony = await this.prisma.$transaction(async (tx) => { + const created = await tx.colony.create({ data: { ...dto } }); + await this.auditLog.record( + { + entityType: 'Colony', + entityId: created.id, + action: 'CREATE', + userId: actingUserId, + afterData: created, + }, + tx, + ); + return created; }); + // No domain event for a new colony in this stage - the webhook stays on + // the direct path. await this.webhooks.dispatch('colony.created', colony); return colony; } @@ -94,14 +104,16 @@ export class ColoniesService { dto: UpdateColonyDto, actingUserId: string, ): Promise { - const before = await this.findById(id); - const movesApiary = - dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; - if (movesApiary) { - await this.assertApiaryActive(dto.apiaryId!); - } + // Fail fast with 404; everything else is decided on the locked row. + await this.findById(id); const colony = await this.prisma.$transaction(async (tx) => { + const before = await lockColony(tx, id); + const movesApiary = + dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; + if (movesApiary) { + await this.assertApiaryActive(dto.apiaryId!); + } const updated = await tx.colony.update({ where: { id }, data: { ...dto }, @@ -119,17 +131,23 @@ export class ColoniesService { }, }); } + await this.events.recordAll( + tx, + colonyTransitionEvents(before, updated, actingUserId), + ); + await this.auditLog.record( + { + entityType: 'Colony', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: updated, + }, + tx, + ); return updated; }); - await this.auditLog.record({ - entityType: 'Colony', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: colony, - }); - await this.automation.evaluateColonyHealth(before, colony); return colony; } @@ -138,10 +156,11 @@ export class ColoniesService { dto: MoveColonyDto, actingUserId: string, ): Promise { - const before = await this.findById(id); - this.assertActive(before); + this.assertActive(await this.findById(id)); await this.assertApiaryActive(dto.apiaryId); const colony = await this.prisma.$transaction(async (tx) => { + const before = await lockColony(tx, id); + this.assertActive(before); const updated = await tx.colony.update({ where: { id }, data: { apiaryId: dto.apiaryId }, @@ -154,16 +173,19 @@ export class ColoniesService { notes: dto.notes, }, }); + await this.auditLog.record( + { + entityType: 'Colony', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: updated, + }, + tx, + ); return updated; }); - await this.auditLog.record({ - entityType: 'Colony', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: colony, - }); return colony; } @@ -192,15 +214,18 @@ export class ColoniesService { notes: dto.notes, }, }); + await this.auditLog.record( + { + entityType: 'Colony', + entityId: created.id, + action: 'CREATE', + userId: actingUserId, + afterData: { ...created, splitFromColonyId: parent.id }, + }, + tx, + ); return created; }); - await this.auditLog.record({ - entityType: 'Colony', - entityId: child.id, - action: 'CREATE', - userId: actingUserId, - afterData: { ...child, splitFromColonyId: parent.id }, - }); return child; } @@ -222,6 +247,9 @@ export class ColoniesService { ); } await this.prisma.$transaction(async (tx) => { + // One statement in id order, the same order bulk lockRowsForShare + // uses: no deadlock with a concurrent bulk write. + await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds); for (const mergedColonyId of dto.mergedColonyIds) { const merged = await tx.colony.findUnique({ where: { id: mergedColonyId }, @@ -238,7 +266,7 @@ export class ColoniesService { // The survivor keeps its own queen; merged-away queens are simply // released, not auto-transferred (which colony's queen "wins" a // merge is a beekeeper decision, not one to guess here). - await tx.colony.update({ + const mergedAway = await tx.colony.update({ where: { id: mergedColonyId }, data: { status: 'MERGED', @@ -246,6 +274,10 @@ export class ColoniesService { currentQueenId: null, }, }); + await this.events.recordAll( + tx, + colonyTransitionEvents(merged, mergedAway, actingUserId), + ); if (merged.currentQueenId) { await tx.queenColonyHistory.updateMany({ where: { @@ -266,24 +298,28 @@ export class ColoniesService { }, }); } - }); - await this.auditLog.record({ - entityType: 'Colony', - entityId: survivor.id, - action: 'UPDATE', - userId: actingUserId, - beforeData: survivor, - afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds }, + await this.auditLog.record( + { + entityType: 'Colony', + entityId: survivor.id, + action: 'UPDATE', + userId: actingUserId, + beforeData: survivor, + afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds }, + }, + tx, + ); }); return this.findById(survivor.id); } async dissolve(id: string, actingUserId: string): Promise { - const before = await this.findById(id); - // Re-dissolving would overwrite deactivatedAt and the terminal status - // (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended. - this.assertActive(before); + this.assertActive(await this.findById(id)); const colony = await this.prisma.$transaction(async (tx) => { + const before = await lockColony(tx, id); + // Re-dissolving would overwrite deactivatedAt and the terminal status + // (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended. + this.assertActive(before); const updated = await tx.colony.update({ where: { id }, data: { @@ -302,39 +338,56 @@ export class ColoniesService { data: { endDate: new Date() }, }); } + await this.events.recordAll( + tx, + colonyTransitionEvents(before, updated, actingUserId), + ); + await this.auditLog.record( + { + entityType: 'Colony', + entityId: id, + action: 'DEACTIVATE', + userId: actingUserId, + beforeData: before, + afterData: updated, + }, + tx, + ); return updated; }); - await this.auditLog.record({ - entityType: 'Colony', - entityId: id, - action: 'DEACTIVATE', - userId: actingUserId, - beforeData: before, - afterData: colony, - }); return colony; } async reactivate(id: string, actingUserId: string): Promise { - const before = await this.findById(id); - // Only an ended colony can be brought back. A weak or queenless one - // still exists - reactivating it would just overwrite its condition. - if (isLivingColonyStatus(before.status)) { - throw new ConflictException('Colony is already active'); - } - const colony = await this.prisma.colony.update({ - where: { id }, - data: { status: 'ACTIVE', deactivatedAt: null }, + await this.findById(id); + return this.prisma.$transaction(async (tx) => { + const before = await lockColony(tx, id); + // Only an ended colony can be brought back. A weak or queenless one + // still exists - reactivating it would just overwrite its condition. + if (isLivingColonyStatus(before.status)) { + throw new ConflictException('Colony is already active'); + } + const updated = await tx.colony.update({ + where: { id }, + data: { status: 'ACTIVE', deactivatedAt: null }, + }); + await this.events.recordAll( + tx, + colonyTransitionEvents(before, updated, actingUserId), + ); + await this.auditLog.record( + { + entityType: 'Colony', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: updated, + }, + tx, + ); + return updated; }); - await this.auditLog.record({ - entityType: 'Colony', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: colony, - }); - return colony; } async getHistory(id: string) { diff --git a/ApiCore-Backend/src/colonies/colony-events.spec.ts b/ApiCore-Backend/src/colonies/colony-events.spec.ts new file mode 100644 index 0000000..940be46 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colony-events.spec.ts @@ -0,0 +1,72 @@ +import { Colony } from '../../generated/prisma'; +import { colonyTransitionEvents } from './colony-events'; + +function colony(overrides: Partial = {}): Colony { + return { + id: 'c1', + status: 'ACTIVE', + healthStatus: null, + ...overrides, + } as Colony; +} + +describe('colonyTransitionEvents', () => { + it('records nothing when neither status nor healthStatus changed', () => { + expect( + colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1'), + ).toEqual([]); + }); + + it('records ColonyStatusChanged with from/to snapshots on a health change', () => { + const events = colonyTransitionEvents( + colony(), + colony({ healthStatus: 'CRITICAL' }), + 'u1', + ); + expect(events).toEqual([ + { + type: 'ColonyStatusChanged', + aggregateType: 'Colony', + aggregateId: 'c1', + actorUserId: 'u1', + payload: { + from: { status: 'ACTIVE', healthStatus: null }, + to: { status: 'ACTIVE', healthStatus: 'CRITICAL' }, + }, + }, + ]); + }); + + it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)( + 'adds ColonyEnded { status: %s } on the transition from living to ended', + (status) => { + const events = colonyTransitionEvents( + colony({ status: 'WEAK' }), + colony({ status }), + null, + ); + expect(events.map((event) => event.type)).toEqual([ + 'ColonyStatusChanged', + 'ColonyEnded', + ]); + expect(events[1].payload).toEqual({ status }); + }, + ); + + it('does not add ColonyEnded between two ended statuses or on reactivation', () => { + expect( + colonyTransitionEvents( + colony({ status: 'DISSOLVED' }), + colony({ status: 'DECEASED' }), + 'u1', + ).map((event) => event.type), + ).toEqual(['ColonyStatusChanged']); + expect( + colonyTransitionEvents( + colony({ status: 'DISSOLVED' }), + colony({ status: 'ACTIVE' }), + 'u1', + ).map((event) => event.type), + ).toEqual(['ColonyStatusChanged']); + }); +}); diff --git a/ApiCore-Backend/src/colonies/colony-events.ts b/ApiCore-Backend/src/colonies/colony-events.ts new file mode 100644 index 0000000..6ff277e --- /dev/null +++ b/ApiCore-Backend/src/colonies/colony-events.ts @@ -0,0 +1,74 @@ +import { NotFoundException } from '@nestjs/common'; +import { Colony, Prisma } from '../../generated/prisma'; +import { lockRowForUpdate } from '../prisma/row-lock'; +import { + AnyNewDomainEvent, + ColonyStateSnapshot, +} from '../domain-events/domain-event-types'; +import { isLivingColonyStatus } from './colony-status'; + +type ColonyState = Pick; + +function snapshotOf(colony: ColonyState): ColonyStateSnapshot { + return { status: colony.status, healthStatus: colony.healthStatus }; +} + +/** + * The domain events of one colony going from `before` to `after`: + * ColonyStatusChanged as soon as status or healthStatus differs, plus + * ColonyEnded when a living colony became an ended one. Empty when nothing + * the events care about changed. The caller records them in the same + * transaction as the colony update. + */ +export function colonyTransitionEvents( + before: Pick, + after: Pick, + actorUserId: string | null, +): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] { + if ( + before.status === after.status && + before.healthStatus === after.healthStatus + ) { + return []; + } + const events: AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] = [ + { + type: 'ColonyStatusChanged', + aggregateType: 'Colony', + aggregateId: after.id, + actorUserId, + payload: { from: snapshotOf(before), to: snapshotOf(after) }, + }, + ]; + if ( + isLivingColonyStatus(before.status) && + !isLivingColonyStatus(after.status) + ) { + events.push({ + type: 'ColonyEnded', + aggregateType: 'Colony', + aggregateId: after.id, + actorUserId, + payload: { status: after.status }, + }); + } + return events; +} + +/** + * Locks the colony row and re-reads it inside the transaction. Use the + * result - not an earlier read - as "before" for checks, audit and + * colonyTransitionEvents, so a concurrent change cannot be missed or + * mislabelled. + */ +export async function lockColony( + tx: Prisma.TransactionClient, + id: string, +): Promise { + await lockRowForUpdate(tx, 'Colony', id); + const colony = await tx.colony.findUnique({ where: { id } }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + return colony; +} diff --git a/ApiCore-Backend/src/common/after-commit-usage.spec.ts b/ApiCore-Backend/src/common/after-commit-usage.spec.ts new file mode 100644 index 0000000..9b50d13 --- /dev/null +++ b/ApiCore-Backend/src/common/after-commit-usage.spec.ts @@ -0,0 +1,86 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +// Side effects run through the domain-event backbone. runAfterCommit and the +// direct wrappers stay only for the legacy paths that have no event yet +// (ARCHITECTURE assumption 11). A new caller must record an event instead. +const SRC = join(__dirname, '..'); +const DEFINITION = 'common/after-commit.ts'; + +function productionSources(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + return productionSources(path); + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts') + ? [path] + : []; + }); +} + +function withoutComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +function filesCalling(call: string): string[] { + return productionSources(SRC) + .filter((file) => + withoutComments(readFileSync(file, 'utf8')).includes(call), + ) + .map((file) => relative(SRC, file).split(sep).join('/')) + .filter((file) => file !== DEFINITION) + .map((file) => `src/${file}`) + .sort(); +} + +describe('legacy after-commit paths', () => { + it('runAfterCommit is only used by the remaining legacy paths', () => { + expect(filesCalling('runAfterCommit(')).toEqual([ + 'src/automation/automation.service.ts', + 'src/calendar-sync/calendar-sync.service.ts', + 'src/webhooks/webhooks.service.ts', + ]); + }); + + it('only the queen assignment still enqueues a calendar entry directly', () => { + expect(filesCalling('calendarSync.enqueue(')).toEqual([ + 'src/queens/queens.service.ts', + ]); + }); + + it('only the modules without an event still dispatch webhooks directly', () => { + expect(filesCalling('webhooks.dispatch(')).toEqual([ + 'src/colonies/colonies.service.ts', + 'src/honey-batches/honey-batches.service.ts', + 'src/queens/queens.service.ts', + 'src/storage/storage.service.ts', + ]); + }); + + it('no longer defines the removed post-commit automation wrappers (only SWARM_MOOD_HIGH and STOCK_ITEM_LOW keep one)', () => { + const removed = [ + 'evaluateInspection', + 'evaluateVarroaMeasurement', + 'evaluateTreatment', + 'evaluateFeeding', + 'evaluateColonyHealth', + 'evaluateRawMaterialLow', + ].map( + (name) => + [ + name, + new RegExp( + String.raw`\b(?:public\s+|private\s+)?async\s+${name}\s*\(`, + ), + ] as const, + ); + const definitions = productionSources(SRC).flatMap((file) => { + const source = withoutComments(readFileSync(file, 'utf8')); + return removed + .filter(([, pattern]) => pattern.test(source)) + .map(([name]) => name); + }); + expect(definitions).toEqual([]); + }); +}); diff --git a/ApiCore-Backend/src/common/bulk/assert-living-colonies.spec.ts b/ApiCore-Backend/src/common/bulk/assert-living-colonies.spec.ts new file mode 100644 index 0000000..7d79c49 --- /dev/null +++ b/ApiCore-Backend/src/common/bulk/assert-living-colonies.spec.ts @@ -0,0 +1,111 @@ +import { BadRequestException } from '@nestjs/common'; +import { Prisma } from '../../../generated/prisma'; +import { assertLivingColonies } from './assert-living-colonies'; + +describe('assertLivingColonies', () => { + let tx: { $queryRaw: jest.Mock; colony: { findMany: jest.Mock } }; + + beforeEach(() => { + tx = { + $queryRaw: jest.fn().mockResolvedValue([]), + colony: { findMany: jest.fn().mockResolvedValue([]) }, + }; + }); + + async function rejection(colonyIds: string[]): Promise { + const error: unknown = await assertLivingColonies( + tx as never, + colonyIds, + ).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(BadRequestException); + return error as BadRequestException; + } + + it('locks the colonies for share before it reads them through the transaction', async () => { + tx.colony.findMany.mockResolvedValue([ + { id: 'c2', status: 'ACTIVE', apiaryId: 'a1' }, + { id: 'c1', status: 'ACTIVE', apiaryId: 'a1' }, + ]); + + await assertLivingColonies(tx as never, ['c2', 'c1']); + + const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]]; + expect(query.sql).toBe( + 'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR SHARE', + ); + expect(query.values).toEqual([['c1', 'c2']]); + expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + tx.colony.findMany.mock.invocationCallOrder[0], + ); + expect(tx.colony.findMany).toHaveBeenCalledWith({ + where: { id: { in: ['c2', 'c1'] } }, + select: { id: true, status: true, apiaryId: true }, + }); + }); + + it('returns the apiary of every colony that still lives', async () => { + tx.colony.findMany.mockResolvedValue([ + { id: 'c1', status: 'ACTIVE', apiaryId: 'a1' }, + { id: 'c2', status: 'WEAK', apiaryId: 'a2' }, + { id: 'c3', status: 'CONSPICUOUS', apiaryId: 'a1' }, + { id: 'c4', status: 'QUEENLESS', apiaryId: 'a2' }, + ]); + + const colonies = await assertLivingColonies(tx as never, [ + 'c1', + 'c2', + 'c3', + 'c4', + ]); + + expect(colonies).toEqual( + new Map([ + ['c1', { apiaryId: 'a1' }], + ['c2', { apiaryId: 'a2' }], + ['c3', { apiaryId: 'a1' }], + ['c4', { apiaryId: 'a2' }], + ]), + ); + }); + + it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'])( + 'rejects a colony with the status %s', + async (status) => { + tx.colony.findMany.mockResolvedValue([ + { id: 'c1', status: 'ACTIVE', apiaryId: 'a1' }, + { id: 'c2', status, apiaryId: 'a1' }, + ]); + + const error = await rejection(['c1', 'c2']); + + expect(error.getResponse()).toEqual({ + message: 'Einige Völker sind nicht (mehr) erfassbar', + invalidColonyIds: ['c2'], + }); + }, + ); + + it('names every ended and unknown colony in request order', async () => { + tx.colony.findMany.mockResolvedValue([ + { id: 'c1', status: 'SOLD', apiaryId: 'a1' }, + { id: 'c2', status: 'ACTIVE', apiaryId: 'a1' }, + ]); + + const error = await rejection(['missing', 'c2', 'c1']); + + expect(error.getResponse()).toEqual({ + message: 'Einige Völker sind nicht (mehr) erfassbar', + invalidColonyIds: ['missing', 'c1'], + }); + }); + + it('refuses the root client before it locks or reads anything', async () => { + const root = { $connect: jest.fn(), ...tx }; + + await expect(assertLivingColonies(root as never, ['c1'])).rejects.toThrow( + 'needs the transaction client', + ); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + expect(tx.colony.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/common/bulk/assert-living-colonies.ts b/ApiCore-Backend/src/common/bulk/assert-living-colonies.ts new file mode 100644 index 0000000..6d9db97 --- /dev/null +++ b/ApiCore-Backend/src/common/bulk/assert-living-colonies.ts @@ -0,0 +1,41 @@ +import { BadRequestException } from '@nestjs/common'; +import { Prisma } from '../../../generated/prisma'; +import { isLivingColonyStatus } from '../../colonies/colony-status'; +import { lockRowsForShare } from '../../prisma/row-lock'; + +export interface LivingColony { + apiaryId: string; +} + +/** + * Checks, inside the bulk transaction, that every colony exists and still + * lives, and returns where each one stands. The colonies are locked FOR + * SHARE first: none can be ended or moved before the bulk commits, so the + * apiary returned is the one kept on the records. + * + * Unknown and ended colonies are named together in `invalidColonyIds` - the + * client can drop them and send the rest again; nothing is stored. + */ +export async function assertLivingColonies( + tx: Prisma.TransactionClient, + colonyIds: readonly string[], +): Promise> { + await lockRowsForShare(tx, 'Colony', colonyIds); + const colonies = await tx.colony.findMany({ + where: { id: { in: [...colonyIds] } }, + select: { id: true, status: true, apiaryId: true }, + }); + const living = new Map( + colonies + .filter((colony) => isLivingColonyStatus(colony.status)) + .map((colony) => [colony.id, { apiaryId: colony.apiaryId }]), + ); + const invalidColonyIds = colonyIds.filter((id) => !living.has(id)); + if (invalidColonyIds.length > 0) { + throw new BadRequestException({ + message: 'Einige Völker sind nicht (mehr) erfassbar', + invalidColonyIds, + }); + } + return living; +} diff --git a/ApiCore-Backend/src/common/bulk/bulk-create.ts b/ApiCore-Backend/src/common/bulk/bulk-create.ts new file mode 100644 index 0000000..73a279c --- /dev/null +++ b/ApiCore-Backend/src/common/bulk/bulk-create.ts @@ -0,0 +1,21 @@ +import { RecordedForColony } from '../../domain-events/domain-event-types'; + +/** + * Answer of a bulk entry: one record per colony, in request order. `bulkId` + * groups them - it is also in the payload of each record's domain event. + */ +export interface BulkCreateResult { + bulkId: string; + records: T[]; +} + +/** + * Payload of a *Recorded event: `bulkId` only when the record comes from a + * bulk entry, so a single create's payload stays `{ colonyId }`. + */ +export function recordedForColony( + colonyId: string, + bulkId?: string, +): RecordedForColony { + return bulkId === undefined ? { colonyId } : { colonyId, bulkId }; +} diff --git a/ApiCore-Backend/src/common/bulk/bulk-limits.ts b/ApiCore-Backend/src/common/bulk/bulk-limits.ts new file mode 100644 index 0000000..4da6e70 --- /dev/null +++ b/ApiCore-Backend/src/common/bulk/bulk-limits.ts @@ -0,0 +1,13 @@ +/** + * Most colonies one bulk entry (POST /treatments/bulk, /feedings/bulk, + * /varroa-measurements/bulk) may cover - more than any apiary holds, and it + * keeps one request from becoming an unbounded transaction. + */ +export const BULK_MAX_COLONIES = 100; + +/** + * A bulk entry writes up to BULK_MAX_COLONIES records, each with its audit + * entry and domain event, in one transaction - Prisma's 5 s default is too + * short for the upper end. + */ +export const BULK_TRANSACTION_TIMEOUT_MS = 15_000; diff --git a/ApiCore-Backend/src/common/bulk/bulk-validation.ts b/ApiCore-Backend/src/common/bulk/bulk-validation.ts new file mode 100644 index 0000000..b35fb99 --- /dev/null +++ b/ApiCore-Backend/src/common/bulk/bulk-validation.ts @@ -0,0 +1,84 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsNotEmpty, + IsString, + ValidateNested, +} from 'class-validator'; +import { BULK_MAX_COLONIES } from './bulk-limits'; + +const DUPLICATE_COLONY_MESSAGE = '$property enthält ein Volk doppelt'; + +// The size rule every bulk list shares: at least one colony, at most +// BULK_MAX_COLONIES. +function bulkListSize(): PropertyDecorator[] { + return [ + IsArray({ message: '$property muss eine Liste sein' }), + ArrayMinSize(1, { message: '$property braucht mindestens ein Volk' }), + ArrayMaxSize(BULK_MAX_COLONIES, { + message: `$property darf höchstens ${BULK_MAX_COLONIES} Völker enthalten`, + }), + ]; +} + +// The rule for one colony id - in colonyIds (each) and in every entry - so an +// empty id is invalid input on both paths, never an "unknown colony". +function colonyIdChecks(each: boolean): PropertyDecorator[] { + return [ + IsString({ each, message: '$property enthält eine ungültige Volk-ID' }), + IsNotEmpty({ each, message: '$property enthält eine leere Volk-ID' }), + ]; +} + +/** `colonyIds` of a bulk entry with shared values: distinct, non-empty ids. */ +export function IsBulkColonyIds(): PropertyDecorator { + return applyDecorators( + ApiProperty({ + type: [String], + minItems: 1, + maxItems: BULK_MAX_COLONIES, + uniqueItems: true, + description: `Die Völker, für die derselbe Eintrag gilt (1 bis ${BULK_MAX_COLONIES}, jedes nur einmal)`, + }), + ...bulkListSize(), + ArrayUnique({ message: DUPLICATE_COLONY_MESSAGE }), + ...colonyIdChecks(true), + ); +} + +/** `colonyId` of one entry in `entries`: a non-empty id. */ +export function IsBulkEntryColonyId(): PropertyDecorator { + return applyDecorators( + ApiProperty({ description: 'Das Volk dieses Eintrags' }), + ...colonyIdChecks(false), + ); +} + +/** + * `entries` of a bulk entry with one value set per colony: each entry is + * validated as its class, and each colony appears once. + */ +export function IsBulkEntries( + entryType: new () => { colonyId: string }, +): PropertyDecorator { + return applyDecorators( + ApiProperty({ + type: [entryType], + minItems: 1, + maxItems: BULK_MAX_COLONIES, + description: `Ein Eintrag je Volk (1 bis ${BULK_MAX_COLONIES}, jedes Volk nur einmal)`, + }), + ...bulkListSize(), + // class-validator skips null entries before calling the selector. + ArrayUnique((entry: { colonyId?: unknown }) => entry.colonyId, { + message: DUPLICATE_COLONY_MESSAGE, + }), + ValidateNested({ each: true }), + Type(() => entryType), + ); +} diff --git a/ApiCore-Backend/src/common/bulk/run-bulk.spec.ts b/ApiCore-Backend/src/common/bulk/run-bulk.spec.ts new file mode 100644 index 0000000..90276d4 --- /dev/null +++ b/ApiCore-Backend/src/common/bulk/run-bulk.spec.ts @@ -0,0 +1,137 @@ +import { BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { BULK_TRANSACTION_TIMEOUT_MS } from './bulk-limits'; +import { BulkEntryWork, runBulk } from './run-bulk'; + +describe('runBulk', () => { + let tx: { + $queryRaw: jest.Mock; + colony: { findMany: jest.Mock }; + write: jest.Mock; + }; + let prisma: { $transaction: jest.Mock }; + // Writes of the fake transaction that survived its commit. + let committed: string[]; + + beforeEach(() => { + committed = []; + tx = { + $queryRaw: jest.fn().mockResolvedValue([]), + colony: { + findMany: jest.fn().mockResolvedValue([ + { id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' }, + { id: 'colony-2', status: 'WEAK', apiaryId: 'apiary-2' }, + { id: 'colony-3', status: 'ACTIVE', apiaryId: 'apiary-1' }, + ]), + }, + write: jest.fn(), + }; + // Keeps the writes only when the work succeeds - a rollback otherwise. + prisma = { + $transaction: jest.fn( + async (work: (txArg: typeof tx) => Promise) => { + const pending: string[] = []; + tx.write.mockImplementation((value: string) => { + pending.push(value); + }); + const result = await work(tx); + committed.push(...pending); + return result; + }, + ), + }; + }); + + const run = ( + entries: E[], + perEntry: BulkEntryWork, + ) => runBulk(prisma as unknown as PrismaService, entries, perEntry); + + it('runs every entry in request order in one transaction with the bulk timeout', async () => { + const result = await run( + [{ colonyId: 'colony-3' }, { colonyId: 'colony-1' }], + (txArg, entry) => { + (txArg as unknown as typeof tx).write(entry.colonyId); + return Promise.resolve(`record-of-${entry.colonyId}`); + }, + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + timeout: BULK_TRANSACTION_TIMEOUT_MS, + }); + expect(result.records).toEqual([ + 'record-of-colony-3', + 'record-of-colony-1', + ]); + expect(committed).toEqual(['colony-3', 'colony-1']); + }); + + it('hands every entry the transaction, its colony and the one bulkId it returns', async () => { + const perEntry = jest.fn().mockResolvedValue('record'); + + const { bulkId } = await run( + [{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }], + perEntry, + ); + + expect(bulkId).toMatch(/^[0-9a-f-]{36}$/); + expect(perEntry.mock.calls).toEqual([ + [tx, { colonyId: 'colony-1' }, { apiaryId: 'apiary-1' }, bulkId], + [tx, { colonyId: 'colony-2' }, { apiaryId: 'apiary-2' }, bulkId], + ]); + }); + + it('gives each bulk its own bulkId', async () => { + const perEntry = jest.fn().mockResolvedValue('record'); + + const first = await run([{ colonyId: 'colony-1' }], perEntry); + const second = await run([{ colonyId: 'colony-1' }], perEntry); + + expect(first.bulkId).not.toBe(second.bulkId); + }); + + it('checks every colony under the share lock before the first entry runs', async () => { + tx.colony.findMany.mockResolvedValue([ + { id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' }, + { id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' }, + ]); + const perEntry = jest.fn(); + + const error: unknown = await run( + [{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }], + perEntry, + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(BadRequestException); + expect((error as BadRequestException).getResponse()).toEqual( + expect.objectContaining({ invalidColonyIds: ['colony-2'] }), + ); + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + expect(perEntry).not.toHaveBeenCalled(); + }); + + it('rolls back every record when one entry fails', async () => { + const perEntry = jest.fn((txArg: unknown, entry: { colonyId: string }) => { + (txArg as typeof tx).write(entry.colonyId); + return entry.colonyId === 'colony-2' + ? Promise.reject(new Error('audit write failed')) + : Promise.resolve(entry.colonyId); + }); + + await expect( + run( + [ + { colonyId: 'colony-1' }, + { colonyId: 'colony-2' }, + { colonyId: 'colony-3' }, + ], + perEntry, + ), + ).rejects.toThrow('audit write failed'); + + expect(committed).toEqual([]); + // The entries after the failure are not started. + expect(perEntry).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ApiCore-Backend/src/common/bulk/run-bulk.ts b/ApiCore-Backend/src/common/bulk/run-bulk.ts new file mode 100644 index 0000000..70e3009 --- /dev/null +++ b/ApiCore-Backend/src/common/bulk/run-bulk.ts @@ -0,0 +1,51 @@ +import { randomUUID } from 'node:crypto'; +import { Prisma } from '../../../generated/prisma'; +import { PrismaService } from '../../prisma/prisma.service'; +import { assertLivingColonies, LivingColony } from './assert-living-colonies'; +import { BulkCreateResult } from './bulk-create'; +import { BULK_TRANSACTION_TIMEOUT_MS } from './bulk-limits'; + +/** + * Writes the record of one entry - record, audit entry and domain event, + * all through `tx`. `colony` is the entry's colony as read under the share + * lock; `bulkId` goes into the event payload. + */ +export type BulkEntryWork = ( + tx: Prisma.TransactionClient, + entry: E, + colony: LivingColony, + bulkId: string, +) => Promise; + +/** + * The frame of every bulk entry, all or none: one transaction with + * BULK_TRANSACTION_TIMEOUT_MS, first assertLivingColonies for all colonies, + * then `perEntry` once per entry in request order. The first failure rolls + * back every record; the records come back in request order with a new + * bulkId. + */ +export async function runBulk( + prisma: PrismaService, + entries: readonly E[], + perEntry: BulkEntryWork, +): Promise> { + const bulkId = randomUUID(); + const records = await prisma.$transaction( + async (tx) => { + const colonies = await assertLivingColonies( + tx, + entries.map((entry) => entry.colonyId), + ); + const created: T[] = []; + for (const entry of entries) { + // Every requested id is in the map - assertLivingColonies threw + // otherwise. + const colony = colonies.get(entry.colonyId)!; + created.push(await perEntry(tx, entry, colony, bulkId)); + } + return created; + }, + { timeout: BULK_TRANSACTION_TIMEOUT_MS }, + ); + return { bulkId, records }; +} diff --git a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.spec.ts b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.spec.ts index 22717c8..615e1a0 100644 --- a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.spec.ts +++ b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.spec.ts @@ -1,62 +1,75 @@ -import { Logger } from '@nestjs/common'; -import { Prisma } from '../../../generated/prisma'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; import { PrismaService } from '../../prisma/prisma.service'; -import { IdempotencyCleanupService } from './idempotency-cleanup.service'; +import { + CONSUMED_TOKEN_GRACE_HOURS, + IDEMPOTENCY_RETENTION_DAYS, + IdempotencyCleanupService, +} from './idempotency-cleanup.service'; + +type RawCall = [readonly string[], ...unknown[]]; describe('IdempotencyCleanupService', () => { + let executeRaw: jest.Mock, RawCall>; let service: IdempotencyCleanupService; - let prisma: { - idempotencyRecord: { deleteMany: jest.Mock }; - consumedToken: { deleteMany: jest.Mock }; - }; + + const statements = (): { sql: string; values: unknown[] }[] => + executeRaw.mock.calls.map(([strings, ...values]) => ({ + sql: strings.join('?').replace(/\s+/g, ' '), + values, + })); beforeEach(() => { - prisma = { - idempotencyRecord: { - deleteMany: jest.fn().mockResolvedValue({ count: 0 }), - }, - consumedToken: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, - }; - service = new IdempotencyCleanupService(prisma as unknown as PrismaService); - }); - - afterEach(() => { - jest.restoreAllMocks(); + executeRaw = jest + .fn, RawCall>() + .mockResolvedValueOnce(3) + .mockResolvedValueOnce(4); + service = new IdempotencyCleanupService({ + $executeRaw: executeRaw, + } as unknown as PrismaService); }); it('removes records older than the retention period and expired tokens', async () => { - prisma.idempotencyRecord.deleteMany.mockResolvedValue({ count: 3 }); - const now = new Date('2026-10-12T04:00:00.000Z'); - - await expect(service.removeExpired(now)).resolves.toBe(3); - - expect(prisma.idempotencyRecord.deleteMany).toHaveBeenCalledWith({ - where: { createdAt: { lt: new Date('2026-07-14T04:00:00.000Z') } }, - }); - expect(prisma.consumedToken.deleteMany).toHaveBeenCalledWith({ - where: { expiresAt: { lt: now } }, + await expect(service.removeExpired()).resolves.toEqual({ + idempotencyRecords: 3, + consumedTokens: 4, }); + + const [records, tokens] = statements(); + expect(records.sql).toContain('DELETE FROM "IdempotencyRecord"'); + expect(records.sql).toContain( + '"createdAt" < now() - (? * interval \'1 day\')', + ); + expect(records.values).toEqual([IDEMPOTENCY_RETENTION_DAYS]); + expect(tokens.sql).toContain('DELETE FROM "ConsumedToken"'); + expect(tokens.sql).toContain( + '"expiresAt" < now() - (? * interval \'1 hour\')', + ); + expect(tokens.values).toEqual([CONSUMED_TOKEN_GRACE_HOURS]); }); - // The cron tick's own errors would otherwise reach the scheduler's logger - // with the full Prisma message and stack. - it('the daily tick never throws and logs a failure without the Prisma message', async () => { - const errorSpy = jest - .spyOn(Logger.prototype, 'error') - .mockImplementation(() => undefined); - prisma.idempotencyRecord.deleteMany.mockRejectedValue( - new Prisma.PrismaClientKnownRequestError('key "Max Mustermann"', { - code: 'P2024', - clientVersion: '7.9.1', - meta: { modelName: 'IdempotencyRecord' }, - }), + // The job pod's clock is not the clock the api pods check token expiry + // with; the database clock plus a margin keeps a used token's row until + // no api pod can accept the token any more. + it('compares only against the database clock, with a margin for tokens', async () => { + await service.removeExpired(); + + expect(CONSUMED_TOKEN_GRACE_HOURS).toBe(1); + for (const { sql, values } of statements()) { + expect(sql).toContain('now()'); + expect(values.some((value) => value instanceof Date)).toBe(false); + } + }); + + // The cleanup CronJob (dist/cli.js cleanup) runs it once a day; an + // in-process timer would run it again in every api pod. + it('has no in-process schedule of its own', () => { + const source = readFileSync( + path.join(__dirname, 'idempotency-cleanup.service.ts'), + 'utf8', ); - await expect(service.scheduledCleanup()).resolves.toBeUndefined(); - - const [message, stack] = errorSpy.mock.calls[0] as [string, unknown]; - expect(message).toContain('P2024'); - expect(message).not.toContain('Mustermann'); - expect(stack).toBeUndefined(); + expect(source).not.toMatch(/@nestjs\/schedule|@Cron\(/); + expect('scheduledCleanup' in service).toBe(false); }); }); diff --git a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts index e9ec5b1..eedcf0c 100644 --- a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts +++ b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts @@ -1,46 +1,45 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; import { PrismaService } from '../../prisma/prisma.service'; -import { loggableError } from '../loggable-error'; // Longer than any realistic offline stretch at the apiary: an entry queued // on the phone must still find its key when it finally syncs, otherwise the // repeat would create a duplicate after all. export const IDEMPOTENCY_RETENTION_DAYS = 90; +// The api pods check token expiry against their own clock. A used token's +// row stays this long past its expiry (database clock), so a pod whose +// clock lags behind can never accept the token again. +export const CONSUMED_TOKEN_GRACE_HOURS = 1; +export interface ExpiredRemovalCounts { + idempotencyRecords: number; + consumedTokens: number; +} + +/** + * Removes expired idempotency records and used one-time tokens. Runs once a + * day as part of the cleanup job (dist/cli.js cleanup, CronJob + * apicore-job-cleanup), not as an in-process timer in every pod. Ages are + * compared against now(), the database clock, like every cleanup step. + */ @Injectable() export class IdempotencyCleanupService { private readonly logger = new Logger(IdempotencyCleanupService.name); constructor(private readonly prisma: PrismaService) {} - // The daily tick. An error is logged here, Prisma errors by name/code/model - // only (see loggableError) - left uncaught, the scheduler would log the - // full Prisma message and stack. - @Cron(CronExpression.EVERY_DAY_AT_4AM) - async scheduledCleanup(): Promise { - try { - await this.removeExpired(); - } catch (error: unknown) { - const { message, stack } = loggableError(error); - this.logger.error(`Idempotency cleanup failed: ${message}`, stack); - } - } - - async removeExpired(now: Date = new Date()): Promise { - const cutoff = new Date( - now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000, - ); - const { count } = await this.prisma.idempotencyRecord.deleteMany({ - where: { createdAt: { lt: cutoff } }, - }); - if (count > 0) { - this.logger.log(`Removed ${count} expired idempotency record(s)`); + async removeExpired(): Promise { + const idempotencyRecords = await this.prisma.$executeRaw` + DELETE FROM "IdempotencyRecord" + WHERE "createdAt" < now() - (${IDEMPOTENCY_RETENTION_DAYS} * interval '1 day')`; + if (idempotencyRecords > 0) { + this.logger.log( + `Removed ${idempotencyRecords} expired idempotency record(s)`, + ); } // Used one-time tokens only matter until the token itself expires. - await this.prisma.consumedToken.deleteMany({ - where: { expiresAt: { lt: now } }, - }); - return count; + const consumedTokens = await this.prisma.$executeRaw` + DELETE FROM "ConsumedToken" + WHERE "expiresAt" < now() - (${CONSUMED_TOKEN_GRACE_HOURS} * interval '1 hour')`; + return { idempotencyRecords, consumedTokens }; } } diff --git a/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.ts b/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.ts index 9e71968..852cbe8 100644 --- a/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.ts +++ b/ApiCore-Backend/src/common/throttler/prisma-throttler.storage.ts @@ -35,6 +35,10 @@ export class PrismaThrottlerStorage implements ThrottlerStorage { throttlerName: string, ): Promise { const storedKey = `${this.namespace}:${key}`; + // The columns are timestamp(3): a stored now() + ttl would be rounded UP by + // up to 1 ms and ceil() would then report ttl + 1 seconds. So the stored + // end times start from now() truncated to the millisecond (never later than + // the real clock); comparisons still use the exact now(). // ttl and blockDuration are milliseconds. In ON CONFLICT DO UPDATE every // expression reads the OLD row: "active block" keeps the hits, an expired // window or block starts again at 1, everything else counts up. @@ -42,10 +46,10 @@ export class PrismaThrottlerStorage implements ThrottlerStorage { INSERT INTO "ThrottlerHit" AS t ("key", "throttlerName", "hits", "expiresAt", "blockedUntil") VALUES ( ${storedKey}, ${throttlerName}, 1, - now() + (${ttl}::double precision * interval '1 millisecond'), + date_trunc('milliseconds', now()) + (${ttl}::double precision * interval '1 millisecond'), CASE WHEN 1 > ${limit}::int - THEN now() + (${blockDuration}::double precision * interval '1 millisecond') + THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond') ELSE NULL END ) @@ -58,7 +62,7 @@ export class PrismaThrottlerStorage implements ThrottlerStorage { "expiresAt" = CASE WHEN t."blockedUntil" > now() THEN t."expiresAt" WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL - THEN now() + (${ttl}::double precision * interval '1 millisecond') + THEN date_trunc('milliseconds', now()) + (${ttl}::double precision * interval '1 millisecond') ELSE t."expiresAt" END, "blockedUntil" = CASE @@ -66,11 +70,11 @@ export class PrismaThrottlerStorage implements ThrottlerStorage { WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN CASE WHEN 1 > ${limit}::int - THEN now() + (${blockDuration}::double precision * interval '1 millisecond') + THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond') ELSE NULL END WHEN t."hits" + 1 > ${limit}::int - THEN now() + (${blockDuration}::double precision * interval '1 millisecond') + THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond') ELSE NULL END RETURNING diff --git a/ApiCore-Backend/src/domain-events/domain-event-types.ts b/ApiCore-Backend/src/domain-events/domain-event-types.ts index 09c04d6..4baa8a0 100644 --- a/ApiCore-Backend/src/domain-events/domain-event-types.ts +++ b/ApiCore-Backend/src/domain-events/domain-event-types.ts @@ -12,6 +12,15 @@ export interface ColonyStateSnapshot { healthStatus: ColonyCondition | null; } +/** + * A record that a bulk entry (POST …/bulk) may have written: `bulkId` groups + * the records of one bulk entry, a single create leaves it out. + */ +export interface RecordedForColony { + colonyId: string; + bulkId?: string; +} + /** * The complete event catalog. A payload carries only ids and the values a * handler needs - never personal data or secrets; dates are 'YYYY-MM-DD' @@ -20,11 +29,11 @@ export interface ColonyStateSnapshot { export interface DomainEventPayloads { InspectionRecorded: { colonyId: string }; InspectionCorrected: { colonyId: string }; - VarroaMeasured: { colonyId: string }; + VarroaMeasured: RecordedForColony; VarroaCorrected: { colonyId: string }; - TreatmentRecorded: { colonyId: string }; + TreatmentRecorded: RecordedForColony; TreatmentCorrected: { colonyId: string }; - FeedingRecorded: { colonyId: string }; + FeedingRecorded: RecordedForColony; FeedingCorrected: { colonyId: string }; HarvestRecorded: { apiaryId: string; colonyIds: string[] }; HarvestCorrected: { apiaryId: string; colonyIds: string[] }; @@ -155,6 +164,14 @@ export type DomainEventHandlerFn = const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +/** + * The UTC day of `date` as 'YYYY-MM-DD' - the form payloads carry dates in + * and the day dailyDedupeKey expects. + */ +export function utcDay(date: Date): string { + return date.toISOString().slice(0, 10); +} + /** * Unique key of a time-based event: at most one per type, aggregate and * (UTC) day of the business event, however often the job runs. A full diff --git a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts index 255f8fd..5217aa8 100644 --- a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts +++ b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts @@ -5,6 +5,7 @@ import { DOMAIN_EVENT_TYPES, dailyDedupeKey, NewDomainEvent, + utcDay, } from './domain-event-types'; type CreateManyArg = { @@ -208,6 +209,21 @@ describe('domain event catalog', () => { ); }, ); + + it.each([ + ['2027-05-01T00:00:00.000Z', '2027-05-01'], + // Late evening in Berlin is still the same UTC day ... + ['2026-10-13T23:30:00+02:00', '2026-10-13'], + // ... and shortly after midnight in Berlin the UTC day before. + ['2026-10-14T01:30:00+02:00', '2026-10-13'], + ])('utcDay(%s) is %s', (instant, day) => { + expect(utcDay(new Date(instant))).toBe(day); + }); + + it('utcDay yields a day dailyDedupeKey accepts', () => { + const day = utcDay(new Date('2026-10-13T08:15:00.000Z')); + expect(dailyDedupeKey('TaskDue', 't1', day)).toBe('TaskDue:t1:2026-10-13'); + }); }); // The @ts-expect-error lines are checked by `npm run typecheck`: if the diff --git a/ApiCore-Backend/src/feedings/dto/bulk-create-feeding.dto.ts b/ApiCore-Backend/src/feedings/dto/bulk-create-feeding.dto.ts new file mode 100644 index 0000000..1e78fe5 --- /dev/null +++ b/ApiCore-Backend/src/feedings/dto/bulk-create-feeding.dto.ts @@ -0,0 +1,11 @@ +import { OmitType } from '@nestjs/swagger'; +import { IsBulkColonyIds } from '../../common/bulk/bulk-validation'; +import { CreateFeedingDto } from './create-feeding.dto'; + +/** One feeding for several colonies: every field entered once, stored on each record. */ +export class BulkCreateFeedingDto extends OmitType(CreateFeedingDto, [ + 'colonyId', +] as const) { + @IsBulkColonyIds() + colonyIds!: string[]; +} diff --git a/ApiCore-Backend/src/feedings/feedings.controller.ts b/ApiCore-Backend/src/feedings/feedings.controller.ts index b1b519b..c690743 100644 --- a/ApiCore-Backend/src/feedings/feedings.controller.ts +++ b/ApiCore-Backend/src/feedings/feedings.controller.ts @@ -4,6 +4,7 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateFeedingDto } from './dto/create-feeding.dto'; +import { BulkCreateFeedingDto } from './dto/bulk-create-feeding.dto'; import { UpdateFeedingDto } from './dto/update-feeding.dto'; import { FeedingsService } from './feedings.service'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; @@ -36,6 +37,17 @@ export class FeedingsController { return this.feedingsService.create(dto, actor.id); } + /** One feeding for several colonies - all records or none. */ + @RequirePermission('feedings:create') + @Idempotent() + @Post('bulk') + createBulk( + @Body() dto: BulkCreateFeedingDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.feedingsService.createBulk(dto, actor.id); + } + @RequirePermission('feedings:update') @Patch(':id') update( diff --git a/ApiCore-Backend/src/feedings/feedings.module.ts b/ApiCore-Backend/src/feedings/feedings.module.ts index 8f88131..6239eb2 100644 --- a/ApiCore-Backend/src/feedings/feedings.module.ts +++ b/ApiCore-Backend/src/feedings/feedings.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; -import { AutomationModule } from '../automation/automation.module'; import { FeedingsController } from './feedings.controller'; import { ColonyFeedingsController } from './colony-feedings.controller'; import { FeedingsService } from './feedings.service'; @Module({ - imports: [AutomationModule], controllers: [FeedingsController, ColonyFeedingsController], providers: [FeedingsService], exports: [FeedingsService], diff --git a/ApiCore-Backend/src/feedings/feedings.service.spec.ts b/ApiCore-Backend/src/feedings/feedings.service.spec.ts index 90c02b3..b517053 100644 --- a/ApiCore-Backend/src/feedings/feedings.service.spec.ts +++ b/ApiCore-Backend/src/feedings/feedings.service.spec.ts @@ -1,9 +1,9 @@ -import { NotFoundException } from '@nestjs/common'; -import { AutomationService } from '../automation/automation.service'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { FeedingsService } from './feedings.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits'; describe('FeedingsService', () => { let service: FeedingsService; @@ -11,33 +11,50 @@ describe('FeedingsService', () => { feeding: { findMany: jest.Mock; findUnique: jest.Mock; - create: jest.Mock; - update: jest.Mock; groupBy: jest.Mock; }; + $transaction: jest.Mock; + }; + let tx: { + feeding: { + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + colony: { findMany: jest.Mock }; + $queryRaw: jest.Mock; }; let auditLog: { record: jest.Mock }; - let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; + let events: { record: jest.Mock }; const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date() }; beforeEach(() => { + tx = { + feeding: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + colony: { findMany: jest.fn().mockResolvedValue([]) }, + $queryRaw: jest.fn().mockResolvedValue([]), + }; prisma = { feeding: { findMany: jest.fn(), findUnique: jest.fn(), - create: jest.fn(), - update: jest.fn(), groupBy: jest.fn(), }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), }; auditLog = { record: jest.fn() }; - calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + events = { record: jest.fn() }; service = new FeedingsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, - calendarSync as unknown as CalendarSyncService, - { evaluateFeeding: jest.fn() } as unknown as AutomationService, + events as unknown as DomainEventsService, ); }); @@ -49,7 +66,7 @@ describe('FeedingsService', () => { }); it('create stores the acting user as the feeder', async () => { - prisma.feeding.create.mockResolvedValue(feeding); + tx.feeding.create.mockResolvedValue(feeding); await service.create( { @@ -62,7 +79,7 @@ describe('FeedingsService', () => { 'user-1', ); - const [[createArg]] = prisma.feeding.create.mock.calls as [ + const [[createArg]] = tx.feeding.create.mock.calls as [ [{ data: { colonyId: string; userId: string } }], ]; expect(createArg.data.colonyId).toBe('colony-1'); @@ -70,9 +87,9 @@ describe('FeedingsService', () => { }); it('update writes an audit log entry with before and after data', async () => { - prisma.feeding.findUnique.mockResolvedValue(feeding); + tx.feeding.findUnique.mockResolvedValue(feeding); const updated = { ...feeding, notes: 'geändert' }; - prisma.feeding.update.mockResolvedValue(updated); + tx.feeding.update.mockResolvedValue(updated); const result = await service.update( 'feeding-1', @@ -89,16 +106,79 @@ describe('FeedingsService', () => { beforeData: feeding, afterData: updated, }), + tx, ); }); - it('update re-enqueues the calendar sync so a changed date moves the event', async () => { - prisma.feeding.findUnique.mockResolvedValue(feeding); - prisma.feeding.update.mockResolvedValue(feeding); + it('create writes feeding, audit entry and FeedingRecorded in one transaction', async () => { + tx.feeding.create.mockResolvedValue(feeding); + + await service.create( + { + colonyId: 'colony-1', + date: '2026-01-01', + feedType: 'SUGAR_WATER', + amount: 2, + unit: 'l', + }, + 'user-1', + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Feeding', + entityId: 'feeding-1', + action: 'CREATE', + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'FeedingRecorded', + aggregateType: 'Feeding', + aggregateId: 'feeding-1', + payload: { colonyId: 'colony-1' }, + actorUserId: 'user-1', + }); + }); + + it('update takes "before" from the locked row inside the transaction, not from a pre-read', async () => { + const stale = { ...feeding, notes: 'veraltet' }; + const locked = { ...feeding, notes: 'gesperrt gelesen' }; + prisma.feeding.findUnique.mockResolvedValue(stale); + tx.feeding.findUnique.mockResolvedValue(locked); + tx.feeding.update.mockResolvedValue(feeding); + + await service.update('feeding-1', { notes: 'neu' }, 'user-1'); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ beforeData: locked }), + tx, + ); + }); + + it('update records FeedingCorrected and no automation', async () => { + tx.feeding.findUnique.mockResolvedValue(feeding); + tx.feeding.update.mockResolvedValue(feeding); await service.update('feeding-1', { date: '2026-05-02' }, 'user-1'); - expect(calendarSync.enqueue).toHaveBeenCalledWith('FEEDING', 'feeding-1'); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'UPDATE' }), + tx, + ); + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'FeedingCorrected', + aggregateType: 'Feeding', + aggregateId: 'feeding-1', + payload: { colonyId: 'colony-1' }, + }), + ); }); it('summaryByColony sums amounts per unit', async () => { @@ -110,4 +190,90 @@ describe('FeedingsService', () => { expect(summary).toEqual([{ unit: 'l', totalAmount: 5.5 }]); }); + + describe('createBulk', () => { + const shared = { + date: '2026-09-01', + feedType: 'SUGAR_WATER' as const, + amount: 5, + unit: 'l', + batchNumber: 'F-7', + }; + type CreateArg = { data: Record & { colonyId: string } }; + + beforeEach(() => { + tx.colony.findMany.mockResolvedValue([ + { id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' }, + { id: 'colony-2', status: 'QUEENLESS', apiaryId: 'apiary-2' }, + ]); + tx.feeding.create.mockImplementation(({ data }: CreateArg) => + Promise.resolve({ ...data, id: `feeding-of-${data.colonyId}` }), + ); + }); + + it('createBulk writes one feeding per colony with audit entry and FeedingRecorded carrying the bulkId', async () => { + const result = await service.createBulk( + { ...shared, colonyIds: ['colony-1', 'colony-2'] }, + 'user-1', + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + timeout: BULK_TRANSACTION_TIMEOUT_MS, + }); + expect(result.records.map((record) => record.id)).toEqual([ + 'feeding-of-colony-1', + 'feeding-of-colony-2', + ]); + for (const colonyId of ['colony-1', 'colony-2']) { + expect(tx.feeding.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + ...shared, + colonyId, + date: new Date('2026-09-01'), + userId: 'user-1', + }) as unknown, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Feeding', + entityId: `feeding-of-${colonyId}`, + action: 'CREATE', + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'FeedingRecorded', + aggregateType: 'Feeding', + aggregateId: `feeding-of-${colonyId}`, + payload: { colonyId, bulkId: result.bulkId }, + actorUserId: 'user-1', + }); + } + }); + + it('createBulk stores the same row as a single create', async () => { + await service.create({ ...shared, colonyId: 'colony-1' }, 'user-1'); + await service.createBulk( + { ...shared, colonyIds: ['colony-1'] }, + 'user-1', + ); + + const [[single], [bulk]] = tx.feeding.create.mock.calls as [CreateArg][]; + expect(bulk).toEqual(single); + expect(bulk.data).not.toHaveProperty('colonyIds'); + }); + + it('createBulk writes nothing when a colony has ended', async () => { + tx.colony.findMany.mockResolvedValue([ + { id: 'colony-1', status: 'MERGED', apiaryId: 'apiary-1' }, + ]); + + await expect( + service.createBulk({ ...shared, colonyIds: ['colony-1'] }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.feeding.create).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/feedings/feedings.service.ts b/ApiCore-Backend/src/feedings/feedings.service.ts index f4d47ec..ab73f87 100644 --- a/ApiCore-Backend/src/feedings/feedings.service.ts +++ b/ApiCore-Backend/src/feedings/feedings.service.ts @@ -1,10 +1,16 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { AutomationService } from '../automation/automation.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; -import { Feeding } from '../../generated/prisma'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { Feeding, Prisma } from '../../generated/prisma'; +import { + BulkCreateResult, + recordedForColony, +} from '../common/bulk/bulk-create'; +import { runBulk } from '../common/bulk/run-bulk'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { CreateFeedingDto } from './dto/create-feeding.dto'; +import { BulkCreateFeedingDto } from './dto/bulk-create-feeding.dto'; import { UpdateFeedingDto } from './dto/update-feeding.dto'; export interface FeedingSummaryEntry { @@ -12,13 +18,21 @@ export interface FeedingSummaryEntry { totalAmount: number; } +// The row a new feeding is stored as - for create and createBulk alike, so a +// bulk entry stores exactly what single entries would. +function feedingRow( + dto: CreateFeedingDto, + actingUserId: string, +): Prisma.FeedingUncheckedCreateInput { + return { ...dto, date: new Date(dto.date), userId: actingUserId }; +} + @Injectable() export class FeedingsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly calendarSync: CalendarSyncService, - private readonly automation: AutomationService, + private readonly events: DomainEventsService, ) {} async findAll(): Promise { @@ -53,18 +67,60 @@ export class FeedingsService { } async create(dto: CreateFeedingDto, actingUserId: string): Promise { - const feeding = await this.prisma.feeding.create({ - data: { ...dto, date: new Date(dto.date), userId: actingUserId }, + return this.prisma.$transaction((tx) => + this.recordIn(tx, feedingRow(dto, actingUserId), actingUserId), + ); + } + + /** + * One feeding per colony, all or none (see runBulk). Every record gets its + * own audit entry and FeedingRecorded, so calendar entry, follow-up check + * and webhook follow per colony exactly as for a single feeding. + */ + async createBulk( + dto: BulkCreateFeedingDto, + actingUserId: string, + ): Promise> { + const { colonyIds, ...shared } = dto; + return runBulk( + this.prisma, + colonyIds.map((colonyId) => ({ colonyId })), + (tx, { colonyId }, _colony, bulkId) => + this.recordIn( + tx, + feedingRow({ ...shared, colonyId }, actingUserId), + actingUserId, + bulkId, + ), + ); + } + + // Feeding, audit entry and FeedingRecorded in the caller's transaction; + // calendar entry and follow-up check follow from the event. + private async recordIn( + tx: Prisma.TransactionClient, + data: Prisma.FeedingUncheckedCreateInput, + actingUserId: string, + bulkId?: string, + ): Promise { + const feeding = await tx.feeding.create({ data }); + await this.auditLog.record( + { + entityType: 'Feeding', + entityId: feeding.id, + action: 'CREATE', + userId: actingUserId, + afterData: feeding, + }, + tx, + ); + await this.events.record(tx, { + type: 'FeedingRecorded', + aggregateType: 'Feeding', + aggregateId: feeding.id, + payload: recordedForColony(feeding.colonyId, bulkId), + actorUserId: actingUserId, }); - await this.auditLog.record({ - entityType: 'Feeding', - entityId: feeding.id, - action: 'CREATE', - userId: actingUserId, - afterData: feeding, - }); - await this.calendarSync.enqueue('FEEDING', feeding.id); - await this.automation.evaluateFeeding(feeding); return feeding; } @@ -73,22 +129,39 @@ export class FeedingsService { dto: UpdateFeedingDto, actingUserId: string, ): Promise { - const before = await this.findById(id); - const feeding = await this.prisma.feeding.update({ - where: { id }, - data: { ...dto, date: dto.date ? new Date(dto.date) : undefined }, + // FeedingCorrected moves the calendar event (changed date or colony) but + // starts no follow-up check. + return this.prisma.$transaction(async (tx) => { + // The audit "before" is read under the row lock, not before the + // transaction: a concurrent edit cannot slip in between. + await lockRowForUpdate(tx, 'Feeding', id); + const before = await tx.feeding.findUnique({ where: { id } }); + if (!before) { + throw new NotFoundException('Feeding not found'); + } + const feeding = await tx.feeding.update({ + where: { id }, + data: { ...dto, date: dto.date ? new Date(dto.date) : undefined }, + }); + await this.auditLog.record( + { + entityType: 'Feeding', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: feeding, + }, + tx, + ); + await this.events.record(tx, { + type: 'FeedingCorrected', + aggregateType: 'Feeding', + aggregateId: id, + payload: { colonyId: feeding.colonyId }, + actorUserId: actingUserId, + }); + return feeding; }); - await this.auditLog.record({ - entityType: 'Feeding', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: feeding, - }); - // A changed date or colony must move the calendar event, not leave the - // old one behind. - await this.calendarSync.enqueue('FEEDING', feeding.id); - return feeding; } } diff --git a/ApiCore-Backend/src/harvests/harvests.service.spec.ts b/ApiCore-Backend/src/harvests/harvests.service.spec.ts index dcdaf3e..d2fa9ff 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.spec.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.spec.ts @@ -2,8 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { HarvestsService } from './harvests.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; describe('HarvestsService', () => { let service: HarvestsService; @@ -13,21 +12,35 @@ describe('HarvestsService', () => { $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; - let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; + let events: { record: jest.Mock }; let tx: { - harvest: { create: jest.Mock; update: jest.Mock }; + harvest: { + create: jest.Mock; + update: jest.Mock; + findUnique: jest.Mock; + findUniqueOrThrow: jest.Mock; + }; harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock }; + $queryRaw: jest.Mock; }; - const harvest = { id: 'harvest-1', apiaryId: 'apiary-1', date: new Date() }; + const harvest = { + id: 'harvest-1', + apiaryId: 'apiary-1', + date: new Date(), + colonies: [{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }], + }; beforeEach(() => { tx = { harvest: { create: jest.fn().mockResolvedValue(harvest), update: jest.fn(), + findUnique: jest.fn().mockResolvedValue(harvest), + findUniqueOrThrow: jest.fn().mockResolvedValue(harvest), }, harvestColony: { createMany: jest.fn(), deleteMany: jest.fn() }, + $queryRaw: jest.fn().mockResolvedValue([]), }; prisma = { harvest: { findUnique: jest.fn(), update: jest.fn() }, @@ -41,13 +54,11 @@ describe('HarvestsService', () => { ), }; auditLog = { record: jest.fn() }; - const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; - calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + events = { record: jest.fn() }; service = new HarvestsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, - webhooks as unknown as WebhooksService, - calendarSync as unknown as CalendarSyncService, + events as unknown as DomainEventsService, ); }); @@ -97,9 +108,8 @@ describe('HarvestsService', () => { it('update writes an audit log entry with before and after data', async () => { const updated = { ...harvest, notes: 'geändert' }; - prisma.harvest.findUnique - .mockResolvedValueOnce(harvest) - .mockResolvedValueOnce(updated); + tx.harvest.findUnique.mockResolvedValueOnce(harvest); + tx.harvest.findUniqueOrThrow.mockResolvedValueOnce(updated); const result = await service.update( 'harvest-1', @@ -117,19 +127,83 @@ describe('HarvestsService', () => { beforeData: harvest, afterData: updated, }), + tx, ); }); - it('update re-enqueues the calendar sync so a changed date moves the event', async () => { - prisma.harvest.findUnique.mockResolvedValue(harvest); + it('update takes "before" from the locked row inside the transaction, not from a pre-read', async () => { + const stale = { ...harvest, notes: 'veraltet' }; + const locked = { ...harvest, notes: 'gesperrt gelesen' }; + prisma.harvest.findUnique.mockResolvedValue(stale); + tx.harvest.findUnique.mockResolvedValue(locked); + + await service.update('harvest-1', { notes: 'neu' }, 'user-1'); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ beforeData: locked }), + tx, + ); + }); + + it('update checks the weights against the locked row', async () => { + tx.harvest.findUnique.mockResolvedValue({ ...harvest, grossWeight: 10 }); + + await expect( + service.update('harvest-1', { netWeight: 12 }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.harvest.update).not.toHaveBeenCalled(); + }); + + it('create records HarvestRecorded with the colony ids, audit entry in the same transaction', async () => { + await service.create( + { + apiaryId: 'apiary-1', + date: '2026-07-01', + colonyIds: ['colony-1', 'colony-2'], + }, + 'user-1', + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Harvest', + entityId: 'harvest-1', + action: 'CREATE', + userId: 'user-1', + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'HarvestRecorded', + aggregateType: 'Harvest', + aggregateId: 'harvest-1', + payload: { apiaryId: 'apiary-1', colonyIds: ['colony-1', 'colony-2'] }, + actorUserId: 'user-1', + }); + }); + + it('update records HarvestCorrected with the resulting colony ids', async () => { + tx.harvest.findUnique.mockResolvedValue(harvest); await service.update('harvest-1', { date: '2026-07-02' }, 'user-1'); - expect(calendarSync.enqueue).toHaveBeenCalledWith('HARVEST', 'harvest-1'); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'HarvestCorrected', + aggregateType: 'Harvest', + aggregateId: 'harvest-1', + payload: { apiaryId: 'apiary-1', colonyIds: ['colony-1', 'colony-2'] }, + }), + ); }); it('update replaces the whole colony set when colonyIds is given', async () => { - prisma.harvest.findUnique.mockResolvedValue(harvest); + tx.harvest.findUnique.mockResolvedValue(harvest); await service.update( 'harvest-1', @@ -150,7 +224,7 @@ describe('HarvestsService', () => { }); it('update rejects an unknown colony id before touching the database', async () => { - prisma.harvest.findUnique.mockResolvedValue(harvest); + tx.harvest.findUnique.mockResolvedValue(harvest); prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]); await expect( diff --git a/ApiCore-Backend/src/harvests/harvests.service.ts b/ApiCore-Backend/src/harvests/harvests.service.ts index 5efc392..32b18b1 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.ts @@ -5,8 +5,8 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { CreateHarvestDto } from './dto/create-harvest.dto'; import { UpdateHarvestDto } from './dto/update-harvest.dto'; @@ -19,8 +19,7 @@ export class HarvestsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly webhooks: WebhooksService, - private readonly calendarSync: CalendarSyncService, + private readonly events: DomainEventsService, ) {} async findAll() { @@ -70,8 +69,10 @@ export class HarvestsService { async create(dto: CreateHarvestDto, actingUserId: string) { this.assertWeightsPlausible(dto.grossWeight, dto.netWeight); await this.assertColoniesExist(dto.colonyIds); - const harvest = await this.prisma.$transaction(async (tx) => { - const created = await tx.harvest.create({ + // Harvest, audit entry and HarvestRecorded commit together; webhook and + // calendar entry follow from the event. + return this.prisma.$transaction(async (tx) => { + const harvest = await tx.harvest.create({ data: { apiaryId: dto.apiaryId, date: new Date(dto.date), @@ -85,37 +86,57 @@ export class HarvestsService { }); await tx.harvestColony.createMany({ data: dto.colonyIds.map((colonyId) => ({ - harvestId: created.id, + harvestId: harvest.id, colonyId, })), }); - return created; + await this.auditLog.record( + { + entityType: 'Harvest', + entityId: harvest.id, + action: 'CREATE', + userId: actingUserId, + afterData: harvest, + }, + tx, + ); + await this.events.record(tx, { + type: 'HarvestRecorded', + aggregateType: 'Harvest', + aggregateId: harvest.id, + payload: { apiaryId: harvest.apiaryId, colonyIds: dto.colonyIds }, + actorUserId: actingUserId, + }); + return tx.harvest.findUniqueOrThrow({ + where: { id: harvest.id }, + include: harvestInclude, + }); }); - await this.auditLog.record({ - entityType: 'Harvest', - entityId: harvest.id, - action: 'CREATE', - userId: actingUserId, - afterData: harvest, - }); - const created = await this.findById(harvest.id); - await this.webhooks.dispatch('harvest.created', created); - await this.calendarSync.enqueue('HARVEST', harvest.id); - return created; } async update(id: string, dto: UpdateHarvestDto, actingUserId: string) { - const before = await this.findById(id); - this.assertWeightsPlausible( - dto.grossWeight !== undefined ? dto.grossWeight : before.grossWeight, - dto.netWeight !== undefined ? dto.netWeight : before.netWeight, - ); const { colonyIds, ...scalarFields } = dto; if (colonyIds) { await this.assertColoniesExist(colonyIds); } - await this.prisma.$transaction(async (tx) => { + // HarvestCorrected moves the calendar event (changed date) but sends no + // webhook. + return this.prisma.$transaction(async (tx) => { + // "Before" and the weight check read under the row lock, not before + // the transaction: a concurrent edit cannot slip in between. + await lockRowForUpdate(tx, 'Harvest', id); + const before = await tx.harvest.findUnique({ + where: { id }, + include: harvestInclude, + }); + if (!before) { + throw new NotFoundException('Harvest not found'); + } + this.assertWeightsPlausible( + dto.grossWeight !== undefined ? dto.grossWeight : before.grossWeight, + dto.netWeight !== undefined ? dto.netWeight : before.netWeight, + ); await tx.harvest.update({ where: { id }, data: { @@ -131,20 +152,32 @@ export class HarvestsService { data: colonyIds.map((colonyId) => ({ harvestId: id, colonyId })), }); } + const harvest = await tx.harvest.findUniqueOrThrow({ + where: { id }, + include: harvestInclude, + }); + await this.auditLog.record( + { + entityType: 'Harvest', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: harvest, + }, + tx, + ); + await this.events.record(tx, { + type: 'HarvestCorrected', + aggregateType: 'Harvest', + aggregateId: id, + payload: { + apiaryId: harvest.apiaryId, + colonyIds: harvest.colonies.map((entry) => entry.colonyId), + }, + actorUserId: actingUserId, + }); + return harvest; }); - - const harvest = await this.findById(id); - await this.auditLog.record({ - entityType: 'Harvest', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: harvest, - }); - // A changed date must move the calendar event, not leave the old one - // behind. - await this.calendarSync.enqueue('HARVEST', harvest.id); - return harvest; } } diff --git a/ApiCore-Backend/src/inspections/inspections.module.ts b/ApiCore-Backend/src/inspections/inspections.module.ts index 427dfc7..47e276f 100644 --- a/ApiCore-Backend/src/inspections/inspections.module.ts +++ b/ApiCore-Backend/src/inspections/inspections.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; -import { AutomationModule } from '../automation/automation.module'; +import { TasksModule } from '../tasks/tasks.module'; import { InspectionsController } from './inspections.controller'; import { ColonyInspectionsController } from './colony-inspections.controller'; import { InspectionsService } from './inspections.service'; +/** + * Rules, calendar and webhooks follow from the inspection's domain events + * (DomainEventsModule is global), so no AutomationModule here. + */ @Module({ - imports: [AutomationModule], + imports: [TasksModule], 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 22d3f32..c6af63c 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.spec.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.spec.ts @@ -1,24 +1,30 @@ 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'; import { AuditLogService } from '../audit/audit-log.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { TasksService } from '../tasks/tasks.service'; +import { Prisma } from '../../generated/prisma'; describe('InspectionsService', () => { let service: InspectionsService; + let tx: { + $queryRaw: jest.Mock; + inspection: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock }; + }; let prisma: { + $connect: jest.Mock; + $transaction: jest.Mock; inspection: { findMany: jest.Mock; findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; }; - task: { create: jest.Mock }; }; let auditLog: { record: jest.Mock }; - let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; + let events: { record: jest.Mock }; + let tasks: { createInTx: jest.Mock }; const inspection = { id: 'inspection-1', @@ -27,30 +33,48 @@ describe('InspectionsService', () => { }; beforeEach(() => { + tx = { + $queryRaw: jest.fn().mockResolvedValue([]), + inspection: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; prisma = { + $connect: jest.fn(), + $transaction: jest.fn((work: (client: typeof tx) => Promise) => + work(tx), + ), inspection: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), }, - task: { create: jest.fn() }, - }; - const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; - const automation = { - evaluateInspection: jest.fn().mockResolvedValue(undefined), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; - calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + events = { record: jest.fn().mockResolvedValue(undefined) }; + tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) }; service = new InspectionsService( prisma as unknown as PrismaService, - webhooks as unknown as WebhooksService, - automation as unknown as AutomationService, auditLog as unknown as AuditLogService, - calendarSync as unknown as CalendarSyncService, + events as unknown as DomainEventsService, + tasks as unknown as TasksService, ); }); + it('has no side-effect dependency: rules, calendar and webhooks follow from its events', () => { + expect( + Reflect.getMetadata('design:paramtypes', InspectionsService), + ).toEqual([ + PrismaService, + AuditLogService, + DomainEventsService, + TasksService, + ]); + }); + it('findById throws NotFoundException when missing', async () => { prisma.inspection.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( @@ -58,96 +82,180 @@ describe('InspectionsService', () => { ); }); - it('create stores the acting user as the inspector', async () => { - prisma.inspection.create.mockResolvedValue(inspection); + it('create writes inspection, audit entry and InspectionRecorded in one transaction and calls no side effect directly', async () => { + tx.inspection.create.mockResolvedValue(inspection); - await service.create( + const result = await service.create( { colonyId: 'colony-1', date: '2026-01-01' }, 'user-1', ); - const [[createArg]] = prisma.inspection.create.mock.calls as [ - [{ data: { colonyId: string; userId: string } }], - ]; - expect(createArg.data.colonyId).toBe('colony-1'); - expect(createArg.data.userId).toBe('user-1'); - }); - - it('create writes an audit log entry with the created inspection', async () => { - prisma.inspection.create.mockResolvedValue(inspection); - - await service.create( - { colonyId: 'colony-1', date: '2026-01-01' }, - 'user-1', - ); - - expect(auditLog.record).toHaveBeenCalledWith({ - entityType: 'Inspection', - entityId: 'inspection-1', - action: 'CREATE', - userId: 'user-1', - afterData: inspection, + expect(result).toBe(inspection); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.inspection.create).toHaveBeenCalledWith({ + data: { + colonyId: 'colony-1', + date: new Date('2026-01-01'), + userId: 'user-1', + }, }); + expect(auditLog.record).toHaveBeenCalledWith( + { + entityType: 'Inspection', + entityId: 'inspection-1', + action: 'CREATE', + userId: 'user-1', + afterData: inspection, + }, + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'InspectionRecorded', + aggregateType: 'Inspection', + aggregateId: 'inspection-1', + payload: { colonyId: 'colony-1' }, + actorUserId: 'user-1', + }); + // Nothing is written outside the transaction - no rule evaluation, + // calendar entry or webhook runs in the request. + expect(prisma.inspection.create).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); }); - it('update writes an audit log entry with before and after data', async () => { - prisma.inspection.findUnique.mockResolvedValue(inspection); - const updated = { ...inspection, notes: 'geändert' }; - prisma.inspection.update.mockResolvedValue(updated); + it('create fails as a whole when its event cannot be written', async () => { + tx.inspection.create.mockResolvedValue(inspection); + events.record.mockRejectedValue(new Error('outbox down')); + + await expect( + service.create({ colonyId: 'colony-1', date: '2026-01-01' }, 'user-1'), + ).rejects.toThrow('outbox down'); + }); + + it('update writes the correction, audit entry and InspectionCorrected in one transaction', async () => { + tx.inspection.findUnique.mockResolvedValue(inspection); + // Re-filed against another colony: the event names the colony it is + // filed against now. + const updated = { ...inspection, colonyId: 'colony-2', notes: 'geändert' }; + tx.inspection.update.mockResolvedValue(updated); const result = await service.update( 'inspection-1', - { notes: 'geändert' }, + { colonyId: 'colony-2', notes: 'geändert', date: '2026-05-02' }, 'user-1', ); expect(result).toBe(updated); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.inspection.findUnique).toHaveBeenCalledWith({ + where: { id: 'inspection-1' }, + }); + expect(tx.inspection.update).toHaveBeenCalledWith({ + where: { id: 'inspection-1' }, + data: { + colonyId: 'colony-2', + notes: 'geändert', + date: new Date('2026-05-02'), + }, + }); expect(auditLog.record).toHaveBeenCalledWith( - expect.objectContaining({ + { entityType: 'Inspection', entityId: 'inspection-1', action: 'UPDATE', + userId: 'user-1', beforeData: inspection, afterData: updated, - }), + }, + tx, + ); + // The calendar entry follows from the event (calendar-sync.inspection); + // a correction evaluates no automation rule. + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'InspectionCorrected', + aggregateType: 'Inspection', + aggregateId: 'inspection-1', + payload: { colonyId: 'colony-2' }, + actorUserId: 'user-1', + }); + expect(prisma.inspection.update).not.toHaveBeenCalled(); + }); + + it('update locks the inspection row before re-reading it as "before"', async () => { + tx.inspection.findUnique.mockResolvedValue(inspection); + tx.inspection.update.mockResolvedValue(inspection); + + await service.update('inspection-1', { notes: 'x' }, 'user-1'); + + const [[lock]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]]; + expect(lock.sql).toBe( + 'SELECT id FROM "Inspection" WHERE id = ? FOR UPDATE', + ); + expect(lock.values).toEqual(['inspection-1']); + const [lockedAt] = tx.$queryRaw.mock.invocationCallOrder; + const [readAt] = tx.inspection.findUnique.mock.invocationCallOrder; + expect(lockedAt).toBeLessThan(readAt); + }); + + it('update uses the locked row, not a read outside the transaction, as "before"', async () => { + // Another request re-filed the inspection while this one waited. + prisma.inspection.findUnique.mockResolvedValue(inspection); + const locked = { ...inspection, colonyId: 'colony-3', notes: 'parallel' }; + tx.inspection.findUnique.mockResolvedValue(locked); + const updated = { ...locked, notes: 'geändert' }; + tx.inspection.update.mockResolvedValue(updated); + + await service.update('inspection-1', { notes: 'geändert' }, 'user-1'); + + expect(prisma.inspection.findUnique).not.toHaveBeenCalled(); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ beforeData: locked, afterData: updated }), + tx, ); }); - it('update re-enqueues the calendar sync so a changed date moves the event', async () => { - prisma.inspection.findUnique.mockResolvedValue(inspection); - prisma.inspection.update.mockResolvedValue(inspection); + it('update throws NotFoundException for a missing inspection and writes nothing', async () => { + tx.inspection.findUnique.mockResolvedValue(null); - await service.update('inspection-1', { date: '2026-05-02' }, 'user-1'); - - expect(calendarSync.enqueue).toHaveBeenCalledWith( - 'INSPECTION', - 'inspection-1', - ); + await expect( + service.update('missing', { notes: 'x' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(tx.inspection.update).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); }); - it('createTaskFromInspection links the task to the inspection and its colony', async () => { + it('createTaskFromInspection creates the task through TasksService.createInTx in one transaction', async () => { prisma.inspection.findUnique.mockResolvedValue(inspection); - prisma.task.create.mockResolvedValue({ id: 'task-1' }); - await service.createTaskFromInspection( + const result = await service.createTaskFromInspection( 'inspection-1', - { title: 'Nachschauen' }, + { + title: 'Nachschauen', + priority: 'HIGH', + dueDate: '2026-06-01T00:00:00.000Z', + }, 'user-1', ); - const [[taskArg]] = prisma.task.create.mock.calls as [ - [ - { - data: { - sourceInspectionId: string; - relatedType: string; - relatedId: string; - }; - }, - ], - ]; - expect(taskArg.data.sourceInspectionId).toBe('inspection-1'); - expect(taskArg.data.relatedType).toBe('COLONY'); - expect(taskArg.data.relatedId).toBe('colony-1'); + expect(result).toEqual({ id: 'task-1' }); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tasks.createInTx).toHaveBeenCalledWith( + tx, + { + title: 'Nachschauen', + description: undefined, + priority: 'HIGH', + dueDate: new Date('2026-06-01T00:00:00.000Z'), + assignedUserId: undefined, + relatedType: 'COLONY', + relatedId: 'colony-1', + sourceInspectionId: 'inspection-1', + }, + 'user-1', + ); + // Audit entry and TaskCreated come from createInTx - calendar entry and + // webhook follow from that event, not from here. + expect(auditLog.record).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); }); }); diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index 85f03be..a9b0a61 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -1,23 +1,31 @@ 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 { DbClient } from '../prisma/db-client'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { AuditLogService } from '../audit/audit-log.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { TasksService } from '../tasks/tasks.service'; import { Inspection, Task } from '../../generated/prisma'; import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { UpdateInspectionDto } from './dto/update-inspection.dto'; import { optionalDate } from '../common/patch-values'; +/** + * Inspections are written together with their audit entry and domain event + * in one transaction: InspectionRecorded, InspectionCorrected. Automation + * rules (automation.inspection-rules, on InspectionRecorded only), calendar + * entry (calendar-sync.inspection) and the inspection.created webhook + * (webhooks.forward) follow from the event, never from a direct call - so + * none of them can fail the request or run twice on a retried one. + */ @Injectable() export class InspectionsService { constructor( private readonly prisma: PrismaService, - private readonly webhooks: WebhooksService, - private readonly automation: AutomationService, private readonly auditLog: AuditLogService, - private readonly calendarSync: CalendarSyncService, + private readonly events: DomainEventsService, + private readonly tasks: TasksService, ) {} async findAll(): Promise { @@ -25,13 +33,7 @@ export class InspectionsService { } async findById(id: string): Promise { - const inspection = await this.prisma.inspection.findUnique({ - where: { id }, - }); - if (!inspection) { - throw new NotFoundException('Inspection not found'); - } - return inspection; + return this.findIn(this.prisma, id); } async findByColony(colonyId: string): Promise { @@ -45,78 +47,104 @@ export class InspectionsService { dto: CreateInspectionDto, actingUserId: string, ): Promise { - const inspection = await this.prisma.inspection.create({ - data: { ...dto, date: new Date(dto.date), userId: actingUserId }, + return this.prisma.$transaction(async (tx) => { + const inspection = await tx.inspection.create({ + data: { ...dto, date: new Date(dto.date), userId: actingUserId }, + }); + await this.auditLog.record( + { + entityType: 'Inspection', + entityId: inspection.id, + action: 'CREATE', + userId: actingUserId, + afterData: inspection, + }, + tx, + ); + await this.events.record(tx, { + type: 'InspectionRecorded', + aggregateType: 'Inspection', + aggregateId: inspection.id, + payload: { colonyId: inspection.colonyId }, + actorUserId: actingUserId, + }); + return inspection; }); - await this.auditLog.record({ - entityType: 'Inspection', - entityId: inspection.id, - action: 'CREATE', - userId: actingUserId, - afterData: inspection, - }); - await this.webhooks.dispatch('inspection.created', inspection); - await this.automation.evaluateInspection(inspection); - await this.calendarSync.enqueue('INSPECTION', inspection.id); - return inspection; } + /** + * A correction evaluates no automation rule, as before the event + * backbone. InspectionCorrected moves the calendar entry when the date or + * colony changed instead of leaving the old one behind. + */ async update( id: string, dto: UpdateInspectionDto, actingUserId: string, ): Promise { - const before = await this.findById(id); - const inspection = await this.prisma.inspection.update({ - where: { id }, - data: { ...dto, date: dto.date ? new Date(dto.date) : undefined }, + return this.prisma.$transaction(async (tx) => { + // "before" is read under the row lock: a concurrent correction can + // neither slip in between nor be mislabelled in the audit entry. + await lockRowForUpdate(tx, 'Inspection', id); + const before = await this.findIn(tx, id); + const inspection = await tx.inspection.update({ + where: { id }, + data: { ...dto, date: dto.date ? new Date(dto.date) : undefined }, + }); + await this.auditLog.record( + { + entityType: 'Inspection', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: inspection, + }, + tx, + ); + await this.events.record(tx, { + type: 'InspectionCorrected', + aggregateType: 'Inspection', + aggregateId: id, + payload: { colonyId: inspection.colonyId }, + actorUserId: actingUserId, + }); + return inspection; }); - await this.auditLog.record({ - entityType: 'Inspection', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: inspection, - }); - // A changed date or colony must move the calendar event, not leave the - // old one behind. - await this.calendarSync.enqueue('INSPECTION', inspection.id); - return inspection; } + // Through TasksService like a task from the task page: audit entry, + // TaskCreated and with it calendar entry and webhook do not depend on + // where the task was started. async createTaskFromInspection( id: string, dto: CreateTaskFromInspectionDto, actingUserId: string, ): Promise { const inspection = await this.findById(id); - const task = await this.prisma.task.create({ - data: { - title: dto.title, - description: dto.description, - priority: dto.priority, - dueDate: optionalDate(dto.dueDate), - assignedUserId: dto.assignedUserId, - createdByUserId: actingUserId, - relatedType: 'COLONY', - relatedId: inspection.colonyId, - sourceInspectionId: inspection.id, - }, - }); - // Same trail as a task created on the task page: audit entry, webhook - // and calendar entry must not depend on where the task was started. - await this.auditLog.record({ - entityType: 'Task', - entityId: task.id, - action: 'CREATE', - userId: actingUserId, - afterData: task, - }); - await this.webhooks.dispatch('task.created', task); - if (task.dueDate) { - await this.calendarSync.enqueue('TASK', task.id); + return this.prisma.$transaction((tx) => + this.tasks.createInTx( + tx, + { + title: dto.title, + description: dto.description, + priority: dto.priority, + dueDate: optionalDate(dto.dueDate), + assignedUserId: dto.assignedUserId, + relatedType: 'COLONY', + relatedId: inspection.colonyId, + sourceInspectionId: inspection.id, + }, + actingUserId, + ), + ); + } + + private async findIn(db: DbClient, id: string): Promise { + const inspection = await db.inspection.findUnique({ where: { id } }); + if (!inspection) { + throw new NotFoundException('Inspection not found'); } - return task; + return inspection; } } diff --git a/ApiCore-Backend/src/jobs/app-backup.job.spec.ts b/ApiCore-Backend/src/jobs/app-backup.job.spec.ts new file mode 100644 index 0000000..5355bf6 --- /dev/null +++ b/ApiCore-Backend/src/jobs/app-backup.job.spec.ts @@ -0,0 +1,48 @@ +import { BackupSchedulerService } from '../backup/backup-scheduler.service'; +import { AppBackupJob } from './app-backup.job'; + +describe('AppBackupJob', () => { + let scheduler: { runIfDue: jest.Mock }; + let job: AppBackupJob; + + beforeEach(() => { + scheduler = { runIfDue: jest.fn() }; + job = new AppBackupJob(scheduler as unknown as BackupSchedulerService); + }); + + it('is the app-backup job', () => { + expect(job.name).toBe('app-backup'); + }); + + it('writes a backup when one is due and reports it', async () => { + scheduler.runIfDue.mockResolvedValue({ + written: true, + fileName: 'apicore-backup-2026-10-13T01-00-04-000Z.json', + sizeBytes: 2048, + pruned: 1, + }); + + await expect(job.run()).resolves.toEqual({ + written: true, + fileName: 'apicore-backup-2026-10-13T01-00-04-000Z.json', + sizeBytes: 2048, + pruned: 1, + }); + expect(scheduler.runIfDue).toHaveBeenCalledWith(); + }); + + it('reports why nothing was written', async () => { + scheduler.runIfDue.mockResolvedValue({ written: false, reason: 'not-due' }); + + await expect(job.run()).resolves.toEqual({ + written: false, + reason: 'not-due', + }); + }); + + it('fails the run when the backup fails', async () => { + scheduler.runIfDue.mockRejectedValue(new Error('disk full')); + + await expect(job.run()).rejects.toThrow('disk full'); + }); +}); diff --git a/ApiCore-Backend/src/jobs/app-backup.job.ts b/ApiCore-Backend/src/jobs/app-backup.job.ts new file mode 100644 index 0000000..2ef7c03 --- /dev/null +++ b/ApiCore-Backend/src/jobs/app-backup.job.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { BackupSchedulerService } from '../backup/backup-scheduler.service'; +import { Job, JobResult } from './job'; +import { JobName } from './job-names'; + +/** + * The hourly CronJob check of the automatic backup: writes a backup only if + * the schedule (env or GUI, Europe/Berlin) says one is due. Without a + * configured path it succeeds without writing; a failed backup fails the + * run, which notifies the holders of backup:configure. + */ +@Injectable() +export class AppBackupJob implements Job { + readonly name: JobName = 'app-backup'; + + constructor(private readonly scheduler: BackupSchedulerService) {} + + run(): Promise { + return this.scheduler.runIfDue(); + } +} diff --git a/ApiCore-Backend/src/jobs/cleanup.job.spec.ts b/ApiCore-Backend/src/jobs/cleanup.job.spec.ts new file mode 100644 index 0000000..6ace8fc --- /dev/null +++ b/ApiCore-Backend/src/jobs/cleanup.job.spec.ts @@ -0,0 +1,140 @@ +import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobRunService } from '../system/job-run.service'; +import { + CleanupJob, + DOMAIN_EVENT_RETENTION_DAYS, + FAILED_RETENTION_DAYS, + JOB_RUN_RETENTION_DAYS, + JOB_RUN_STALE_HOURS, + WEBHOOK_DELIVERY_RETENTION_DAYS, +} from './cleanup.job'; + +type RawCall = [readonly string[], ...unknown[]]; + +describe('CleanupJob', () => { + let executeRaw: jest.Mock, RawCall>; + let idempotencyCleanup: { removeExpired: jest.Mock }; + let jobRuns: { abortStale: jest.Mock }; + let job: CleanupJob; + + const statements = (): { sql: string; values: unknown[] }[] => + executeRaw.mock.calls.map(([strings, ...values]) => ({ + sql: strings.join('?').replace(/\s+/g, ' '), + values, + })); + const statementOn = (table: string, status?: string) => { + const found = statements().filter( + ({ sql }) => + sql.includes(`DELETE FROM "${table}"`) && + (status === undefined || sql.includes(`"status" = '${status}'`)), + ); + expect(found).toHaveLength(1); + return found[0]; + }; + + beforeEach(() => { + let count = 10; + executeRaw = jest.fn, RawCall>(() => + Promise.resolve(count++), + ); + idempotencyCleanup = { + removeExpired: jest + .fn() + .mockResolvedValue({ idempotencyRecords: 2, consumedTokens: 3 }), + }; + jobRuns = { abortStale: jest.fn().mockResolvedValue(1) }; + job = new CleanupJob( + { $executeRaw: executeRaw } as unknown as PrismaService, + idempotencyCleanup as unknown as IdempotencyCleanupService, + jobRuns as unknown as JobRunService, + ); + }); + + it('keeps the retention periods of the backbone spec', () => { + expect(DOMAIN_EVENT_RETENTION_DAYS).toBe(30); + expect(WEBHOOK_DELIVERY_RETENTION_DAYS).toBe(30); + expect(FAILED_RETENTION_DAYS).toBe(90); + expect(JOB_RUN_RETENTION_DAYS).toBe(90); + expect(JOB_RUN_STALE_HOURS).toBe(6); + }); + + it('cleanup removes only expired rows and reports counts', async () => { + const result = await job.run(); + + expect(job.name).toBe('cleanup'); + expect(idempotencyCleanup.removeExpired).toHaveBeenCalledTimes(1); + expect(jobRuns.abortStale).toHaveBeenCalledWith(JOB_RUN_STALE_HOURS); + // One count per statement, in the order the statements ran. + expect(result).toEqual({ + idempotencyRecords: 2, + consumedTokens: 3, + throttlerHits: 10, + 'domainEvents.done': 11, + 'domainEvents.failed': 12, + 'webhookDeliveries.delivered': 13, + 'webhookDeliveries.failed': 14, + 'jobRuns.aborted': 1, + 'jobRuns.deleted': 15, + }); + }); + + // An expired window can still carry an active block (blockDuration + // outlasts the window); deleting it would lift the block early. + it('deletes a throttler counter only when neither its window nor a block is active', async () => { + await job.run(); + + const { sql, values } = statementOn('ThrottlerHit'); + expect(sql).toContain('"expiresAt" < now()'); + expect(sql).toContain('("blockedUntil" IS NULL OR "blockedUntil" < now())'); + expect(values).toEqual([]); + }); + + it('keeps finished outbox entries 30 days and failed ones 90 days, by status', async () => { + await job.run(); + + expect(statementOn('DomainEvent', 'DONE').values).toEqual([ + DOMAIN_EVENT_RETENTION_DAYS, + ]); + expect(statementOn('DomainEvent', 'FAILED').values).toEqual([ + FAILED_RETENTION_DAYS, + ]); + expect(statementOn('WebhookDelivery', 'DELIVERED').values).toEqual([ + WEBHOOK_DELIVERY_RETENTION_DAYS, + ]); + expect(statementOn('WebhookDelivery', 'FAILED').values).toEqual([ + FAILED_RETENTION_DAYS, + ]); + expect(statementOn('DomainEvent', 'DONE').sql).toContain( + '"occurredAt" < now() - (? * interval \'1 day\')', + ); + expect(statementOn('WebhookDelivery', 'FAILED').sql).toContain( + '"createdAt" < now() - (? * interval \'1 day\')', + ); + // PENDING and PROCESSING entries are never touched, however old. + for (const { sql } of statements()) { + expect(sql).not.toMatch(/PENDING|PROCESSING/); + } + }); + + it('deletes job runs after 90 days, only after stale runs were aborted', async () => { + await job.run(); + + const { sql, values } = statementOn('JobRun'); + expect(sql).toContain('"startedAt" < now() - (? * interval \'1 day\')'); + expect(values).toEqual([JOB_RUN_RETENTION_DAYS]); + const deleteOrder = executeRaw.mock.invocationCallOrder.at(-1) ?? 0; + expect(jobRuns.abortStale.mock.invocationCallOrder[0]).toBeLessThan( + deleteOrder, + ); + }); + + it('compares only against the database clock', async () => { + await job.run(); + + for (const { sql, values } of statements()) { + expect(sql).toContain('now()'); + expect(values.some((value) => value instanceof Date)).toBe(false); + } + }); +}); diff --git a/ApiCore-Backend/src/jobs/cleanup.job.ts b/ApiCore-Backend/src/jobs/cleanup.job.ts new file mode 100644 index 0000000..089d2d4 --- /dev/null +++ b/ApiCore-Backend/src/jobs/cleanup.job.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; +import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobRunService } from '../system/job-run.service'; +import { Job, JobResult } from './job'; +import { JobName } from './job-names'; + +// DONE events must outlive the catch-up window of the daily evaluation +// (7 days): a deleted event frees its dedupeKey, and an occurrence still in +// the window would be recorded a second time. +export const DOMAIN_EVENT_RETENTION_DAYS = 30; +export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30; +/** FAILED events and deliveries stay longer, for diagnosis and retry. */ +export const FAILED_RETENTION_DAYS = 90; +export const JOB_RUN_RETENTION_DAYS = 90; +/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */ +export const JOB_RUN_STALE_HOURS = 6; + +/** + * Daily housekeeping (CronJob 04:00). Every age is compared against now(), + * the database clock. PENDING and PROCESSING entries are never removed, + * however old: they are work that has not been done yet. + */ +@Injectable() +export class CleanupJob implements Job { + readonly name: JobName = 'cleanup'; + + constructor( + private readonly prisma: PrismaService, + private readonly idempotencyCleanup: IdempotencyCleanupService, + private readonly jobRuns: JobRunService, + ) {} + + async run(): Promise { + const jobRunsAborted = await this.jobRuns.abortStale(JOB_RUN_STALE_HOURS); + const { idempotencyRecords, consumedTokens } = + await this.idempotencyCleanup.removeExpired(); + // An expired window can still carry an active block (blockDuration + // outlasts the window, e.g. the failed-attempt lock of API keys); + // deleting that row would lift the block early. + const throttlerHits = await this.prisma.$executeRaw` + DELETE FROM "ThrottlerHit" + WHERE "expiresAt" < now() + AND ("blockedUntil" IS NULL OR "blockedUntil" < now())`; + // Deliveries of a deleted event go with it (ON DELETE CASCADE). + const domainEventsDone = await this.prisma.$executeRaw` + DELETE FROM "DomainEvent" + WHERE "status" = 'DONE' + AND "occurredAt" < now() - (${DOMAIN_EVENT_RETENTION_DAYS} * interval '1 day')`; + const domainEventsFailed = await this.prisma.$executeRaw` + DELETE FROM "DomainEvent" + WHERE "status" = 'FAILED' + AND "occurredAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`; + const webhookDeliveriesDelivered = await this.prisma.$executeRaw` + DELETE FROM "WebhookDelivery" + WHERE "status" = 'DELIVERED' + AND "createdAt" < now() - (${WEBHOOK_DELIVERY_RETENTION_DAYS} * interval '1 day')`; + const webhookDeliveriesFailed = await this.prisma.$executeRaw` + DELETE FROM "WebhookDelivery" + WHERE "status" = 'FAILED' + AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`; + // After abortStale: no run this old can still be RUNNING. + const jobRunsDeleted = await this.prisma.$executeRaw` + DELETE FROM "JobRun" + WHERE "startedAt" < now() - (${JOB_RUN_RETENTION_DAYS} * interval '1 day')`; + return { + idempotencyRecords, + consumedTokens, + throttlerHits, + 'domainEvents.done': domainEventsDone, + 'domainEvents.failed': domainEventsFailed, + 'webhookDeliveries.delivered': webhookDeliveriesDelivered, + 'webhookDeliveries.failed': webhookDeliveriesFailed, + 'jobRuns.aborted': jobRunsAborted, + 'jobRuns.deleted': jobRunsDeleted, + }; + } +} diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts new file mode 100644 index 0000000..ef6469e --- /dev/null +++ b/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts @@ -0,0 +1,146 @@ +import { Logger } from '@nestjs/common'; +import { Prisma } from '../../generated/prisma'; +import { AnyNewDomainEvent } from '../domain-events/domain-event-types'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { DailyEvaluationJob } from './daily-evaluation.job'; + +type RawCall = [readonly string[], ...unknown[]]; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +describe('DailyEvaluationJob', () => { + const tx = { tx: true } as unknown as Prisma.TransactionClient; + /** The database clock: a day the JS clock of the test never shows. */ + const dbNow = new Date('2026-06-10T02:30:00.000Z'); + let queryRaw: jest.Mock, RawCall>; + let taskFindMany: jest.Mock; + let transaction: jest.Mock; + let recordAll: jest.Mock< + Promise, + [Prisma.TransactionClient, readonly AnyNewDomainEvent[]] + >; + let job: DailyEvaluationJob; + + beforeEach(() => { + queryRaw = jest.fn, RawCall>(([sql]) => + Promise.resolve( + sql.includes('now()') + ? [{ now: dbNow }] + : [ + { + id: 'treatment-1', + colonyId: 'colony-1', + date: d('2026-05-25'), + endDate: null, + withdrawalPeriodDays: 14, + }, + ], + ), + ); + taskFindMany = jest + .fn() + .mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]); + transaction = jest.fn( + (work: (client: Prisma.TransactionClient) => unknown) => work(tx), + ); + // Like createMany(skipDuplicates) on the unique dedupeKey. + const stored = new Set(); + recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => { + const fresh = events.filter((event) => !stored.has(event.dedupeKey!)); + fresh.forEach((event) => stored.add(event.dedupeKey!)); + return Promise.resolve(fresh.length); + }); + const prisma = { + $queryRaw: queryRaw, + $transaction: transaction, + task: { findMany: taskFindMany }, + queen: { findMany: jest.fn().mockResolvedValue([]) }, + colony: { findMany: jest.fn().mockResolvedValue([]) }, + varroaMeasurement: { groupBy: jest.fn().mockResolvedValue([]) }, + inspection: { groupBy: jest.fn().mockResolvedValue([]) }, + } as unknown as PrismaService; + job = new DailyEvaluationJob(prisma, { + recordAll, + } as unknown as DomainEventsService); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('is the daily-evaluation job', () => { + expect(job.name).toBe('daily-evaluation'); + }); + + it('evaluates the day of the database clock and reports new events per type', async () => { + const result = await job.run(); + + // 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the + // window of 2026-06-10 - not in the one of the test's own clock. + expect(result).toEqual({ + WithdrawalEnded: 1, + TaskDue: 1, + QueenAged: 0, + VarroaMeasurementDue: 0, + InspectionOverdue: 0, + }); + expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()'); + }); + + it('writes each type in its own transaction, with its daily dedupe keys', async () => { + await job.run(); + + // Types without an event open no transaction. + expect(transaction).toHaveBeenCalledTimes(2); + expect(recordAll.mock.calls).toEqual([ + [ + tx, + [ + expect.objectContaining({ + type: 'WithdrawalEnded', + dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08', + }), + ], + ], + [ + tx, + [ + expect.objectContaining({ + type: 'TaskDue', + dedupeKey: 'TaskDue:task-1:2026-06-09', + }), + ], + ], + ]); + }); + + it('a second run on the same day records nothing new', async () => { + await job.run(); + + expect(await job.run()).toEqual({ + WithdrawalEnded: 0, + TaskDue: 0, + QueenAged: 0, + VarroaMeasurementDue: 0, + InspectionOverdue: 0, + }); + }); + + it('a failing rule does not stop the others, but fails the run', async () => { + const logged = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + taskFindMany.mockRejectedValue(new Error('connection reset')); + + await expect(job.evaluate(dbNow)).rejects.toThrow( + 'Daily evaluation failed for: TaskDue', + ); + + expect(recordAll).toHaveBeenCalledWith(tx, [ + expect.objectContaining({ type: 'WithdrawalEnded' }), + ]); + expect(logged).toHaveBeenCalledWith( + 'Rule "TaskDue" failed: connection reset', + expect.any(String), + ); + }); +}); diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.job.ts b/ApiCore-Backend/src/jobs/daily-evaluation.job.ts new file mode 100644 index 0000000..8bdeed6 --- /dev/null +++ b/ApiCore-Backend/src/jobs/daily-evaluation.job.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { loggableError } from '../common/loggable-error'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + catchUpEvents, + DAILY_EVALUATION_RULES, + DailyRule, +} from './daily-evaluation.rules'; +import { Job, JobResult } from './job'; +import { JobName } from './job-names'; + +/** + * Records the time-based domain events (CronJob 04:30): one rule per type, + * see DAILY_EVALUATION_RULES. An occurrence is recorded on every run within + * CATCH_UP_DAYS after its day under the same daily dedupe key, so a missed + * run is caught up and a repeated one adds nothing. The result counts the + * events that were new, per type. + */ +@Injectable() +export class DailyEvaluationJob implements Job { + readonly name: JobName = 'daily-evaluation'; + private readonly logger = new Logger(DailyEvaluationJob.name); + + constructor( + private readonly prisma: PrismaService, + private readonly events: DomainEventsService, + ) {} + + /** Evaluates the current day of the database clock. */ + async run(): Promise { + const [{ now }] = await this.prisma.$queryRaw< + { now: Date }[] + >`SELECT now() AS "now"`; + return this.evaluate(now); + } + + /** + * Runs every rule for `today`. A failing rule is logged and does not stop + * the others - their events are committed - but fails the run, so it + * shows on the System page; the next run catches up. + */ + async evaluate(today: Date): Promise { + const counts: [string, number][] = []; + const failed: string[] = []; + for (const rule of DAILY_EVALUATION_RULES) { + try { + counts.push([rule.type, await this.evaluateRule(rule, today)]); + } catch (error: unknown) { + const { message, stack } = loggableError(error); + this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack); + failed.push(rule.type); + } + } + if (failed.length > 0) { + throw new Error(`Daily evaluation failed for: ${failed.join(', ')}`); + } + return Object.fromEntries(counts); + } + + /** One transaction per type; returns how many events were new. */ + private async evaluateRule(rule: DailyRule, today: Date): Promise { + const events = catchUpEvents( + await rule.occurrences(this.prisma, today), + today, + ); + if (events.length === 0) { + return 0; + } + return this.prisma.$transaction((tx) => this.events.recordAll(tx, events)); + } +} diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts new file mode 100644 index 0000000..9d17967 --- /dev/null +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts @@ -0,0 +1,447 @@ +import { PrismaService } from '../prisma/prisma.service'; +import { + ACTIVE_SEASON_MONTHS, + CATCH_UP_DAYS, + catchUpEvents, + DAILY_EVALUATION_RULES, + DailyEvent, + inCatchUpWindow, + INSPECTION_INTERVAL_DAYS, + latestCycleOccurrence, + QUEEN_AGED_MIN_YEARS, + VARROA_MEASUREMENT_INTERVAL_DAYS, + withdrawalEndDay, +} from './daily-evaluation.rules'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Midnight UTC of a 'YYYY-MM-DD' day. */ +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); +/** When the CronJob runs on `day` (04:30 Europe/Berlin). */ +const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`); + +/** Every day from `from` to `to`, both included. */ +function daysBetween(from: string, to: string): string[] { + const days: string[] = []; + for (let t = d(from).getTime(); t <= d(to).getTime(); t += DAY_MS) { + days.push(new Date(t).toISOString().slice(0, 10)); + } + return days; +} + +interface FakeRows { + treatments?: { + id: string; + colonyId: string; + date: Date; + endDate: Date | null; + withdrawalPeriodDays: number | null; + }[]; + tasks?: { id: string; dueDate: Date | null }[]; + queens?: { + id: string; + hatchYear: number | null; + colony: { id: string } | null; + }[]; + colonies?: { id: string; createdAt: Date }[]; + /** The latest varroa measurement per colony. */ + varroa?: { colonyId: string; date: Date }[]; + /** The latest inspection per colony. */ + inspections?: { colonyId: string; date: Date }[]; +} + +interface FakeDb { + db: PrismaService; + queryRaw: jest.Mock; + taskFindMany: jest.Mock; + queenFindMany: jest.Mock; + colonyFindMany: jest.Mock; +} + +/** + * Returns the given rows whatever the query filters - the rules decide by + * day themselves, the database filters only narrow the candidates. + */ +function fakeDb(rows: FakeRows): FakeDb { + const latest = (records: { colonyId: string; date: Date }[] = []) => + records.map(({ colonyId, date }) => ({ colonyId, _max: { date } })); + const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []); + const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []); + const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []); + const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []); + const db = { + $queryRaw: queryRaw, + task: { findMany: taskFindMany }, + queen: { findMany: queenFindMany }, + colony: { findMany: colonyFindMany }, + varroaMeasurement: { + groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)), + }, + inspection: { + groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)), + }, + } as unknown as PrismaService; + return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany }; +} + +/** One job run on `day`: every rule's events in the catch-up window. */ +async function eventsOn(db: PrismaService, day: string): Promise { + const events: DailyEvent[] = []; + for (const rule of DAILY_EVALUATION_RULES) { + events.push(...catchUpEvents(await rule.occurrences(db, at(day)), at(day))); + } + return events; +} + +/** + * Runs the evaluation on each of `days` into a store keyed like the unique + * DomainEvent.dedupeKey: an event whose key exists is skipped, as + * recordAll's createMany(skipDuplicates) does. + */ +async function runDaily( + db: PrismaService, + days: readonly string[], + store = new Map(), +): Promise> { + for (const day of days) { + for (const event of await eventsOn(db, day)) { + if (!store.has(event.dedupeKey)) { + store.set(event.dedupeKey, event); + } + } + } + return store; +} + +describe('daily evaluation rules', () => { + it('keeps the values of the backbone plan', () => { + expect(CATCH_UP_DAYS).toBe(7); + expect(QUEEN_AGED_MIN_YEARS).toBe(2); + expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28); + expect(INSPECTION_INTERVAL_DAYS).toBe(21); + expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]); + }); + + it('runs one rule per time-based event type', () => { + expect(DAILY_EVALUATION_RULES.map((rule) => rule.type)).toEqual([ + 'WithdrawalEnded', + 'TaskDue', + 'QueenAged', + 'VarroaMeasurementDue', + 'InspectionOverdue', + ]); + }); + + describe('withdrawalEndDay', () => { + it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () => + expect( + withdrawalEndDay({ + date: d('2026-09-28'), + endDate: d('2026-10-01'), + withdrawalPeriodDays: 14, + }), + ).toBe('2026-10-15')); + + it('counts from the treatment day when the treatment has no end date', () => + expect( + withdrawalEndDay({ + date: d('2026-09-28'), + endDate: null, + withdrawalPeriodDays: 14, + }), + ).toBe('2026-10-12')); + + it('ends on the treatment day itself without a waiting time and ignores the time of day', () => + expect( + withdrawalEndDay({ + date: new Date('2026-09-28T22:15:00.000Z'), + endDate: null, + withdrawalPeriodDays: 0, + }), + ).toBe('2026-09-28')); + + it('has no end when the treatment has no withdrawal period', () => + expect( + withdrawalEndDay({ + date: d('2026-09-28'), + endDate: d('2026-10-01'), + withdrawalPeriodDays: null, + }), + ).toBeNull()); + }); + + describe('latestCycleOccurrence', () => { + it('is the latest base + k × interval up to today, with k ≥ 1', () => { + expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe( + '2026-06-12', + ); + expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe( + '2026-05-22', + ); + }); + + it('is null before the first full interval and for a base in the future', () => { + expect( + latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')), + ).toBeNull(); + expect( + latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')), + ).toBeNull(); + expect( + latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')), + ).toBeNull(); + }); + + it('counts whole UTC days, not hours', () => + expect( + latestCycleOccurrence( + new Date('2026-05-01T20:00:00.000Z'), + 21, + at('2026-05-22'), + ), + ).toBe('2026-05-22')); + + it('refuses an interval that is not a positive whole number of days', () => { + expect(() => + latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')), + ).toThrow(RangeError); + expect(() => + latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')), + ).toThrow(RangeError); + }); + }); + + describe('inCatchUpWindow', () => { + it('spans today and the 7 days before it', () => { + const today = at('2026-06-10'); + expect(inCatchUpWindow('2026-06-03', today)).toBe(true); + expect(inCatchUpWindow('2026-06-07', today)).toBe(true); + expect(inCatchUpWindow('2026-06-10', today)).toBe(true); + expect(inCatchUpWindow('2026-06-02', today)).toBe(false); + expect(inCatchUpWindow('2026-06-11', today)).toBe(false); + }); + + it('works across a year boundary', () => { + expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true); + expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false); + }); + }); + + it('builds each event from its rule: aggregate, payload and daily dedupe key', async () => { + const { db } = fakeDb({ + treatments: [ + { + id: 'treatment-1', + colonyId: 'colony-1', + date: d('2026-05-15'), + endDate: d('2026-05-20'), + withdrawalPeriodDays: 5, + }, + ], + tasks: [{ id: 'task-1', dueDate: new Date('2026-05-27T09:00:00Z') }], + colonies: [ + // Measured and inspected: the intervals count from the records. + { id: 'colony-1', createdAt: d('2026-03-01') }, + // Never measured or inspected: they count from its creation. + { id: 'colony-2', createdAt: d('2026-04-12') }, + ], + varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }], + inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }], + }); + + expect(await eventsOn(db, '2026-05-29')).toEqual([ + { + type: 'WithdrawalEnded', + aggregateType: 'Treatment', + aggregateId: 'treatment-1', + payload: { colonyId: 'colony-1', endedOn: '2026-05-25' }, + dedupeKey: 'WithdrawalEnded:treatment-1:2026-05-25', + }, + { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: 'task-1', + payload: { dueOn: '2026-05-27' }, + dedupeKey: 'TaskDue:task-1:2026-05-27', + }, + // colony-2: 2026-04-12 + 28 = 2026-05-10, outside the window. + { + type: 'VarroaMeasurementDue', + aggregateType: 'Colony', + aggregateId: 'colony-1', + payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' }, + dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29', + }, + // colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due. + { + type: 'InspectionOverdue', + aggregateType: 'Colony', + aggregateId: 'colony-2', + payload: { dueOn: '2026-05-24', lastInspectedOn: null }, + dedupeKey: 'InspectionOverdue:colony-2:2026-05-24', + }, + ]); + }); + + it('catches up occurrences of the last 7 days exactly once', async () => { + // The job last ran on 2026-06-01 and is back on 2026-06-10 (CronJob + // down for a week). Then it runs daily again. + const { db } = fakeDb({ + treatments: [ + // Ends 2026-06-02: 8 days before the comeback, outside the window. + { + id: 'missed', + colonyId: 'colony-1', + date: d('2026-05-19'), + endDate: null, + withdrawalPeriodDays: 14, + }, + // Ends 2026-06-03: 7 days before the comeback, caught up. + { + id: 'caught-up', + colonyId: 'colony-1', + date: d('2026-05-20'), + endDate: null, + withdrawalPeriodDays: 14, + }, + ], + tasks: [ + { id: 'due-while-down', dueDate: d('2026-06-06') }, + { id: 'due-later', dueDate: d('2026-06-14') }, + ], + }); + + const store = await runDaily(db, ['2026-06-01']); + expect([...store.keys()]).toEqual([]); + + const comeback = await eventsOn(db, '2026-06-10'); + expect(comeback.map((event) => event.dedupeKey)).toEqual([ + 'WithdrawalEnded:caught-up:2026-06-03', + 'TaskDue:due-while-down:2026-06-06', + ]); + + // Every later run within the window repeats the same keys, so the + // unique dedupeKey keeps one event per occurrence. + await runDaily(db, daysBetween('2026-06-10', '2026-06-30'), store); + expect([...store.keys()].sort()).toEqual( + [ + 'TaskDue:due-later:2026-06-14', + 'TaskDue:due-while-down:2026-06-06', + 'WithdrawalEnded:caught-up:2026-06-03', + ].sort(), + ); + // Once the window has passed, the occurrence is no longer produced. + expect(await eventsOn(db, '2026-06-22')).toEqual([]); + }); + + it('emits no inspection or varroa event outside March–October', async () => { + // Never measured or inspected: due every 28 resp. 21 days from 2026-01-10. + const { db } = fakeDb({ + colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }], + }); + + const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31')); + const dueDays = (type: string) => + [...store.values()] + .filter((event) => event.type === type) + .map((event) => event.dedupeKey.split(':')[2]); + + // Not 2026-02-07, 2026-11-14 or 2026-12-12. + expect(dueDays('VarroaMeasurementDue')).toEqual([ + '2026-03-07', + '2026-04-04', + '2026-05-02', + '2026-05-30', + '2026-06-27', + '2026-07-25', + '2026-08-22', + '2026-09-19', + '2026-10-17', + ]); + // Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12. + expect(dueDays('InspectionOverdue')).toEqual([ + '2026-03-14', + '2026-04-04', + '2026-04-25', + '2026-05-16', + '2026-06-06', + '2026-06-27', + '2026-07-18', + '2026-08-08', + '2026-08-29', + '2026-09-19', + '2026-10-10', + '2026-10-31', + ]); + }); + + it('reports a queen once a year on January 1 from her second year after hatching', async () => { + const { db } = fakeDb({ + queens: [ + { id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } }, + { id: 'queen-2027', hatchYear: 2027, colony: null }, + { id: 'queen-unknown', hatchYear: null, colony: null }, + ], + }); + + const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20')); + + expect([...store.values()]).toEqual([ + { + type: 'QueenAged', + aggregateType: 'Queen', + aggregateId: 'queen-2026', + payload: { hatchYear: 2026, colonyId: 'colony-1' }, + dedupeKey: 'QueenAged:queen-2026:2028-01-01', + }, + expect.objectContaining({ + dedupeKey: 'QueenAged:queen-2026:2029-01-01', + }) as DailyEvent, + { + type: 'QueenAged', + aggregateType: 'Queen', + aggregateId: 'queen-2027', + payload: { hatchYear: 2027, colonyId: null }, + dedupeKey: 'QueenAged:queen-2027:2029-01-01', + }, + ]); + }); + + it('asks the database only for the candidates of the catch-up window', async () => { + const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } = + fakeDb({}); + + await eventsOn(db, '2026-06-10'); + + expect(taskFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + status: { in: ['OPEN', 'IN_PROGRESS'] }, + dueDate: { gte: d('2026-06-03'), lt: d('2026-06-11') }, + }, + }), + ); + expect(colonyFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] }, + }, + }), + ); + const [strings, ...values] = queryRaw.mock.calls[0] as [ + readonly string[], + ...unknown[], + ]; + expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL'); + expect(values).toEqual(['2026-06-03', '2026-06-10']); + // QueenAged only looks on January 1-8. + expect(queenFindMany).not.toHaveBeenCalled(); + + await eventsOn(db, '2027-01-08'); + expect(queenFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { status: 'ACTIVE', hatchYear: { lte: 2025 } }, + }), + ); + }); +}); diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts new file mode 100644 index 0000000..22b98a1 --- /dev/null +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts @@ -0,0 +1,337 @@ +import { TaskStatus, Treatment } from '../../generated/prisma'; +import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; +import { + AnyNewDomainEvent, + dailyDedupeKey, + utcDay, +} from '../domain-events/domain-event-types'; +import { PrismaService } from '../prisma/prisma.service'; + +/** How many days back a run still records an occurrence it missed. */ +export const CATCH_UP_DAYS = 7; +/** A queen counts as aged from this many years after her hatch year. */ +export const QUEEN_AGED_MIN_YEARS = 2; +export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28; +export const INSPECTION_INTERVAL_DAYS = 21; +/** March to October: varroa and inspection reminders only in these months. */ +export const ACTIVE_SEASON_MONTHS: readonly number[] = [ + 3, 4, 5, 6, 7, 8, 9, 10, +]; + +const DAY_MS = 24 * 60 * 60 * 1000; +const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS']; + +/** The event types the daily evaluation records. */ +export type DailyEventType = + | 'WithdrawalEnded' + | 'TaskDue' + | 'QueenAged' + | 'VarroaMeasurementDue' + | 'InspectionOverdue'; + +/** An event as the job records it: always with its daily dedupe key. */ +export type DailyEvent = + AnyNewDomainEvent & { dedupeKey: string }; + +/** One occurrence of a rule: the event and the UTC day it happens on. */ +export interface DailyOccurrence { + day: string; + event: AnyNewDomainEvent; +} + +/** + * One row of the daily evaluation. `occurrences` reads the candidates and + * names the day of each; the job keeps those in the catch-up window + * (catchUpEvents) and records them under their daily dedupe key. A rule + * may narrow its query to the window, but must not rely on that: the day + * it reports decides. + */ +export interface DailyRule { + readonly type: T; + occurrences(db: PrismaService, today: Date): Promise[]>; +} + +/** Midnight UTC of the UTC day of `date`. */ +function dayStart(date: Date): Date { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); +} + +/** The UTC day `days` days after the UTC day of `date`. */ +function dayAfter(date: Date, days: number): Date { + return new Date(dayStart(date).getTime() + days * DAY_MS); +} + +/** The UTC day the withdrawal period ends on, null without one. */ +export function withdrawalEndDay( + t: Pick, +): string | null { + if (t.withdrawalPeriodDays === null) { + return null; + } + return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays)); +} + +/** + * The latest day base + k × intervalDays (k ≥ 1) that is not after today, + * in whole UTC days; null if not even the first interval has passed. + */ +export function latestCycleOccurrence( + base: Date, + intervalDays: number, + today: Date, +): string | null { + if (!Number.isInteger(intervalDays) || intervalDays < 1) { + throw new RangeError( + `Interval must be a positive whole number of days, got ${intervalDays}`, + ); + } + const elapsedDays = Math.round( + (dayStart(today).getTime() - dayStart(base).getTime()) / DAY_MS, + ); + const cycles = Math.floor(elapsedDays / intervalDays); + if (cycles < 1) { + return null; + } + return utcDay(dayAfter(base, cycles * intervalDays)); +} + +/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */ +export function inCatchUpWindow(day: string, today: Date): boolean { + return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today); +} + +function inActiveSeason(day: string): boolean { + return ACTIVE_SEASON_MONTHS.includes(Number(day.slice(5, 7))); +} + +/** + * The occurrences in the catch-up window, as events with their daily dedupe + * key: every run within the window repeats the same key, so the unique + * DomainEvent.dedupeKey keeps one event per occurrence. + */ +export function catchUpEvents( + occurrences: readonly DailyOccurrence[], + today: Date, +): DailyEvent[] { + return occurrences + .filter(({ day }) => inCatchUpWindow(day, today)) + .map(({ day, event }) => ({ + ...event, + dedupeKey: dailyDedupeKey(event.type, event.aggregateId, day), + })); +} + +interface TreatmentRow { + id: string; + colonyId: string; + date: Date; + endDate: Date | null; + withdrawalPeriodDays: number | null; +} + +/** Treatment: the day after which honey may be harvested again. */ +const withdrawalEnded: DailyRule<'WithdrawalEnded'> = { + type: 'WithdrawalEnded', + async occurrences(db, today) { + // Prisma cannot compare column arithmetic; the days are compared as + // dates, so the session time zone plays no part. + const treatments = await db.$queryRaw` + SELECT "id", "colonyId", "date", "endDate", "withdrawalPeriodDays" + FROM "Treatment" + WHERE "withdrawalPeriodDays" IS NOT NULL + AND (COALESCE("endDate", "date") + "withdrawalPeriodDays" * interval '1 day')::date + BETWEEN ${utcDay(dayAfter(today, -CATCH_UP_DAYS))}::date AND ${utcDay(today)}::date`; + return treatments.flatMap((treatment) => { + const day = withdrawalEndDay(treatment); + if (day === null) { + return []; + } + return [ + { + day, + event: { + type: 'WithdrawalEnded', + aggregateType: 'Treatment', + aggregateId: treatment.id, + payload: { colonyId: treatment.colonyId, endedOn: day }, + }, + }, + ]; + }); + }, +}; + +/** Open task: its due day. */ +const taskDue: DailyRule<'TaskDue'> = { + type: 'TaskDue', + async occurrences(db, today) { + const tasks = await db.task.findMany({ + where: { + status: { in: OPEN_TASK_STATUSES }, + dueDate: { + gte: dayAfter(today, -CATCH_UP_DAYS), + lt: dayAfter(today, 1), + }, + }, + select: { id: true, dueDate: true }, + }); + return tasks.flatMap(({ id, dueDate }) => { + if (dueDate === null) { + return []; + } + const day = utcDay(dueDate); + return [ + { + day, + event: { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: id, + payload: { dueOn: day }, + }, + }, + ]; + }); + }, +}; + +/** Active queen from her second year after hatching: once a year, on January 1. */ +const queenAged: DailyRule<'QueenAged'> = { + type: 'QueenAged', + async occurrences(db, today) { + const year = today.getUTCFullYear(); + const day = `${year}-01-01`; + if (!inCatchUpWindow(day, today)) { + return []; + } + const queens = await db.queen.findMany({ + where: { + status: 'ACTIVE', + hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS }, + }, + select: { id: true, hatchYear: true, colony: { select: { id: true } } }, + }); + return queens.flatMap(({ id, hatchYear, colony }) => { + if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) { + return []; + } + return [ + { + day, + event: { + type: 'QueenAged', + aggregateType: 'Queen', + aggregateId: id, + payload: { hatchYear, colonyId: colony?.id ?? null }, + }, + }, + ]; + }); + }, +}; + +interface CycleDue { + colonyId: string; + day: string; + /** The UTC day of the last record, null if the colony has none. */ + lastOn: string | null; +} + +/** + * Living colonies whose cycle comes due in the active season: counted from + * the last record, or from the colony's creation if it has none. + */ +async function colonyCyclesDue( + db: PrismaService, + lastRecordOn: ReadonlyMap, + intervalDays: number, + today: Date, +): Promise { + const colonies = await db.colony.findMany({ + where: LIVING_COLONY_WHERE, + select: { id: true, createdAt: true }, + }); + return colonies.flatMap(({ id, createdAt }) => { + const last = lastRecordOn.get(id) ?? null; + const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today); + if (day === null || !inActiveSeason(day)) { + return []; + } + return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }]; + }); +} + +function latestByColony( + rows: { colonyId: string; _max: { date: Date | null } }[], +): ReadonlyMap { + return new Map( + rows.flatMap(({ colonyId, _max }) => + _max.date === null ? [] : [[colonyId, _max.date] as const], + ), + ); +} + +/** Living colony: varroa measurement due again in the active season. */ +const varroaMeasurementDue: DailyRule<'VarroaMeasurementDue'> = { + type: 'VarroaMeasurementDue', + async occurrences(db, today) { + const latest = await db.varroaMeasurement.groupBy({ + by: ['colonyId'], + where: { colony: LIVING_COLONY_WHERE }, + _max: { date: true }, + }); + const due = await colonyCyclesDue( + db, + latestByColony(latest), + VARROA_MEASUREMENT_INTERVAL_DAYS, + today, + ); + return due.map(({ colonyId, day, lastOn }) => ({ + day, + event: { + type: 'VarroaMeasurementDue', + aggregateType: 'Colony', + aggregateId: colonyId, + payload: { dueOn: day, lastMeasuredOn: lastOn }, + }, + })); + }, +}; + +/** Living colony: inspection overdue in the active season. */ +const inspectionOverdue: DailyRule<'InspectionOverdue'> = { + type: 'InspectionOverdue', + async occurrences(db, today) { + const latest = await db.inspection.groupBy({ + by: ['colonyId'], + where: { colony: LIVING_COLONY_WHERE }, + _max: { date: true }, + }); + const due = await colonyCyclesDue( + db, + latestByColony(latest), + INSPECTION_INTERVAL_DAYS, + today, + ); + return due.map(({ colonyId, day, lastOn }) => ({ + day, + event: { + type: 'InspectionOverdue', + aggregateType: 'Colony', + aggregateId: colonyId, + payload: { dueOn: day, lastInspectedOn: lastOn }, + }, + })); + }, +}; + +/** The rules of the daily evaluation, one row per time-based event type. */ +export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [ + withdrawalEnded, + taskDue, + queenAged, + varroaMeasurementDue, + inspectionOverdue, +]; diff --git a/ApiCore-Backend/src/jobs/job-names.spec.ts b/ApiCore-Backend/src/jobs/job-names.spec.ts new file mode 100644 index 0000000..662e04a --- /dev/null +++ b/ApiCore-Backend/src/jobs/job-names.spec.ts @@ -0,0 +1,39 @@ +import { PERMISSION_CATALOG } from '../auth/permissions/permission-catalog'; +import { + JOB_ALLOWED_ARGS, + JOB_FAILURE_PERMISSION, + JOB_NAMES, +} from './job-names'; + +// Failure permissions that join the catalog with the System page. The +// change that adds them to the catalog must empty this list: from then on +// the second test fails until it is empty. +const PENDING_FAILURE_PERMISSIONS: readonly string[] = []; + +const catalogKeys = new Set(PERMISSION_CATALOG.map((e) => e.key)); + +describe('job names', () => { + it('every job failure permission is in the permission catalog, except the pending ones', () => { + const unknown = Object.values(JOB_FAILURE_PERMISSION).filter( + (key) => + !catalogKeys.has(key) && !PENDING_FAILURE_PERMISSIONS.includes(key), + ); + + expect(unknown).toEqual([]); + }); + + it('the pending list holds only permissions still missing from the catalog', () => { + expect( + PENDING_FAILURE_PERMISSIONS.filter((key) => catalogKeys.has(key)), + ).toEqual([]); + }); + + it('only rekey accepts an argument, and only --dry-run', () => { + expect(Object.keys(JOB_ALLOWED_ARGS)).toEqual([...JOB_NAMES]); + for (const name of JOB_NAMES) { + expect(JOB_ALLOWED_ARGS[name]).toEqual( + name === 'rekey' ? ['--dry-run'] : [], + ); + } + }); +}); diff --git a/ApiCore-Backend/src/jobs/job-names.ts b/ApiCore-Backend/src/jobs/job-names.ts new file mode 100644 index 0000000..87840f6 --- /dev/null +++ b/ApiCore-Backend/src/jobs/job-names.ts @@ -0,0 +1,52 @@ +import type { PermissionKey } from '../auth/permissions/permission-catalog'; + +/** The jobs of `node dist/cli.js [args]`, one CronJob each (deploy/k8s). */ +export const JOB_NAMES = [ + 'daily-evaluation', + 'task-templates', + 'cleanup', + 'app-backup', + 'rekey', +] as const; + +export type JobName = (typeof JOB_NAMES)[number]; + +/** How the UI and the failure notification name a job. */ +export const JOB_LABELS_DE: Record = { + 'daily-evaluation': 'Tägliche Auswertung', + 'task-templates': 'Aufgaben aus Vorlagen', + cleanup: 'Aufräumen', + 'app-backup': 'App-Backup', + rekey: 'Schlüssel umstellen', +}; + +/** + * Who is told about a failed run: the holders of the permission that + * manages what the job does. system:manage joins the permission catalog + * together with the System page; until then a failed cleanup or rekey run + * notifies nobody and shows in JobRun and the pod log only. + */ +export const JOB_FAILURE_PERMISSION: Record = { + 'daily-evaluation': 'automation:update', + 'task-templates': 'automation:update', + cleanup: 'system:manage', + 'app-backup': 'backup:configure', + rekey: 'system:manage', +}; + +/** + * The arguments each job accepts after its name. Anything else is a wrong + * call (exit code 2) and starts nothing: a typo such as `rekey --dry-rn` + * must not run the real job. + */ +export const JOB_ALLOWED_ARGS: Record = { + 'daily-evaluation': [], + 'task-templates': [], + cleanup: [], + 'app-backup': [], + rekey: ['--dry-run'], +}; + +export function isJobName(value: string): value is JobName { + return (JOB_NAMES as readonly string[]).includes(value); +} diff --git a/ApiCore-Backend/src/jobs/job-registry.spec.ts b/ApiCore-Backend/src/jobs/job-registry.spec.ts new file mode 100644 index 0000000..9492f6e --- /dev/null +++ b/ApiCore-Backend/src/jobs/job-registry.spec.ts @@ -0,0 +1,52 @@ +import { Test } from '@nestjs/testing'; +import { Job, JobResult } from './job'; +import { JOB_NAMES, JobName } from './job-names'; +import { JobRegistry, JOBS } from './job-registry'; +import { JobsModule } from './jobs.module'; + +function stubJob(name: string): Job { + return { + name: name as JobName, + run: (): Promise => Promise.resolve({}), + }; +} + +const allStubs = (): Job[] => JOB_NAMES.map(stubJob); + +describe('JobRegistry', () => { + // Compiled only - no init, so nothing connects to the database. + it('the registry covers every JOB_NAME exactly once', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [JobsModule], + }).compile(); + try { + const registry = moduleRef.get(JobRegistry); + for (const name of JOB_NAMES) { + expect(registry.get(name).name).toBe(name); + } + expect(moduleRef.get(JOBS)).toHaveLength(JOB_NAMES.length); + } finally { + await moduleRef.close(); + } + }); + + it('refuses a job registered twice', () => { + expect(() => new JobRegistry([...allStubs(), stubJob('cleanup')])).toThrow( + 'Job "cleanup" is registered twice', + ); + }); + + it('refuses a registry that misses a job', () => { + const withoutRekey = allStubs().filter((job) => job.name !== 'rekey'); + + expect(() => new JobRegistry(withoutRekey)).toThrow( + 'No job registered for: rekey', + ); + }); + + it('refuses a job whose name is not in JOB_NAMES', () => { + expect(() => new JobRegistry([...allStubs(), stubJob('nope')])).toThrow( + 'Unknown job name "nope"', + ); + }); +}); diff --git a/ApiCore-Backend/src/jobs/job-registry.ts b/ApiCore-Backend/src/jobs/job-registry.ts new file mode 100644 index 0000000..db651e4 --- /dev/null +++ b/ApiCore-Backend/src/jobs/job-registry.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Job } from './job'; +import { isJobName, JOB_NAMES, JobName } from './job-names'; + +/** Injection token of every Job provider of JobsModule. */ +export const JOBS = Symbol('JOBS'); + +function indexJobs(jobs: readonly Job[]): ReadonlyMap { + const byName = new Map(); + for (const job of jobs) { + if (!isJobName(job.name)) { + throw new Error(`Unknown job name "${String(job.name)}"`); + } + if (byName.has(job.name)) { + throw new Error(`Job "${job.name}" is registered twice`); + } + byName.set(job.name, job); + } + const missing = JOB_NAMES.filter((name) => !byName.has(name)); + if (missing.length > 0) { + throw new Error(`No job registered for: ${missing.join(', ')}`); + } + return byName; +} + +/** + * The job behind each JOB_NAME. Checked once when the context is built: a + * name without a job, a job registered twice or a job with a name outside + * JOB_NAMES stops every run instead of failing only the affected CronJob. + */ +@Injectable() +export class JobRegistry { + private readonly jobs: ReadonlyMap; + + constructor(@Inject(JOBS) jobs: readonly Job[]) { + this.jobs = indexJobs(jobs); + } + + get(name: JobName): Job { + const job = this.jobs.get(name); + if (!job) { + // Unreachable: indexJobs requires a job for every JOB_NAME. + throw new Error(`No job registered for: ${name}`); + } + return job; + } +} diff --git a/ApiCore-Backend/src/jobs/job.ts b/ApiCore-Backend/src/jobs/job.ts new file mode 100644 index 0000000..2e90943 --- /dev/null +++ b/ApiCore-Backend/src/jobs/job.ts @@ -0,0 +1,17 @@ +import type { JobName } from './job-names'; + +/** + * What a run reports, stored as JobRun.result and shown on the System page: + * counts, flags and short names - never personal data or secrets. + */ +export type JobResult = Record; + +/** + * One job of dist/cli.js. `args` are the command line arguments after the + * job name (e.g. ['--dry-run']). A job fails its run by throwing; the + * message is stored via loggableError(). + */ +export interface Job { + readonly name: JobName; + run(args: readonly string[]): Promise; +} diff --git a/ApiCore-Backend/src/jobs/jobs.module.ts b/ApiCore-Backend/src/jobs/jobs.module.ts new file mode 100644 index 0000000..60c510b --- /dev/null +++ b/ApiCore-Backend/src/jobs/jobs.module.ts @@ -0,0 +1,73 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AuditModule } from '../audit/audit.module'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { BackupModule } from '../backup/backup.module'; +import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service'; +import { DomainEventsModule } from '../domain-events/domain-events.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { SystemModule } from '../system/system.module'; +import { TasksModule } from '../tasks/tasks.module'; +import { AppBackupJob } from './app-backup.job'; +import { CleanupJob } from './cleanup.job'; +import { DailyEvaluationJob } from './daily-evaluation.job'; +import { Job } from './job'; +import { JobRegistry, JOBS } from './job-registry'; +import { RekeyJob } from './rekey.job'; +import { TaskTemplatesJob } from './task-templates.job'; + +/** + * PermissionResolverService for the job context (BackupService clears its + * cache after a restore, which no job does). Not PermissionsModule: its + * PermissionCatalogSyncService would rewrite the permission catalog on + * every job start. Global like PermissionsModule, so modules that inject + * the resolver load here unchanged. + */ +@Global() +@Module({ + providers: [PermissionResolverService], + exports: [PermissionResolverService], +}) +class JobPermissionsModule {} + +const JOB_CLASSES = [ + DailyEvaluationJob, + TaskTemplatesJob, + CleanupJob, + AppBackupJob, + RekeyJob, +]; + +/** + * Root module of dist/cli.js, started with + * NestFactory.createApplicationContext for exactly one run: the modules + * the jobs need, without the HTTP layer (no guards, interceptors, throttler + * or routes - controllers of the imported modules are never served) and + * without the worker loops. No in-process scheduler: no timer may run + * in a job pod. + */ +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + AuditModule, + NotificationsModule, + DomainEventsModule, + TasksModule, + JobPermissionsModule, + BackupModule, + SystemModule, + ], + providers: [ + ...JOB_CLASSES, + { + provide: JOBS, + useFactory: (...jobs: Job[]): Job[] => jobs, + inject: JOB_CLASSES, + }, + JobRegistry, + IdempotencyCleanupService, + ], +}) +export class JobsModule {} diff --git a/ApiCore-Backend/src/jobs/rekey.job.spec.ts b/ApiCore-Backend/src/jobs/rekey.job.spec.ts new file mode 100644 index 0000000..815fbf9 --- /dev/null +++ b/ApiCore-Backend/src/jobs/rekey.job.spec.ts @@ -0,0 +1,569 @@ +import { Logger } from '@nestjs/common'; +import * as mfaCrypto from '../auth/mfa/mfa-crypto.util'; +import { + decryptMfaSecret, + encryptMfaSecret, +} from '../auth/mfa/mfa-crypto.util'; +import { gcmSeal, legacyJwtKey } from '../common/crypto/key-ring'; +import { PrismaService } from '../prisma/prisma.service'; +import { + openWebhookSecret, + sealWebhookSecret, +} from '../webhooks/webhook-secret'; +import { RekeyJob } from './rekey.job'; + +// Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40 +// (the same bytes as in mfa-crypto.util.spec.ts and webhook-secret.spec.ts). +const LEGACY_MFA = + 'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ=='; +const LEGACY_MFA_PLAINTEXT = 'JBSWY3DPEHPK3PXP'; +const LEGACY_WEBHOOK = + 'enc:v1:MlOPzAuy67RTMMb9.YslrBRO2dTYumby+P28INg==.KFmilRVOsJtQ'; +const LEGACY_WEBHOOK_PLAINTEXT = 'topsecret'; +// From before webhook secrets were encrypted at all. +const PLAIN_WEBHOOK = '3f9c2a7d41e8b6c05a1d9e7f3b2c8a64'; + +interface UserRow { + id: string; + mfaSecret: string | null; +} + +interface SubscriptionRow { + id: string; + secret: string; +} + +interface UpdateResult { + count: number; +} + +interface UserUpdate { + where: { id: string; mfaSecret: string }; + data: { mfaSecret: string }; +} + +interface SubscriptionUpdate { + where: { id: string; secret: string }; + data: { secret: string }; +} + +/** How the code before the key ring sealed: AES-256-GCM under sha256(JWT_ACCESS_SECRET). */ +function legacyCiphertext(plaintext: string): string { + const { iv, tag, ciphertext } = gcmSeal( + legacyJwtKey(), + Buffer.from(plaintext, 'utf8'), + ); + return [iv, tag, ciphertext].map((part) => part.toString('base64')).join('.'); +} + +/** Seals with the key that `_PREVIOUS` names, as if before the rotation. */ +function sealedWithPrevious(variable: string, seal: () => string): string { + const current = process.env[variable]; + process.env[variable] = process.env[`${variable}_PREVIOUS`]; + try { + return seal(); + } finally { + process.env[variable] = current; + } +} + +/** + * The two tables, in memory. updateMany behaves like the SQL compare-and- + * swap: it writes only while id and old value still match. + */ +function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) { + const userRows = new Map(users.map((row) => [row.id, row])); + const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row])); + const user = { + findMany: jest.fn(() => + Promise.resolve( + [...userRows.values()].filter((row) => row.mfaSecret !== null), + ), + ), + updateMany: jest.fn, [UserUpdate]>( + ({ where, data }) => { + const row = userRows.get(where.id); + if (!row || row.mfaSecret !== where.mfaSecret) { + return Promise.resolve({ count: 0 }); + } + userRows.set(where.id, { ...row, mfaSecret: data.mfaSecret }); + return Promise.resolve({ count: 1 }); + }, + ), + }; + const webhookSubscription = { + findMany: jest.fn(() => Promise.resolve([...subscriptionRows.values()])), + updateMany: jest.fn, [SubscriptionUpdate]>( + ({ where, data }) => { + const row = subscriptionRows.get(where.id); + if (!row || row.secret !== where.secret) { + return Promise.resolve({ count: 0 }); + } + subscriptionRows.set(where.id, { ...row, secret: data.secret }); + return Promise.resolve({ count: 1 }); + }, + ), + }; + return { + prisma: { user, webhookSubscription } as unknown as PrismaService, + user, + webhookSubscription, + userRows, + subscriptionRows, + mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '', + webhookSecret: (id: string): string => + subscriptionRows.get(id)?.secret ?? '', + }; +} + +const keyIdOf = (stored: string): string => stored.split(':')[0]; + +describe('RekeyJob', () => { + const originalEnv = { ...process.env }; + let warn: jest.SpyInstance; + let error: jest.SpyInstance; + + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = 'x'.repeat(40); + process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40); + process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40); + process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40); + process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40); + warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + error = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + jest.restoreAllMocks(); + }); + + /** Everything the job wrote to the log, as one string per call. */ + const logged = (): string[] => + [...warn.mock.calls, ...error.mock.calls].map((call) => + call.map(String).join(' '), + ); + + describe('a full set of old and current secrets', () => { + let previousMfa: string; + let currentMfa: string; + let previousWebhook: string; + let currentWebhook: string; + let db: ReturnType; + + beforeEach(() => { + previousMfa = sealedWithPrevious('MFA_ENCRYPTION_KEY', () => + encryptMfaSecret('PREVIOUSKEYMFA22'), + ); + currentMfa = encryptMfaSecret('CURRENTKEYMFA222'); + previousWebhook = sealedWithPrevious('WEBHOOK_ENCRYPTION_KEY', () => + sealWebhookSecret('previous-webhook-secret'), + ); + currentWebhook = sealWebhookSecret('current-webhook-secret'); + db = fakeDb( + [ + { id: 'u1', mfaSecret: LEGACY_MFA }, + { id: 'u2', mfaSecret: previousMfa }, + { id: 'u3', mfaSecret: currentMfa }, + ], + [ + { id: 'w1', secret: LEGACY_WEBHOOK }, + { id: 'w2', secret: PLAIN_WEBHOOK }, + { id: 'w3', secret: previousWebhook }, + { id: 'w4', secret: currentWebhook }, + ], + ); + }); + + const expectedCounts = { + 'mfa.total': 3, + 'mfa.rekeyed': 2, + 'mfa.alreadyCurrent': 1, + 'mfa.plaintextSealed': 0, + 'mfa.changedMeanwhile': 0, + 'mfa.failed': 0, + 'webhook.total': 4, + 'webhook.rekeyed': 2, + 'webhook.alreadyCurrent': 1, + 'webhook.plaintextSealed': 1, + 'webhook.changedMeanwhile': 0, + 'webhook.failed': 0, + }; + + it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => { + const job = new RekeyJob(db.prisma); + + const result = await job.run([]); + + expect(job.name).toBe('rekey'); + expect(result).toEqual({ dryRun: false, ...expectedCounts }); + // Every value is now sealed with the current key ... + for (const id of ['u1', 'u2', 'u3']) { + expect(keyIdOf(db.mfaSecret(id))).toBe(keyIdOf(currentMfa)); + } + for (const id of ['w1', 'w2', 'w3', 'w4']) { + expect(keyIdOf(db.webhookSecret(id))).toBe(keyIdOf(currentWebhook)); + } + // ... a current one was left exactly as it was ... + expect(db.mfaSecret('u3')).toBe(currentMfa); + expect(db.webhookSecret('w4')).toBe(currentWebhook); + expect(db.user.updateMany).toHaveBeenCalledTimes(2); + expect(db.webhookSubscription.updateMany).toHaveBeenCalledTimes(3); + // ... and each write was a compare-and-swap on the value it read. + expect(db.user.updateMany).toHaveBeenCalledWith({ + where: { id: 'u1', mfaSecret: LEGACY_MFA }, + data: { mfaSecret: db.mfaSecret('u1') }, + }); + expect(db.webhookSubscription.updateMany).toHaveBeenCalledWith({ + where: { id: 'w2', secret: PLAIN_WEBHOOK }, + data: { secret: db.webhookSecret('w2') }, + }); + // Only id and secret are read, in a stable order. + expect(db.user.findMany).toHaveBeenCalledWith({ + where: { mfaSecret: { not: null } }, + select: { id: true, mfaSecret: true }, + orderBy: { id: 'asc' }, + }); + expect(db.webhookSubscription.findMany).toHaveBeenCalledWith({ + select: { id: true, secret: true }, + orderBy: { id: 'asc' }, + }); + }); + + it('leaves values that open to the original secrets without the old keys', async () => { + await new RekeyJob(db.prisma).run([]); + // After the run: *_PREVIOUS removed and the JWT secret rotated. + delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS; + delete process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS; + process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); + + expect(decryptMfaSecret(db.mfaSecret('u1'))).toBe(LEGACY_MFA_PLAINTEXT); + expect(decryptMfaSecret(db.mfaSecret('u2'))).toBe('PREVIOUSKEYMFA22'); + expect(decryptMfaSecret(db.mfaSecret('u3'))).toBe('CURRENTKEYMFA222'); + expect(openWebhookSecret(db.webhookSecret('w1'))).toBe( + LEGACY_WEBHOOK_PLAINTEXT, + ); + expect(openWebhookSecret(db.webhookSecret('w2'))).toBe(PLAIN_WEBHOOK); + expect(openWebhookSecret(db.webhookSecret('w3'))).toBe( + 'previous-webhook-secret', + ); + expect(openWebhookSecret(db.webhookSecret('w4'))).toBe( + 'current-webhook-secret', + ); + }); + + it('dry-run counts what a run would change and writes nothing', async () => { + const before = { + users: [...db.userRows.values()], + subscriptions: [...db.subscriptionRows.values()], + }; + + const result = await new RekeyJob(db.prisma).run(['--dry-run']); + + expect(result).toEqual({ dryRun: true, ...expectedCounts }); + expect(db.user.updateMany).not.toHaveBeenCalled(); + expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled(); + expect([...db.userRows.values()]).toEqual(before.users); + expect([...db.subscriptionRows.values()]).toEqual(before.subscriptions); + }); + + it('a second run rekeys nothing', async () => { + const job = new RekeyJob(db.prisma); + await job.run([]); + db.user.updateMany.mockClear(); + db.webhookSubscription.updateMany.mockClear(); + + const result = await job.run([]); + + expect(result).toMatchObject({ + 'mfa.alreadyCurrent': 3, + 'mfa.rekeyed': 0, + 'webhook.alreadyCurrent': 4, + 'webhook.rekeyed': 0, + 'webhook.plaintextSealed': 0, + }); + expect(db.user.updateMany).not.toHaveBeenCalled(); + expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled(); + }); + }); + + it('resumes after an interruption: current rows are skipped, the rest are rekeyed', async () => { + const stored = [ + 'AAAAAAAAAAAAAAAA', + 'BBBBBBBBBBBBBBBB', + 'CCCCCCCCCCCCCCCC', + ].map(legacyCiphertext); + const db = fakeDb( + stored.map((mfaSecret, index) => ({ id: `u${index + 1}`, mfaSecret })), + [], + ); + const job = new RekeyJob(db.prisma); + const updateMany = db.user.updateMany; + + updateMany.mockRejectedValueOnce(new Error('connection reset')); + await expect(job.run([])).rejects.toThrow( + /mfa\.rekeyed=2, .*mfa\.failed=1/, + ); + // The failed row still holds its old value, the others were written. + expect(db.mfaSecret('u1')).toBe(stored[0]); + expect(db.mfaSecret('u2')).not.toBe(stored[1]); + expect(db.mfaSecret('u3')).not.toBe(stored[2]); + expect(logged()).toContainEqual( + expect.stringContaining( + 'User u1: mfa secret not rekeyed: connection reset', + ), + ); + + updateMany.mockClear(); + updateMany.mockResolvedValue({ count: 1 }); + expect(await job.run([])).toMatchObject({ + 'mfa.alreadyCurrent': 2, + 'mfa.rekeyed': 1, + 'mfa.failed': 0, + }); + expect(updateMany).toHaveBeenCalledTimes(1); + expect(updateMany).toHaveBeenCalledWith({ + where: { id: 'u1', mfaSecret: stored[0] }, + data: { mfaSecret: expect.stringMatching(/^k[0-9a-f]{8}:/) as string }, + }); + }); + + it('skips a row changed since it was read and reports it', async () => { + const oldMfa = legacyCiphertext('OLDMFASECRET2222'); + const db = fakeDb( + [{ id: 'u1', mfaSecret: oldMfa }], + [{ id: 'w1', secret: PLAIN_WEBHOOK }], + ); + // Right after the read, the user sets MFA up anew and the + // subscription is deleted. + const reenrolled = encryptMfaSecret('NEWMFASECRET2222'); + db.user.findMany.mockImplementationOnce(() => { + const read = [...db.userRows.values()]; + db.userRows.set('u1', { id: 'u1', mfaSecret: reenrolled }); + return Promise.resolve(read); + }); + db.webhookSubscription.findMany.mockImplementationOnce(() => { + const read = [...db.subscriptionRows.values()]; + db.subscriptionRows.delete('w1'); + return Promise.resolve(read); + }); + + const result = await new RekeyJob(db.prisma).run([]); + + expect(result).toMatchObject({ + 'mfa.total': 1, + 'mfa.rekeyed': 0, + 'mfa.changedMeanwhile': 1, + 'mfa.failed': 0, + 'webhook.total': 1, + 'webhook.plaintextSealed': 0, + 'webhook.changedMeanwhile': 1, + 'webhook.failed': 0, + }); + // The new MFA secret was not overwritten with the old one. + expect(db.mfaSecret('u1')).toBe(reenrolled); + expect(db.subscriptionRows.has('w1')).toBe(false); + expect(logged()).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'User u1: mfa secret changed since it was read', + ), + expect.stringContaining( + 'WebhookSubscription w1: webhook secret changed since it was read', + ), + ]), + ); + }); + + describe('rows already on the current key', () => { + /** A current-key value whose ciphertext no longer authenticates. */ + function corrupted(sealed: string): string { + const parts = sealed.split('.'); + const last = parts[parts.length - 1]; + parts[parts.length - 1] = (last[0] === 'A' ? 'B' : 'A') + last.slice(1); + return parts.join('.'); + } + + let brokenMfa: string; + let db: ReturnType; + + beforeEach(() => { + brokenMfa = corrupted(encryptMfaSecret('CURRENTKEYMFA222')); + db = fakeDb( + [ + { id: 'u1', mfaSecret: brokenMfa }, + { id: 'u2', mfaSecret: encryptMfaSecret('CURRENTKEYMFA333') }, + ], + [ + // Plain text that happens to look like a current-key value. + { + id: 'w1', + secret: `${keyIdOf(sealWebhookSecret('probe'))}:looks-sealed`, + }, + { id: 'w2', secret: sealWebhookSecret('fine') }, + ], + ); + }); + + it('dry-run opens them and fails the run on a value that does not open', async () => { + await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow( + /^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1$/, + ); + expect(db.user.updateMany).not.toHaveBeenCalled(); + expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled(); + }); + + it('a real run skips them without opening and leaves them as they are', async () => { + const decrypt = jest.spyOn(mfaCrypto, 'decryptMfaSecret'); + + const result = await new RekeyJob(db.prisma).run([]); + + expect(result).toMatchObject({ + 'mfa.alreadyCurrent': 2, + 'mfa.failed': 0, + 'webhook.alreadyCurrent': 2, + 'webhook.failed': 0, + }); + expect(decrypt).not.toHaveBeenCalled(); + expect(db.mfaSecret('u1')).toBe(brokenMfa); + }); + }); + + describe('secrets that cannot be opened', () => { + let unknownKey: string; + let staleLegacy: string; + let previousMfa: string; + let db: ReturnType; + + beforeEach(() => { + // Sealed with a key that is neither current nor previous (e.g. the + // previous key was removed before the rekey ran). + unknownKey = `k00000000:${LEGACY_MFA}`; + staleLegacy = legacyCiphertext('STALELEGACYMFA22'); + previousMfa = sealedWithPrevious('MFA_ENCRYPTION_KEY', () => + encryptMfaSecret('PREVIOUSKEYMFA22'), + ); + db = fakeDb( + [ + { id: 'u1', mfaSecret: unknownKey }, + { id: 'u2', mfaSecret: staleLegacy }, + { id: 'u3', mfaSecret: previousMfa }, + ], + [ + { id: 'w1', secret: 'enc:v1:not-a-ciphertext' }, + { id: 'w2', secret: PLAIN_WEBHOOK }, + ], + ); + // JWT_ACCESS_SECRET was rotated before the rekey ran. + process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); + }); + + it('fails the run after every other row was rekeyed, leaving those rows as they are', async () => { + const run = new RekeyJob(db.prisma).run([]); + + await expect(run).rejects.toThrow( + 'rekey: 3 secret(s) could not be rekeyed - dryRun=false, mfa.total=3, mfa.rekeyed=1, mfa.alreadyCurrent=0, mfa.plaintextSealed=0, mfa.changedMeanwhile=0, mfa.failed=2, webhook.total=2, webhook.rekeyed=0, webhook.alreadyCurrent=0, webhook.plaintextSealed=1, webhook.changedMeanwhile=0, webhook.failed=1', + ); + expect(db.mfaSecret('u1')).toBe(unknownKey); + expect(db.mfaSecret('u2')).toBe(staleLegacy); + expect(db.webhookSecret('w1')).toBe('enc:v1:not-a-ciphertext'); + expect(decryptMfaSecret(db.mfaSecret('u3'))).toBe('PREVIOUSKEYMFA22'); + expect(openWebhookSecret(db.webhookSecret('w2'))).toBe(PLAIN_WEBHOOK); + expect(logged()).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'User u1: mfa secret not rekeyed: unknown key id', + ), + expect.stringContaining( + 'User u2: mfa secret not rekeyed: ciphertext failed authentication', + ), + expect.stringContaining( + 'WebhookSubscription w1: webhook secret not rekeyed: malformed ciphertext', + ), + ]), + ); + }); + + it('dry-run reports them too and writes nothing', async () => { + await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow( + /^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1$/, + ); + expect(db.user.updateMany).not.toHaveBeenCalled(); + expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled(); + }); + + it('never logs or reports a secret or ciphertext', async () => { + const values = [ + unknownKey, + staleLegacy, + previousMfa, + 'enc:v1:not-a-ciphertext', + PLAIN_WEBHOOK, + 'STALELEGACYMFA22', + 'PREVIOUSKEYMFA22', + ]; + const fragments = values.flatMap((value) => + value.split(/[:.]/).filter((part) => part.length >= 8), + ); + + const message = await new RekeyJob(db.prisma).run([]).then( + () => '', + (failure: Error) => failure.message, + ); + + expect(message).toContain('mfa.failed=2'); + for (const text of [message, ...logged()]) { + for (const fragment of fragments) { + expect(text).not.toContain(fragment); + } + } + }); + }); + + it('checks that the new value opens to the same secret before it replaces the old one', async () => { + const stored = legacyCiphertext('ORIGINALMFA22222'); + const db = fakeDb([{ id: 'u1', mfaSecret: stored }], []); + const wrongSeal = encryptMfaSecret('SOMETHINGELSE222'); + jest.spyOn(mfaCrypto, 'encryptMfaSecret').mockReturnValue(wrongSeal); + + await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow( + 'mfa.failed=1', + ); + expect(db.user.updateMany).not.toHaveBeenCalled(); + expect(db.mfaSecret('u1')).toBe(stored); + expect(logged()).toContainEqual( + expect.stringContaining( + 'User u1: mfa secret not rekeyed: resealed secret does not open to the same value', + ), + ); + }); + + it('fails before reading any row when a key is set but unusable', async () => { + process.env.WEBHOOK_ENCRYPTION_KEY = 'too-short'; + const db = fakeDb([{ id: 'u1', mfaSecret: LEGACY_MFA }], []); + + await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow( + 'WEBHOOK_ENCRYPTION_KEY must be at least 32 characters', + ); + expect(db.user.findMany).not.toHaveBeenCalled(); + expect(db.webhookSubscription.findMany).not.toHaveBeenCalled(); + }); + + // dist/cli.js refuses these already (JOB_ALLOWED_ARGS); a typo must never + // run the real rekey, however the job is called. + it.each([[['--dry-rn']], [['--dry-run', 'extra']]])( + 'refuses %j before reading any row', + async (args) => { + const db = fakeDb([{ id: 'u1', mfaSecret: LEGACY_MFA }], []); + + await expect(new RekeyJob(db.prisma).run(args)).rejects.toThrow( + 'rekey does not accept', + ); + expect(db.user.findMany).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/ApiCore-Backend/src/jobs/rekey.job.ts b/ApiCore-Backend/src/jobs/rekey.job.ts new file mode 100644 index 0000000..81631d2 --- /dev/null +++ b/ApiCore-Backend/src/jobs/rekey.job.ts @@ -0,0 +1,265 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + decryptMfaSecret, + encryptMfaSecret, + mfaSecretNeedsRekey, +} from '../auth/mfa/mfa-crypto.util'; +import { loadKeyRing, SecretPurpose } from '../common/crypto/key-ring'; +import { loggableError } from '../common/loggable-error'; +import { PrismaService } from '../prisma/prisma.service'; +import { + isPlaintextWebhookSecret, + openWebhookSecret, + sealWebhookSecret, + webhookSecretNeedsRekey, +} from '../webhooks/webhook-secret'; +import { Job, JobResult } from './job'; +import { JobName } from './job-names'; + +/** `rekey --dry-run`: open and count every secret, write none. */ +export const REKEY_DRY_RUN_ARG = '--dry-run'; + +/** What one run did with the secrets of one purpose (mfa, webhook). */ +export interface RekeyCounts { + /** Rows with a stored secret. */ + total: number; + /** Sealed with the previous key or before the key ring, resealed. */ + rekeyed: number; + /** + * Already sealed with the current key, left as they are. Only a dry-run + * opens them (a value that does not open counts as `failed`). + */ + alreadyCurrent: number; + /** Webhook secrets stored in plain text, now sealed. */ + plaintextSealed: number; + /** Written by someone else since this run read them; not overwritten. */ + changedMeanwhile: number; + /** Could not be opened or written; they keep their old value. */ + failed: number; +} + +type RowOutcome = Exclude; + +interface StoredSecret { + id: string; + stored: string; +} + +/** One column of sealed secrets, with the helpers of its purpose. */ +interface SecretColumn { + purpose: SecretPurpose; + /** How log lines name a row: " ". */ + entity: string; + read(): Promise; + /** + * Writes `next` only while the row still holds `previous` - one UPDATE + * with both in its WHERE, so a value written meanwhile (a new MFA setup, + * another run) is never overwritten. False if nothing matched. + */ + swap(id: string, previous: string, next: string): Promise; + needsRekey(stored: string): boolean; + isPlaintext(stored: string): boolean; + open(stored: string): string; + seal(plaintext: string): string; +} + +/** Refuses anything but --dry-run: a typo must never start the real run. */ +function isDryRun(args: readonly string[]): boolean { + const refused = args.filter((arg) => arg !== REKEY_DRY_RUN_ARG); + if (refused.length > 0) { + throw new Error( + `rekey does not accept ${refused.map((arg) => JSON.stringify(arg)).join(', ')}`, + ); + } + return args.length > 0; +} + +function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts { + const count = (outcome: RowOutcome): number => + outcomes.filter((each) => each === outcome).length; + return { + total: outcomes.length, + rekeyed: count('rekeyed'), + alreadyCurrent: count('alreadyCurrent'), + plaintextSealed: count('plaintextSealed'), + changedMeanwhile: count('changedMeanwhile'), + failed: count('failed'), + }; +} + +/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */ +function toJobResult( + dryRun: boolean, + counts: readonly (readonly [SecretPurpose, RekeyCounts])[], +): JobResult { + return { + dryRun, + ...Object.fromEntries( + counts.flatMap(([purpose, perPurpose]) => + (Object.keys(perPurpose) as (keyof RekeyCounts)[]).map((key) => [ + `${purpose}.${key}`, + perPurpose[key], + ]), + ), + ), + }; +} + +function describeResult(result: JobResult): string { + return Object.entries(result) + .map(([key, value]) => `${key}=${String(value)}`) + .join(', '); +} + +/** + * Reseals every stored MFA (TOTP) and webhook signing secret with the + * current key of its purpose (common/crypto/key-ring.ts): values sealed + * with `*_PREVIOUS` or before the key ring (MFA without key id, webhook + * `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets + * still stored in plain text. Run it after setting new keys, before + * removing `*_PREVIOUS` and before rotating the JWT secrets. + * + * One row at a time, each write its own compare-and-swap statement, no + * transaction around the run: an interrupted run keeps what it resealed and + * the next run skips those rows. A row that cannot be opened or written + * keeps its old value; the run goes on and fails at the end, with the + * counts in its error. Logs name rows by id, never a secret or ciphertext. + */ +@Injectable() +export class RekeyJob implements Job { + readonly name: JobName = 'rekey'; + + private readonly logger = new Logger(RekeyJob.name); + + constructor(private readonly prisma: PrismaService) {} + + async run(args: readonly string[]): Promise { + const dryRun = isDryRun(args); + const columns = [this.mfaColumn(), this.webhookColumn()]; + // A key that is set but unusable fails the run before any row is read. + for (const column of columns) { + loadKeyRing(column.purpose); + } + const counts: (readonly [SecretPurpose, RekeyCounts])[] = []; + for (const column of columns) { + counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]); + } + const result = toJobResult(dryRun, counts); + const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0); + if (failed > 0) { + throw new Error( + `rekey: ${failed} secret(s) could not be rekeyed - ${describeResult(result)}`, + ); + } + return result; + } + + private async rekeyColumn( + column: SecretColumn, + dryRun: boolean, + ): Promise { + const rows = await column.read(); + const outcomes: RowOutcome[] = []; + for (const row of rows) { + outcomes.push(await this.rekeyRow(column, row, dryRun)); + } + return countOutcomes(outcomes); + } + + private async rekeyRow( + column: SecretColumn, + row: StoredSecret, + dryRun: boolean, + ): Promise { + const current = !column.needsRekey(row.stored); + // A real run skips current rows unopened; a dry-run opens them too, so + // the closing all-zero check also catches a corrupted current value. + if (current && !dryRun) { + return 'alreadyCurrent'; + } + const outcome: RowOutcome = current + ? 'alreadyCurrent' + : column.isPlaintext(row.stored) + ? 'plaintextSealed' + : 'rekeyed'; + const subject = `${column.entity} ${row.id}: ${column.purpose} secret`; + try { + // Opened in a dry-run too: it reports what a real run could not reseal. + const plaintext = column.open(row.stored); + if (dryRun) { + return outcome; + } + const resealed = column.seal(plaintext); + // The swap replaces the only copy of the old value. + if (column.open(resealed) !== plaintext) { + throw new Error('resealed secret does not open to the same value'); + } + if (await column.swap(row.id, row.stored, resealed)) { + return outcome; + } + this.logger.warn( + `${subject} changed since it was read, left as it is (run rekey --dry-run to check)`, + ); + return 'changedMeanwhile'; + } catch (error: unknown) { + this.logger.error( + `${subject} not rekeyed: ${loggableError(error).message}`, + ); + return 'failed'; + } + } + + private mfaColumn(): SecretColumn { + return { + purpose: 'mfa', + entity: 'User', + read: async () => { + const users = await this.prisma.user.findMany({ + where: { mfaSecret: { not: null } }, + select: { id: true, mfaSecret: true }, + orderBy: { id: 'asc' }, + }); + return users.flatMap(({ id, mfaSecret }) => + mfaSecret === null ? [] : [{ id, stored: mfaSecret }], + ); + }, + swap: async (id, previous, next) => { + const { count } = await this.prisma.user.updateMany({ + where: { id, mfaSecret: previous }, + data: { mfaSecret: next }, + }); + return count === 1; + }, + needsRekey: mfaSecretNeedsRekey, + // TOTP secrets were never stored in plain text. + isPlaintext: () => false, + open: decryptMfaSecret, + seal: encryptMfaSecret, + }; + } + + private webhookColumn(): SecretColumn { + return { + purpose: 'webhook', + entity: 'WebhookSubscription', + read: async () => { + const subscriptions = await this.prisma.webhookSubscription.findMany({ + select: { id: true, secret: true }, + orderBy: { id: 'asc' }, + }); + return subscriptions.map(({ id, secret }) => ({ id, stored: secret })); + }, + swap: async (id, previous, next) => { + const { count } = await this.prisma.webhookSubscription.updateMany({ + where: { id, secret: previous }, + data: { secret: next }, + }); + return count === 1; + }, + needsRekey: webhookSecretNeedsRekey, + isPlaintext: isPlaintextWebhookSecret, + open: openWebhookSecret, + seal: sealWebhookSecret, + }; + } +} diff --git a/ApiCore-Backend/src/jobs/run-cli.spec.ts b/ApiCore-Backend/src/jobs/run-cli.spec.ts new file mode 100644 index 0000000..605f590 --- /dev/null +++ b/ApiCore-Backend/src/jobs/run-cli.spec.ts @@ -0,0 +1,216 @@ +import { INestApplicationContext, Logger } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { JobRun, Prisma } from '../../generated/prisma'; +import { NotificationsService } from '../notifications/notifications.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobRunService } from '../system/job-run.service'; +import { JobResult } from './job'; +import { JobName } from './job-names'; +import { JobRegistry } from './job-registry'; +import { JobsModule } from './jobs.module'; +import { runCli } from './run-cli'; + +type RawCall = [readonly string[], ...unknown[]]; + +const STARTED_AT = new Date('2026-10-13T04:00:00.000Z'); + +describe('runCli', () => { + let job: { name: JobName; run: jest.Mock, [string[]]> }; + let jobRun: { create: jest.Mock }; + // The run is finished by one UPDATE … RETURNING * (finishedAt = now()). + let finish: jest.Mock, RawCall>; + let app: { get: jest.Mock; close: jest.Mock }; + let createContext: jest.Mock, []>; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + job = { + name: 'cleanup', + run: jest + .fn, [string[]]>() + .mockResolvedValue({ deleted: 2 }), + }; + jobRun = { + create: jest.fn(({ data }: { data: { job: string } }): Promise => + Promise.resolve({ + id: 'run-1', + job: data.job, + status: 'RUNNING', + startedAt: STARTED_AT, + finishedAt: null, + result: null, + error: null, + }), + ), + }; + finish = jest.fn, RawCall>((_sql, status) => + Promise.resolve([ + { + id: 'run-1', + job: job.name, + status: status as JobRun['status'], + startedAt: STARTED_AT, + finishedAt: new Date(), + result: null, + error: null, + }, + ]), + ); + const tx = { + $executeRaw: jest.fn().mockResolvedValue(1), + $queryRaw: jest.fn().mockResolvedValue([{ notified: false }]), + }; + const prisma = { + jobRun, + $queryRaw: finish, + $transaction: jest.fn((work: (client: typeof tx) => Promise) => + work(tx), + ), + }; + const notifications = { + recordForPermission: jest.fn().mockResolvedValue(1), + }; + const jobRuns = new JobRunService( + prisma as unknown as PrismaService, + notifications as unknown as NotificationsService, + ); + const registry = { get: jest.fn(() => job) }; + app = { + get: jest.fn((token: unknown) => + token === JobRegistry ? registry : jobRuns, + ), + close: jest.fn().mockResolvedValue(undefined), + }; + createContext = jest + .fn, []>() + .mockResolvedValue(app as unknown as INestApplicationContext); + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('rejects an unknown job with exit code 2 and writes no JobRun', async () => { + expect(await runCli(['nope'], createContext)).toBe(2); + + expect(createContext).not.toHaveBeenCalled(); + expect(jobRun.create).not.toHaveBeenCalled(); + }); + + it('rejects a call without a job name with exit code 2', async () => { + expect(await runCli([], createContext)).toBe(2); + + expect(createContext).not.toHaveBeenCalled(); + }); + + it('runs the job, records SUCCEEDED with its result and returns 0', async () => { + expect(await runCli(['cleanup'], createContext)).toBe(0); + + expect(jobRun.create).toHaveBeenCalledWith({ data: { job: 'cleanup' } }); + expect(finish.mock.calls[0].slice(1)).toEqual([ + 'SUCCEEDED', + '{"deleted":2}', + null, + 'run-1', + ]); + expect(app.close).toHaveBeenCalledTimes(1); + }); + + it('passes extra arguments such as --dry-run to the job', async () => { + job.name = 'rekey'; + + expect(await runCli(['rekey', '--dry-run'], createContext)).toBe(0); + + expect(job.run).toHaveBeenCalledWith(['--dry-run']); + }); + + // A typo or a flag the job does not know must not start the real run - + // `rekey --dry-rn` would otherwise rewrite every secret. + it.each([ + [['cleanup', '--dry-run']], + [['rekey', '--dry-rn']], + [['rekey', '--dry-run', '--dry-run', 'extra']], + ])('rejects %j with exit code 2 before any context exists', async (argv) => { + expect(await runCli(argv, createContext)).toBe(2); + + expect(createContext).not.toHaveBeenCalled(); + expect(job.run).not.toHaveBeenCalled(); + }); + + it('returns 1 and stores the error via loggableError when the job fails', async () => { + job.run.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('name "Max Mustermann"', { + code: 'P2024', + clientVersion: '7.9.1', + meta: { modelName: 'User' }, + }), + ); + + expect(await runCli(['cleanup'], createContext)).toBe(1); + + expect(finish.mock.calls[0].slice(1)).toEqual([ + 'FAILED', + null, + 'PrismaClientKnownRequestError P2024 (User)', + 'run-1', + ]); + const logged = JSON.stringify(errorSpy.mock.calls); + expect(logged).not.toContain('Mustermann'); + expect(app.close).toHaveBeenCalledTimes(1); + }); + + // No JobRun can be written then - the exit code and the log are all the + // CronJob shows. + it('returns 1 when the context cannot be created, without the Prisma message', async () => { + createContext.mockRejectedValue( + new Prisma.PrismaClientInitializationError( + 'Can\'t reach database at "db.internal" for user "geheim"', + '7.9.1', + ), + ); + + expect(await runCli(['cleanup'], createContext)).toBe(1); + + const logged = JSON.stringify(errorSpy.mock.calls); + expect(logged).toContain('PrismaClientInitializationError'); + expect(logged).not.toContain('geheim'); + }); + + // Without abortOnError: false, Nest exits the process (code 1, no log + // through our logger) on a DI or registry error instead of rejecting. + it('builds the default context without aborting the process on a module error', async () => { + const createApplicationContext = jest + .spyOn(NestFactory, 'createApplicationContext') + .mockRejectedValue(new Error('No job registered for: rekey')); + + expect(await runCli(['cleanup'])).toBe(1); + + expect(createApplicationContext).toHaveBeenCalledWith(JobsModule, { + abortOnError: false, + }); + expect(JSON.stringify(errorSpy.mock.calls)).toContain( + 'No job registered for: rekey', + ); + }); + + it('returns 1 and still closes the context when the run cannot be recorded', async () => { + jobRun.create.mockRejectedValue(new Error('connection reset')); + + expect(await runCli(['cleanup'], createContext)).toBe(1); + + expect(job.run).not.toHaveBeenCalled(); + expect(app.close).toHaveBeenCalledTimes(1); + }); + + it('keeps the exit code when closing the context fails', async () => { + app.close.mockRejectedValue(new Error('pool already closed')); + + expect(await runCli(['cleanup'], createContext)).toBe(0); + + expect(JSON.stringify(errorSpy.mock.calls)).toContain( + 'pool already closed', + ); + }); +}); diff --git a/ApiCore-Backend/src/jobs/run-cli.ts b/ApiCore-Backend/src/jobs/run-cli.ts new file mode 100644 index 0000000..93f51d5 --- /dev/null +++ b/ApiCore-Backend/src/jobs/run-cli.ts @@ -0,0 +1,92 @@ +import { INestApplicationContext, Logger } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { loggableError } from '../common/loggable-error'; +import { JobRunService } from '../system/job-run.service'; +import { isJobName, JOB_ALLOWED_ARGS, JOB_NAMES, JobName } from './job-names'; +import { JobRegistry } from './job-registry'; +import { JobsModule } from './jobs.module'; + +/** Exit codes of dist/cli.js: run succeeded, run failed, wrong call. */ +export type CliExitCode = 0 | 1 | 2; + +const logger = new Logger('Cli'); + +const USAGE = `Usage: node dist/cli.js [args] - jobs: ${JOB_NAMES.join(', ')}`; + +/** + * The context of one job run. abortOnError: false makes a module error + * (DI, JobRegistry) reject, so runCli logs it and exits 1 - by default Nest + * would end the process itself. No shutdown hooks: a pod that is killed + * leaves its run RUNNING, and the cleanup job marks it aborted. + */ +export function createJobsContext(): Promise { + return NestFactory.createApplicationContext(JobsModule, { + abortOnError: false, + }); +} + +async function closeContext(app: INestApplicationContext): Promise { + try { + await app.close(); + } catch (error: unknown) { + const { message, stack } = loggableError(error); + logger.error(`Closing the job context failed: ${message}`, stack); + } +} + +/** True for a known job with accepted arguments; otherwise logs why not. */ +function isValidCall( + name: string | undefined, + args: readonly string[], +): name is JobName { + if (name === undefined || !isJobName(name)) { + logger.error( + name === undefined + ? 'No job given' + : `Unknown job ${JSON.stringify(name)}`, + ); + logger.error(USAGE); + return false; + } + const allowed = JOB_ALLOWED_ARGS[name]; + const refused = args.filter((arg) => !allowed.includes(arg)); + if (refused.length > 0) { + logger.error( + `Job "${name}" does not accept ${refused.map((arg) => JSON.stringify(arg)).join(', ')} (allowed: ${allowed.join(', ') || 'no arguments'})`, + ); + return false; + } + return true; +} + +/** + * Runs `node dist/cli.js [args]`: argv[0] is the job, the rest goes + * to the job unchanged. A missing or unknown job, or an argument outside + * JOB_ALLOWED_ARGS, is refused before anything connects, so it writes no + * JobRun. + */ +export async function runCli( + argv: readonly string[], + createContext: () => Promise = createJobsContext, +): Promise { + const [name, ...args] = argv; + if (!isValidCall(name, args)) { + return 2; + } + let app: INestApplicationContext | undefined; + try { + app = await createContext(); + const job = app.get(JobRegistry).get(name); + const run = await app.get(JobRunService).run(name, () => job.run(args)); + return run.status === 'SUCCEEDED' ? 0 : 1; + } catch (error: unknown) { + // The run could not even be recorded (no context, no database). + const { message, stack } = loggableError(error); + logger.error(`Job "${name}" could not run: ${message}`, stack); + return 1; + } finally { + if (app) { + await closeContext(app); + } + } +} diff --git a/ApiCore-Backend/src/jobs/task-templates.job.ts b/ApiCore-Backend/src/jobs/task-templates.job.ts new file mode 100644 index 0000000..a3610dc --- /dev/null +++ b/ApiCore-Backend/src/jobs/task-templates.job.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { TasksService } from '../tasks/tasks.service'; +import { Job, JobResult } from './job'; +import { JobName } from './job-names'; + +/** + * Creates the due tasks of the recurring templates (CronJob 05:00), so they + * exist without anyone opening the task list first. Running twice, or + * alongside the lazy generation before GET /tasks, creates each occurrence + * once: generateFromTemplates claims it by compare-and-swap. + */ +@Injectable() +export class TaskTemplatesJob implements Job { + readonly name: JobName = 'task-templates'; + + constructor(private readonly tasks: TasksService) {} + + async run(): Promise { + return { created: await this.tasks.generateFromTemplates() }; + } +} diff --git a/ApiCore-Backend/src/prisma/row-lock.spec.ts b/ApiCore-Backend/src/prisma/row-lock.spec.ts new file mode 100644 index 0000000..8d4ae47 --- /dev/null +++ b/ApiCore-Backend/src/prisma/row-lock.spec.ts @@ -0,0 +1,145 @@ +import { Prisma } from '../../generated/prisma'; +import { + lockRowForUpdate, + lockRowsForShare, + lockRowsForUpdate, +} from './row-lock'; + +describe('lockRowForUpdate', () => { + it('locks the row by id with a parameterised FOR UPDATE on the mapped table', async () => { + const tx = { $queryRaw: jest.fn().mockResolvedValue([]) }; + + await lockRowForUpdate(tx as never, 'Colony', 'c1'); + + const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]]; + expect(query.sql).toBe('SELECT id FROM "Colony" WHERE id = ? FOR UPDATE'); + expect(query.values).toEqual(['c1']); + }); + + it('refuses the root client', async () => { + const root = { $connect: jest.fn(), $queryRaw: jest.fn() }; + + await expect( + lockRowForUpdate(root as never, 'Colony', 'c1'), + ).rejects.toThrow('lockRowForUpdate needs the transaction client'); + expect(root.$queryRaw).not.toHaveBeenCalled(); + }); + + it('refuses a table outside the fixed map', async () => { + const tx = { $queryRaw: jest.fn() }; + + await expect( + lockRowForUpdate(tx as never, 'User"; DROP TABLE x; --' as never, 'c1'), + ).rejects.toThrow('lockRowForUpdate: table not lockable'); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + }); +}); + +describe('lockRowsForShare', () => { + it('locks every row with one parameterised FOR SHARE in ascending id order', async () => { + const tx = { $queryRaw: jest.fn().mockResolvedValue([]) }; + const ids = ['c3', 'c1', 'c2']; + + await lockRowsForShare(tx as never, 'Colony', ids); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]]; + expect(query.sql).toBe( + 'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR SHARE', + ); + expect(query.values).toEqual([['c1', 'c2', 'c3']]); + // The caller's list stays as it was. + expect(ids).toEqual(['c3', 'c1', 'c2']); + }); + + it('sends no statement for an empty list', async () => { + const tx = { $queryRaw: jest.fn() }; + + await lockRowsForShare(tx as never, 'Colony', []); + + expect(tx.$queryRaw).not.toHaveBeenCalled(); + }); + + it('refuses the root client', async () => { + const root = { $connect: jest.fn(), $queryRaw: jest.fn() }; + + await expect( + lockRowsForShare(root as never, 'Colony', ['c1']), + ).rejects.toThrow('lockRowsForShare needs the transaction client'); + expect(root.$queryRaw).not.toHaveBeenCalled(); + }); + + it('refuses a table outside the fixed map', async () => { + const tx = { $queryRaw: jest.fn() }; + + await expect( + lockRowsForShare(tx as never, 'User"; DROP TABLE x; --' as never, ['c1']), + ).rejects.toThrow('lockRowsForShare: table not lockable'); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + }); +}); + +describe('lockRowForUpdate result', () => { + it('resolves to whether the row exists', async () => { + const tx = { + $queryRaw: jest + .fn() + .mockResolvedValueOnce([{ id: 'p1' }]) + .mockResolvedValueOnce([]), + }; + + await expect(lockRowForUpdate(tx as never, 'Product', 'p1')).resolves.toBe( + true, + ); + await expect(lockRowForUpdate(tx as never, 'Product', 'p2')).resolves.toBe( + false, + ); + const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]]; + expect(query.sql).toBe('SELECT id FROM "Product" WHERE id = ? FOR UPDATE'); + }); +}); + +describe('lockRowsForUpdate', () => { + it('locks every row with one parameterised FOR UPDATE in ascending id order', async () => { + const tx = { $queryRaw: jest.fn().mockResolvedValue([]) }; + const ids = ['c3', 'c1', 'c2']; + + await lockRowsForUpdate(tx as never, 'Colony', ids); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]]; + expect(query.sql).toBe( + 'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE', + ); + expect(query.values).toEqual([['c1', 'c2', 'c3']]); + expect(ids).toEqual(['c3', 'c1', 'c2']); + }); + + it('sends no statement for an empty list', async () => { + const tx = { $queryRaw: jest.fn() }; + + await lockRowsForUpdate(tx as never, 'Colony', []); + + expect(tx.$queryRaw).not.toHaveBeenCalled(); + }); + + it('refuses the root client', async () => { + const root = { $connect: jest.fn(), $queryRaw: jest.fn() }; + + await expect( + lockRowsForUpdate(root as never, 'Colony', ['c1']), + ).rejects.toThrow('lockRowsForUpdate needs the transaction client'); + expect(root.$queryRaw).not.toHaveBeenCalled(); + }); + + it('refuses a table outside the fixed map', async () => { + const tx = { $queryRaw: jest.fn() }; + + await expect( + lockRowsForUpdate(tx as never, 'User"; DROP TABLE x; --' as never, [ + 'c1', + ]), + ).rejects.toThrow('lockRowsForUpdate: table not lockable'); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/prisma/row-lock.ts b/ApiCore-Backend/src/prisma/row-lock.ts new file mode 100644 index 0000000..a2078f9 --- /dev/null +++ b/ApiCore-Backend/src/prisma/row-lock.ts @@ -0,0 +1,90 @@ +import { Prisma } from '../../generated/prisma'; +import { assertTransactionClient } from './db-client'; + +// Fixed identifier map: a table name is never taken from input. +const LOCKABLE_TABLES = { + Colony: Prisma.raw('"Colony"'), + Task: Prisma.raw('"Task"'), + BottlingBatch: Prisma.raw('"BottlingBatch"'), + Feeding: Prisma.raw('"Feeding"'), + Harvest: Prisma.raw('"Harvest"'), + Inspection: Prisma.raw('"Inspection"'), + Product: Prisma.raw('"Product"'), + ProductionBatch: Prisma.raw('"ProductionBatch"'), + RawMaterialLot: Prisma.raw('"RawMaterialLot"'), + StockItem: Prisma.raw('"StockItem"'), + Treatment: Prisma.raw('"Treatment"'), + VarroaMeasurement: Prisma.raw('"VarroaMeasurement"'), +} as const; + +export type LockableTable = keyof typeof LOCKABLE_TABLES; + +/** + * Locks one row (SELECT ... FOR UPDATE) until the transaction ends, so a + * read-modify-write on it is serialised with concurrent writers. Re-read the + * row after locking and use that copy as "before". Resolves to whether the + * row exists. + */ +export async function lockRowForUpdate( + tx: Prisma.TransactionClient, + table: LockableTable, + id: string, +): Promise { + assertTransactionClient(tx, 'lockRowForUpdate'); + if (!Object.hasOwn(LOCKABLE_TABLES, table)) { + throw new Error('lockRowForUpdate: table not lockable'); + } + const locked = await tx.$queryRaw<{ id: string }[]>( + Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ${id} FOR UPDATE`, + ); + return locked.length > 0; +} + +/** + * Locks several rows (SELECT ... FOR UPDATE) until the transaction ends, with + * ONE statement in ascending id order: it meets lockRowsForShare on the same + * order, so two transactions locking overlapping sets cannot deadlock. Unknown + * ids lock nothing; re-read the rows after locking. + */ +export async function lockRowsForUpdate( + tx: Prisma.TransactionClient, + table: LockableTable, + ids: readonly string[], +): Promise { + assertTransactionClient(tx, 'lockRowsForUpdate'); + if (!Object.hasOwn(LOCKABLE_TABLES, table)) { + throw new Error('lockRowsForUpdate: table not lockable'); + } + if (ids.length === 0) { + return; + } + const sortedIds = [...ids].sort(); + await tx.$queryRaw( + Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR UPDATE`, + ); +} + +/** + * Locks several rows (SELECT ... FOR SHARE) until the transaction ends: they + * cannot be changed or deleted meanwhile, other readers are not blocked. The + * rows are locked in ascending id order, so two transactions locking + * overlapping sets cannot deadlock. Unknown ids lock nothing; re-read the rows + * after locking. + */ +export async function lockRowsForShare( + tx: Prisma.TransactionClient, + table: LockableTable, + ids: readonly string[], +): Promise { + assertTransactionClient(tx, 'lockRowsForShare'); + if (!Object.hasOwn(LOCKABLE_TABLES, table)) { + throw new Error('lockRowsForShare: table not lockable'); + } + if (ids.length === 0) { + return; + } + const sortedIds = [...ids].sort(); + await tx.$queryRaw( + Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR SHARE`, + ); +} diff --git a/ApiCore-Backend/src/production-batches/production-batches.service.spec.ts b/ApiCore-Backend/src/production-batches/production-batches.service.spec.ts new file mode 100644 index 0000000..17b5811 --- /dev/null +++ b/ApiCore-Backend/src/production-batches/production-batches.service.spec.ts @@ -0,0 +1,299 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { ProductionBatchesService } from './production-batches.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { StockService } from '../stock/stock.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { AutomationService } from '../automation/automation.service'; + +/** + * Production batches announce their stock through StockService.recordChange + * in the transaction that writes them - with the article and the raw + * material kinds consumed or given back. The minimum-stock rule runs in the + * automation.raw-material-low handler, never from here. + */ +describe('ProductionBatchesService', () => { + let service: ProductionBatchesService; + let tx: { + $queryRaw: jest.Mock; + productionBatch: { + create: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + findUnique: jest.Mock; + }; + }; + let prisma: { + product: { findUnique: jest.Mock }; + productionBatch: { findUnique: jest.Mock }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + let stock: { + consumeForProduction: jest.Mock; + returnProductionInputs: jest.Mock; + recordChange: jest.Mock; + remainingByProductionBatch: jest.Mock; + hasMovements: jest.Mock; + assertInitialCovers: jest.Mock; + lock: jest.Mock; + }; + let automation: { + generateNumber: jest.Mock; + }; + let steps: string[]; + + const batch = { + id: 'batch-1', + productId: 'product-1', + lotNumber: 'HS-1', + quantity: 20, + inputs: [], + }; + + beforeEach(() => { + steps = []; + tx = { + $queryRaw: jest.fn(() => { + steps.push('lock batch'); + return Promise.resolve([]); + }), + productionBatch: { + create: jest.fn().mockResolvedValue(batch), + update: jest.fn().mockResolvedValue(batch), + delete: jest.fn().mockResolvedValue(batch), + findUnique: jest.fn(() => { + steps.push('read'); + return Promise.resolve(batch); + }), + }, + }; + prisma = { + product: { + findUnique: jest + .fn() + .mockResolvedValue({ id: 'product-1', kind: 'MANUFACTURED' }), + }, + productionBatch: { findUnique: jest.fn().mockResolvedValue(batch) }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn() }; + stock = { + consumeForProduction: jest + .fn() + .mockResolvedValue(['BEESWAX', 'PROPOLIS']), + returnProductionInputs: jest.fn().mockResolvedValue(['PROPOLIS']), + recordChange: jest.fn(), + remainingByProductionBatch: jest.fn().mockResolvedValue(new Map()), + hasMovements: jest.fn().mockResolvedValue(false), + assertInitialCovers: jest.fn(), + lock: jest.fn(() => { + steps.push('lock article'); + return Promise.resolve(); + }), + }; + automation = { + generateNumber: jest.fn().mockResolvedValue(null), + }; + const permissions = { + getPermissions: jest + .fn() + .mockResolvedValue(new Set(['raw-material:withdraw'])), + }; + service = new ProductionBatchesService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + stock as unknown as StockService, + permissions as unknown as PermissionResolverService, + automation as unknown as AutomationService, + ); + }); + + it('create records StockChanged with the article and the consumed kinds in the batch transaction', async () => { + await service.create( + { + productId: 'product-1', + lotNumber: 'HS-1', + date: '2026-06-10', + quantity: 20, + inputs: [ + { rawMaterialLotId: 'wax', quantity: 300 }, + { rawMaterialLotId: 'propolis', quantity: 50 }, + ], + }, + 'user-1', + ); + + expect(stock.consumeForProduction).toHaveBeenCalledWith( + tx, + 'batch-1', + 'HS-1', + [ + { rawMaterialLotId: 'wax', quantity: 300 }, + { rawMaterialLotId: 'propolis', quantity: 50 }, + ], + 'user-1', + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'ProductionBatch', + entityId: 'batch-1', + action: 'CREATE', + }), + tx, + ); + expect(stock.recordChange).toHaveBeenCalledTimes(1); + expect(stock.recordChange).toHaveBeenCalledWith( + tx, + { + bottlingBatchIds: [], + productIds: ['product-1'], + rawMaterialKinds: ['BEESWAX', 'PROPOLIS'], + }, + 'user-1', + { type: 'ProductionBatch', id: 'batch-1' }, + ); + }); + + it('create without raw material records StockChanged for the article only', async () => { + await service.create( + { + productId: 'product-1', + lotNumber: 'HS-1', + date: '2026-06-10', + quantity: 20, + }, + 'user-1', + ); + + expect(stock.consumeForProduction).not.toHaveBeenCalled(); + expect(stock.recordChange).toHaveBeenCalledWith( + tx, + { + bottlingBatchIds: [], + productIds: ['product-1'], + rawMaterialKinds: [], + }, + 'user-1', + { type: 'ProductionBatch', id: 'batch-1' }, + ); + }); + + it('update locks article and batch, then reads "before" through the transaction', async () => { + // A read outside the transaction would see this stale copy. + prisma.productionBatch.findUnique.mockResolvedValue({ + ...batch, + lotNumber: 'STALE', + quantity: 99, + }); + tx.productionBatch.findUnique + .mockImplementationOnce(() => { + steps.push('read'); + return Promise.resolve(batch); + }) + .mockImplementationOnce(() => + Promise.resolve({ ...batch, quantity: 25 }), + ); + + const result = await service.update('batch-1', { quantity: 25 }, 'user-1'); + + // A shop sale locks the article before it books onto the batch - the + // same order here keeps the two from deadlocking. + expect(steps.slice(0, 3)).toEqual(['lock article', 'lock batch', 'read']); + expect(stock.lock).toHaveBeenCalledWith(tx, { + kind: 'production', + id: 'batch-1', + }); + expect(prisma.productionBatch.findUnique).not.toHaveBeenCalled(); + // Checked against the locked quantity (20), not the stale one (99). + expect(stock.assertInitialCovers).toHaveBeenCalledWith( + tx, + { kind: 'production', id: 'batch-1' }, + 25, + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'ProductionBatch', + action: 'UPDATE', + beforeData: { ...batch, remainingCount: 20 }, + afterData: { ...batch, quantity: 25, remainingCount: 25 }, + }), + tx, + ); + expect(stock.recordChange).toHaveBeenCalledWith( + tx, + { bottlingBatchIds: [], productIds: ['product-1'], rawMaterialKinds: [] }, + 'user-1', + { type: 'ProductionBatch', id: 'batch-1' }, + ); + expect(result).toEqual({ ...batch, quantity: 25, remainingCount: 25 }); + }); + + it('update skips the cover check when the quantity stays the same', async () => { + await service.update('batch-1', { quantity: 20, notes: 'x' }, 'user-1'); + + expect(stock.assertInitialCovers).not.toHaveBeenCalled(); + }); + + it('update throws NotFoundException for an unknown batch', async () => { + stock.lock.mockRejectedValueOnce( + new NotFoundException('Production batch not found'), + ); + + await expect( + service.update('missing', { notes: 'x' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(stock.recordChange).not.toHaveBeenCalled(); + }); + + it('remove gives the raw material back and records StockChanged with the returned kinds', async () => { + const result = await service.remove('batch-1', 'user-1'); + + expect(result).toEqual({ id: 'batch-1' }); + expect(steps.slice(0, 3)).toEqual(['lock article', 'lock batch', 'read']); + expect(stock.hasMovements).toHaveBeenCalledWith( + { kind: 'production', id: 'batch-1' }, + tx, + ); + expect(stock.returnProductionInputs).toHaveBeenCalledWith( + tx, + 'batch-1', + 'HS-1', + 'user-1', + ); + expect(tx.productionBatch.delete).toHaveBeenCalledWith({ + where: { id: 'batch-1' }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'ProductionBatch', + action: 'DELETE', + beforeData: { ...batch, remainingCount: 20 }, + }), + tx, + ); + expect(stock.recordChange).toHaveBeenCalledWith( + tx, + { + bottlingBatchIds: [], + productIds: ['product-1'], + rawMaterialKinds: ['PROPOLIS'], + }, + 'user-1', + { type: 'ProductionBatch', id: 'batch-1' }, + ); + }); + + it('remove refuses a batch with stock movements and books nothing', async () => { + stock.hasMovements.mockResolvedValueOnce(true); + + await expect(service.remove('batch-1', 'user-1')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(stock.returnProductionInputs).not.toHaveBeenCalled(); + expect(tx.productionBatch.delete).not.toHaveBeenCalled(); + expect(stock.recordChange).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/production-batches/production-batches.service.ts b/ApiCore-Backend/src/production-batches/production-batches.service.ts index 37287e1..ed8b985 100644 --- a/ApiCore-Backend/src/production-batches/production-batches.service.ts +++ b/ApiCore-Backend/src/production-batches/production-batches.service.ts @@ -7,10 +7,12 @@ import { } from '@nestjs/common'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PrismaService } from '../prisma/prisma.service'; +import { DbClient } from '../prisma/db-client'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { AuditLogService } from '../audit/audit-log.service'; import { AutomationService } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; -import { RawMaterialKind } from '../../generated/prisma'; +import { Prisma, RawMaterialKind } from '../../generated/prisma'; import { optionalDate } from '../common/patch-values'; import { CreateProductionBatchDto } from './dto/create-production-batch.dto'; import { UpdateProductionBatchDto } from './dto/update-production-batch.dto'; @@ -31,11 +33,18 @@ export interface ProductionBatchFilter { productId?: string; } +type Tx = Prisma.TransactionClient; + /** * Production batches of MANUFACTURED articles (candles, propolis tinctures, * salves, ...) - the counterpart of a honey bottling. Each one carries its * own stock in the movement ledger (StockService); later a production run * will create them from raw-material batches. + * + * Every write announces its stock in its own transaction (StockChanged via + * StockService.recordChange): the article for the shop, the raw material + * kinds consumed or given back for the minimum-stock rule, which the + * automation.raw-material-low handler evaluates. */ @Injectable() export class ProductionBatchesService { @@ -62,13 +71,15 @@ export class ProductionBatchesService { })); } - async findById(id: string) { - const batch = await this.prisma.productionBatch.findUnique({ + /** With `tx` it reads what the caller's transaction sees. */ + async findById(id: string, tx?: Tx) { + const db: DbClient = tx ?? this.prisma; + const batch = await db.productionBatch.findUnique({ where: { id }, include, }); if (!batch) throw new NotFoundException('Production batch not found'); - const remaining = await this.stock.remainingByProductionBatch([id]); + const remaining = await this.stock.remainingByProductionBatch([id], tx); return { ...batch, remainingCount: remaining.get(id) ?? batch.quantity }; } @@ -118,42 +129,30 @@ export class ProductionBatchesService { userId: actingUserId, }, }); - if (dto.inputs && dto.inputs.length > 0) { - await this.stock.consumeForProduction( - tx, - created.id, - created.lotNumber, - dto.inputs, - actingUserId, - ); - } + const consumedKinds = + dto.inputs && dto.inputs.length > 0 + ? await this.stock.consumeForProduction( + tx, + created.id, + created.lotNumber, + dto.inputs, + actingUserId, + ) + : []; + await this.auditLog.record( + { + entityType: 'ProductionBatch', + entityId: created.id, + action: 'CREATE', + userId: actingUserId, + afterData: created, + }, + tx, + ); + await this.recordChange(tx, created, consumedKinds, actingUserId); return created; }); - await this.auditLog.record({ - entityType: 'ProductionBatch', - entityId: batch.id, - action: 'CREATE', - userId: actingUserId, - afterData: batch, - }); - await this.stock.notify({ - bottlingBatchIds: [], - productIds: [batch.productId], - }); - const created = await this.findById(batch.id); - await this.checkRawMaterialLow(created.inputs); - return created; - } - - /** Runs the RAW_MATERIAL_LOW rule for every kind this batch consumed. */ - private async checkRawMaterialLow( - inputs: { rawMaterialLot: { kind: RawMaterialKind } }[], - ): Promise { - const kinds = new Set(inputs.map((input) => input.rawMaterialLot.kind)); - for (const kind of kinds) { - const remaining = await this.stock.remainingByRawMaterialKind(kind); - await this.automation.evaluateRawMaterialLow(kind, remaining); - } + return this.findById(batch.id); } async update( @@ -161,8 +160,8 @@ export class ProductionBatchesService { dto: UpdateProductionBatchDto, actingUserId: string, ) { - const before = await this.findById(id); - await this.prisma.$transaction(async (tx) => { + return this.prisma.$transaction(async (tx) => { + const before = await this.lockBatch(tx, id); if (dto.quantity !== undefined && dto.quantity !== before.quantity) { await this.stock.assertInitialCovers( tx, @@ -180,21 +179,23 @@ export class ProductionBatchesService { notes: dto.notes, }, }); + const after = await this.findById(id, tx); + await this.auditLog.record( + { + entityType: 'ProductionBatch', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: after, + }, + tx, + ); + // A corrected quantity changes the article's stock; no raw material + // moves (inputs are fixed once the batch exists). + await this.recordChange(tx, after, [], actingUserId); + return after; }); - const after = await this.findById(id); - await this.auditLog.record({ - entityType: 'ProductionBatch', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: after, - }); - await this.stock.notify({ - bottlingBatchIds: [], - productIds: [after.productId], - }); - return after; } /** @@ -203,34 +204,61 @@ export class ProductionBatchesService { * an inventory correction. */ async remove(id: string, actingUserId: string) { - const before = await this.findById(id); - if (await this.stock.hasMovements({ kind: 'production', id })) { - throw new ConflictException( - 'Charge hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen', - ); - } - // The raw material goes back to its lots as a movement of its own; the - // input rows go with the batch (cascade). await this.prisma.$transaction(async (tx) => { - await this.stock.returnProductionInputs( + const before = await this.lockBatch(tx, id); + if (await this.stock.hasMovements({ kind: 'production', id }, tx)) { + throw new ConflictException( + 'Charge hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen', + ); + } + // The raw material goes back to its lots as a movement of its own; + // the input rows go with the batch (cascade). + const returnedKinds = await this.stock.returnProductionInputs( tx, id, before.lotNumber, actingUserId, ); await tx.productionBatch.delete({ where: { id } }); - }); - await this.auditLog.record({ - entityType: 'ProductionBatch', - entityId: id, - action: 'DELETE', - userId: actingUserId, - beforeData: before, - }); - await this.stock.notify({ - bottlingBatchIds: [], - productIds: [before.productId], + await this.auditLog.record( + { + entityType: 'ProductionBatch', + entityId: id, + action: 'DELETE', + userId: actingUserId, + beforeData: before, + }, + tx, + ); + await this.recordChange(tx, before, returnedKinds, actingUserId); }); return { id }; } + + /** + * Locks the batch and re-reads it in the transaction - use the result, + * not an earlier read, as "before". The article is locked first, as + * every booking on it does: a shop sale holds the article while its + * movement key-share locks the batch, so the other order could deadlock. + */ + private async lockBatch(tx: Tx, id: string) { + await this.stock.lock(tx, { kind: 'production', id }); + await lockRowForUpdate(tx, 'ProductionBatch', id); + return this.findById(id, tx); + } + + /** StockChanged for the batch's article and the raw material it moved. */ + private async recordChange( + tx: Tx, + batch: { id: string; productId: string }, + rawMaterialKinds: RawMaterialKind[], + actingUserId: string, + ): Promise { + await this.stock.recordChange( + tx, + { bottlingBatchIds: [], productIds: [batch.productId], rawMaterialKinds }, + actingUserId, + { type: 'ProductionBatch', id: batch.id }, + ); + } } diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.service.spec.ts b/ApiCore-Backend/src/raw-materials/raw-materials.service.spec.ts new file mode 100644 index 0000000..8f7563c --- /dev/null +++ b/ApiCore-Backend/src/raw-materials/raw-materials.service.spec.ts @@ -0,0 +1,205 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { RawMaterialsService } from './raw-materials.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { StockService } from '../stock/stock.service'; +import { AutomationService } from '../automation/automation.service'; + +/** + * Raw material lots: withdrawals and inventory counts go through the stock + * ledger, which records StockChanged with the lot's kind - the minimum-stock + * rule is evaluated by the automation.raw-material-low handler, not here. + * Corrections and removals decide on the row locked in their transaction. + */ +describe('RawMaterialsService', () => { + let service: RawMaterialsService; + let tx: { + $queryRaw: jest.Mock; + rawMaterialLot: { + findUnique: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + }; + let prisma: { + rawMaterialLot: { findUnique: jest.Mock }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + let stock: { + withdraw: jest.Mock; + correct: jest.Mock; + remainingByRawMaterialLot: jest.Mock; + remainingByRawMaterialKind: jest.Mock; + hasMovements: jest.Mock; + assertInitialCovers: jest.Mock; + }; + let automation: { + generateNumber: jest.Mock; + }; + let steps: string[]; + + const lot = { + id: 'lot-1', + kind: 'BEESWAX', + lotNumber: 'WA-1', + quantity: 1000, + usedIn: [], + }; + const bookedStock = { + summary: { initial: 1000, remaining: 900 }, + movements: [], + }; + + beforeEach(() => { + steps = []; + tx = { + $queryRaw: jest.fn(() => { + steps.push('lock'); + return Promise.resolve([]); + }), + rawMaterialLot: { + findUnique: jest.fn(() => { + steps.push('read'); + return Promise.resolve(lot); + }), + update: jest.fn().mockResolvedValue(lot), + delete: jest.fn().mockResolvedValue(lot), + }, + }; + prisma = { + rawMaterialLot: { findUnique: jest.fn().mockResolvedValue(lot) }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn() }; + stock = { + withdraw: jest.fn().mockResolvedValue(bookedStock), + correct: jest.fn().mockResolvedValue(bookedStock), + remainingByRawMaterialLot: jest.fn().mockResolvedValue(new Map()), + remainingByRawMaterialKind: jest.fn().mockResolvedValue(900), + hasMovements: jest.fn().mockResolvedValue(false), + assertInitialCovers: jest.fn(), + }; + automation = { + generateNumber: jest.fn().mockResolvedValue(null), + }; + service = new RawMaterialsService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + stock as unknown as StockService, + automation as unknown as AutomationService, + ); + }); + + it('withdraw books through the stock ledger and leaves the low-stock rule to its handler', async () => { + const result = await service.withdraw( + 'lot-1', + { quantity: 100, reason: 'SONSTIGES' }, + 'user-1', + ); + + expect(result).toBe(bookedStock); + expect(stock.withdraw).toHaveBeenCalledWith( + { kind: 'raw', id: 'lot-1' }, + { quantity: 100, reason: 'SONSTIGES' }, + 'user-1', + ); + expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled(); + }); + + it('correct books through the stock ledger and leaves the low-stock rule to its handler', async () => { + const result = await service.correct( + 'lot-1', + { countedQuantity: 900, notes: 'gewogen' }, + 'user-1', + ); + + expect(result).toBe(bookedStock); + expect(stock.correct).toHaveBeenCalledWith( + { kind: 'raw', id: 'lot-1' }, + { countedQuantity: 900, notes: 'gewogen' }, + 'user-1', + ); + }); + + it('update takes "before" from the row locked in its transaction and audits there', async () => { + // A read outside the transaction would see this stale copy. + prisma.rawMaterialLot.findUnique.mockResolvedValue({ + ...lot, + quantity: 5, + lotNumber: 'STALE', + }); + tx.rawMaterialLot.findUnique + .mockImplementationOnce(() => { + steps.push('read'); + return Promise.resolve(lot); + }) + .mockImplementationOnce(() => Promise.resolve({ ...lot, quantity: 950 })); + + const result = await service.update('lot-1', { quantity: 950 }, 'user-1'); + + expect(steps.slice(0, 2)).toEqual(['lock', 'read']); + expect(prisma.rawMaterialLot.findUnique).not.toHaveBeenCalled(); + expect(stock.assertInitialCovers).toHaveBeenCalledWith( + tx, + { kind: 'raw', id: 'lot-1' }, + 950, + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'RawMaterialLot', + action: 'UPDATE', + beforeData: { ...lot, remainingCount: 1000 }, + afterData: { ...lot, quantity: 950, remainingCount: 950 }, + }), + tx, + ); + expect(result).toEqual({ ...lot, quantity: 950, remainingCount: 950 }); + }); + + it('update refuses a quantity below what was already booked and audits nothing', async () => { + stock.assertInitialCovers.mockRejectedValueOnce( + new BadRequestException('zu wenig'), + ); + + await expect( + service.update('lot-1', { quantity: 50 }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.rawMaterialLot.update).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + }); + + it('remove checks movements under the row lock and audits in its transaction', async () => { + const result = await service.remove('lot-1', 'user-1'); + + expect(result).toEqual({ id: 'lot-1' }); + expect(steps.slice(0, 2)).toEqual(['lock', 'read']); + expect(stock.hasMovements).toHaveBeenCalledWith( + { kind: 'raw', id: 'lot-1' }, + tx, + ); + expect(tx.rawMaterialLot.delete).toHaveBeenCalledWith({ + where: { id: 'lot-1' }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'RawMaterialLot', + action: 'DELETE', + beforeData: { ...lot, remainingCount: 1000 }, + }), + tx, + ); + }); + + it('remove keeps a lot with movements as history', async () => { + stock.hasMovements.mockResolvedValueOnce(true); + + await expect(service.remove('lot-1', 'user-1')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(tx.rawMaterialLot.delete).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts index f59e474..8bf9e71 100644 --- a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts +++ b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts @@ -5,13 +5,15 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { DbClient } from '../prisma/db-client'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { AuditLogService } from '../audit/audit-log.service'; import { AutomationService, RAW_MATERIAL_KIND_CODES, } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; -import { RawMaterialKind } from '../../generated/prisma'; +import { Prisma, RawMaterialKind } from '../../generated/prisma'; import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto'; import { CorrectStockDto } from '../stock/dto/correct-stock.dto'; import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto'; @@ -41,10 +43,14 @@ export interface RawMaterialLotFilter { kind?: RawMaterialKind; } +type Tx = Prisma.TransactionClient; + /** * Raw material lots (beeswax, propolis) in grams. Their stock lives in the * same movement ledger as bottlings and production batches: withdrawals and * inventory counts by hand, consumption when a production batch is made. + * The ledger records StockChanged with the lot's kind; the minimum-stock + * rule runs in the automation.raw-material-low handler. */ @Injectable() export class RawMaterialsService { @@ -70,13 +76,15 @@ export class RawMaterialsService { })); } - async findById(id: string) { - const lot = await this.prisma.rawMaterialLot.findUnique({ + /** With `tx` it reads what the caller's transaction sees. */ + async findById(id: string, tx?: Tx) { + const db: DbClient = tx ?? this.prisma; + const lot = await db.rawMaterialLot.findUnique({ where: { id }, include, }); if (!lot) throw new NotFoundException('Raw material lot not found'); - const remaining = await this.stock.remainingByRawMaterialLot([id]); + const remaining = await this.stock.remainingByRawMaterialLot([id], tx); return { ...lot, remainingCount: remaining.get(id) ?? lot.quantity }; } @@ -117,8 +125,8 @@ export class RawMaterialsService { } async update(id: string, dto: UpdateRawMaterialLotDto, actingUserId: string) { - const before = await this.findById(id); - await this.prisma.$transaction(async (tx) => { + return this.prisma.$transaction(async (tx) => { + const before = await this.lockLot(tx, id); if (dto.quantity !== undefined && dto.quantity !== before.quantity) { await this.stock.assertInitialCovers( tx, @@ -137,63 +145,60 @@ export class RawMaterialsService { notes: dto.notes, }, }); + const after = await this.findById(id, tx); + await this.auditLog.record( + { + entityType: 'RawMaterialLot', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: after, + }, + tx, + ); + return after; }); - const after = await this.findById(id); - await this.auditLog.record({ - entityType: 'RawMaterialLot', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: after, - }); - return after; } + /** The ledger locks the lot (404 if unknown) and records StockChanged. */ async withdraw(id: string, dto: WithdrawStockDto, actingUserId: string) { - const lot = await this.findById(id); - const stock = await this.stock.withdraw( - { kind: 'raw', id }, - dto, - actingUserId, - ); - await this.checkLowStock(lot.kind); - return stock; + return this.stock.withdraw({ kind: 'raw', id }, dto, actingUserId); } async correct(id: string, dto: CorrectStockDto, actingUserId: string) { - const lot = await this.findById(id); - const stock = await this.stock.correct( - { kind: 'raw', id }, - dto, - actingUserId, - ); - await this.checkLowStock(lot.kind); - return stock; - } - - /** Runs the RAW_MATERIAL_LOW rule against what is left of the kind. */ - private async checkLowStock(kind: RawMaterialKind): Promise { - const remaining = await this.stock.remainingByRawMaterialKind(kind); - await this.automation.evaluateRawMaterialLow(kind, remaining); + return this.stock.correct({ kind: 'raw', id }, dto, actingUserId); } /** Only for lots entered by mistake: once anything was booked, it stays. */ async remove(id: string, actingUserId: string) { - const before = await this.findById(id); - if (await this.stock.hasMovements({ kind: 'raw', id })) { - throw new ConflictException( - 'Rohstoff-Charge hat bereits Bewegungen (Verbrauch, Entnahme oder Inventur) und kann nicht gelöscht werden', + await this.prisma.$transaction(async (tx) => { + // Consumption and withdrawals lock the same row, so the movement + // check cannot be raced. + const before = await this.lockLot(tx, id); + if (await this.stock.hasMovements({ kind: 'raw', id }, tx)) { + throw new ConflictException( + 'Rohstoff-Charge hat bereits Bewegungen (Verbrauch, Entnahme oder Inventur) und kann nicht gelöscht werden', + ); + } + await tx.rawMaterialLot.delete({ where: { id } }); + await this.auditLog.record( + { + entityType: 'RawMaterialLot', + entityId: id, + action: 'DELETE', + userId: actingUserId, + beforeData: before, + }, + tx, ); - } - await this.prisma.rawMaterialLot.delete({ where: { id } }); - await this.auditLog.record({ - entityType: 'RawMaterialLot', - entityId: id, - action: 'DELETE', - userId: actingUserId, - beforeData: before, }); return { id }; } + + /** Locks the lot and re-reads it - use the result, not an earlier read, as "before". */ + private async lockLot(tx: Tx, id: string) { + await lockRowForUpdate(tx, 'RawMaterialLot', id); + return this.findById(id, tx); + } } diff --git a/ApiCore-Backend/src/runtime/no-in-process-schedulers.spec.ts b/ApiCore-Backend/src/runtime/no-in-process-schedulers.spec.ts new file mode 100644 index 0000000..a880f15 --- /dev/null +++ b/ApiCore-Backend/src/runtime/no-in-process-schedulers.spec.ts @@ -0,0 +1,54 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +// Spec section 9 guard: recurring work runs as Kubernetes CronJobs through +// dist/cli.js, never as a timer inside an api or worker process. +const SRC = join(__dirname, '..'); +const PACKAGE_JSON = join(__dirname, '..', '..', 'package.json'); + +function productionSources(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + return productionSources(path); + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts') + ? [path] + : []; + }); +} + +function withoutComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +function offenders(pattern: RegExp): string[] { + return productionSources(SRC) + .filter((file) => pattern.test(withoutComments(readFileSync(file, 'utf8')))) + .map((file) => relative(SRC, file).split(sep).join('/')) + .sort(); +} + +describe('no in-process schedulers', () => { + it('has no @Cron/@Interval/@Timeout, SchedulerRegistry, ScheduleModule or setInterval in src', () => { + expect( + offenders( + /@(Cron|Interval|Timeout)\(|SchedulerRegistry|ScheduleModule|setInterval\(|@nestjs\/schedule/, + ), + ).toEqual([]); + }); + + it('no longer depends on @nestjs/schedule', () => { + const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) as { + dependencies: Record; + }; + expect(pkg.dependencies['@nestjs/schedule']).toBeUndefined(); + }); + + it('depends on cron directly, for the cron-expression parsing in the backup schedule', () => { + const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) as { + dependencies: Record; + }; + expect(pkg.dependencies['cron']).toBeDefined(); + }); +}); diff --git a/ApiCore-Backend/src/runtime/root-module.spec.ts b/ApiCore-Backend/src/runtime/root-module.spec.ts index 6471944..4cb1176 100644 --- a/ApiCore-Backend/src/runtime/root-module.spec.ts +++ b/ApiCore-Backend/src/runtime/root-module.spec.ts @@ -1,8 +1,10 @@ -import { forwardRef, Module } from '@nestjs/common'; +import { forwardRef, Module, Type } from '@nestjs/common'; import { DiscoveryModule } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; import { AllInOneModule } from '../all-in-one.module'; import { AppModule } from '../app.module'; import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module'; +import { DomainEventRegistry } from '../domain-events/domain-event-registry'; import { WorkerModule } from '../worker/worker.module'; import { WorkerRuntimeModule } from '../worker/worker-runtime.module'; import { isHttpRole, rootModuleFor } from './root-module'; @@ -10,6 +12,24 @@ import { isHttpRole, rootModuleFor } from './root-module'; const importsOf = (module: object): unknown[] => (Reflect.getMetadata('imports', module) as unknown[] | undefined) ?? []; +/** + * The handler ids the DomainEventRegistry discovers under `root`. Compiled + * only - no init, so nothing connects or starts a loop; the registry's + * boot scan runs by hand on the constructed providers. + */ +async function discoveredHandlerIds(root: Type): Promise { + const moduleRef = await Test.createTestingModule({ + imports: [root], + }).compile(); + try { + const registry = moduleRef.get(DomainEventRegistry); + registry.onModuleInit(); + return registry.handlerIds(); + } finally { + await moduleRef.close(); + } +} + /** * Every module class reachable from `root` through imports - including * the imports of dynamic modules (also async ones like @@ -76,6 +96,16 @@ describe('rootModuleFor', () => { expect(reachable).toContain(WorkerRuntimeModule); expect(reachable).not.toContain(AppModule); }); + + // Only the worker dispatches events. A handler in a module the worker + // does not import would be registered by the API and never run. + it('the worker discovers exactly the domain event handlers of the API', async () => { + const api = await discoveredHandlerIds(AppModule); + const worker = await discoveredHandlerIds(WorkerModule); + + expect(api).toContain('webhooks.forward'); + expect(worker).toEqual(api); + }, 30_000); }); describe('reachableModules', () => { diff --git a/ApiCore-Backend/src/stock/stock.service.spec.ts b/ApiCore-Backend/src/stock/stock.service.spec.ts new file mode 100644 index 0000000..0cbb2cc --- /dev/null +++ b/ApiCore-Backend/src/stock/stock.service.spec.ts @@ -0,0 +1,517 @@ +import { BadRequestException } from '@nestjs/common'; +import { StockService, type ShopStockRequestInput } from './stock.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; + +interface StoredRequest { + id: string; + externalId: string; + kind: 'SALE' | 'RETURN'; + bottlingBatchId: string | null; + productId: string | null; + movements: { + bottlingBatchId: string | null; + productionBatchId: string | null; + quantity: number; + bottlingBatch: { lotNumber: string } | null; + productionBatch: { lotNumber: string } | null; + }[]; +} + +/** + * The movement ledger books and announces in one transaction: the movement + * (or shop booking), its audit entry and StockChanged commit together or not + * at all. Prisma is replaced by plain mocks; `tx` is what $transaction hands + * the callback, so "in the transaction" means "written through tx". + */ +describe('StockService', () => { + let service: StockService; + let tx: { + $queryRaw: jest.Mock; + batchMovement: { + create: jest.Mock; + createMany: jest.Mock; + groupBy: jest.Mock; + }; + bottlingBatch: { findUnique: jest.Mock }; + productionBatch: { findUnique: jest.Mock; findUniqueOrThrow: jest.Mock }; + rawMaterialLot: { findUnique: jest.Mock; findUniqueOrThrow: jest.Mock }; + productionInput: { create: jest.Mock; findMany: jest.Mock }; + shopStockRequest: { create: jest.Mock }; + }; + let prisma: { + bottlingBatch: { findUnique: jest.Mock; findMany: jest.Mock }; + productionBatch: { findUnique: jest.Mock }; + rawMaterialLot: { findUnique: jest.Mock }; + batchMovement: { findMany: jest.Mock; groupBy: jest.Mock }; + shopStockRequest: { findUnique: jest.Mock }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + let events: { record: jest.Mock }; + let storedRequests: Map; + + const lots: Record = { + 'lot-a': { quantity: 500, kind: 'BEESWAX' }, + 'lot-b': { quantity: 200, kind: 'PROPOLIS' }, + }; + + beforeEach(() => { + storedRequests = new Map(); + tx = { + $queryRaw: jest.fn().mockResolvedValue([{ id: 'locked' }]), + batchMovement: { + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ id: 'movement-1', ...data }), + ), + createMany: jest.fn().mockResolvedValue({ count: 1 }), + groupBy: jest.fn().mockResolvedValue([]), + }, + bottlingBatch: { + findUnique: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve( + where.id.startsWith('bottling') + ? { id: where.id, jarCount: 10 } + : null, + ), + ), + }, + productionBatch: { + findUnique: jest + .fn() + .mockResolvedValue({ productId: 'product-1', quantity: 5 }), + findUniqueOrThrow: jest + .fn() + .mockResolvedValue({ productId: 'product-1', quantity: 5 }), + }, + rawMaterialLot: { + findUnique: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve(lots[where.id] ?? null), + ), + findUniqueOrThrow: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve({ lotNumber: `${where.id}-nr`, ...lots[where.id] }), + ), + }, + productionInput: { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + shopStockRequest: { + create: jest.fn( + ({ data }: { data: Omit }) => { + const stored: StoredRequest = { + id: `request-${data.externalId}`, + externalId: data.externalId, + kind: data.kind, + bottlingBatchId: data.bottlingBatchId ?? null, + productId: data.productId ?? null, + movements: [], + }; + storedRequests.set(data.externalId, stored); + return Promise.resolve(stored); + }, + ), + }, + }; + prisma = { + bottlingBatch: { + findUnique: jest.fn().mockResolvedValue({ jarCount: 10 }), + findMany: jest.fn(({ where }: { where: { id: { in: string[] } } }) => + Promise.resolve(where.id.in.map((id) => ({ id, jarCount: 10 }))), + ), + }, + productionBatch: { + findUnique: jest.fn().mockResolvedValue({ quantity: 5 }), + }, + rawMaterialLot: { + findUnique: jest.fn().mockResolvedValue({ quantity: 500 }), + }, + batchMovement: { + findMany: jest.fn().mockResolvedValue([]), + groupBy: jest.fn().mockResolvedValue([]), + }, + shopStockRequest: { + findUnique: jest.fn(({ where }: { where: { externalId: string } }) => + Promise.resolve(storedRequests.get(where.externalId) ?? null), + ), + }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn() }; + events = { record: jest.fn() }; + service = new StockService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + events as unknown as DomainEventsService, + ); + }); + + describe('withdraw and correct', () => { + it('withdraw books the movement, the audit entry and StockChanged in one transaction', async () => { + await service.withdraw( + { kind: 'bottling', id: 'bottling-1' }, + { quantity: 2, reason: 'HOFLADEN' }, + 'user-1', + ); + + expect(tx.batchMovement.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + bottlingBatchId: 'bottling-1', + type: 'WITHDRAWAL', + quantity: -2, + userId: 'user-1', + }) as unknown, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'BatchMovement', + entityId: 'movement-1', + action: 'CREATE', + userId: 'user-1', + }), + tx, + ); + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'StockChanged', + aggregateType: 'BatchMovement', + aggregateId: 'movement-1', + payload: { + bottlingBatchIds: ['bottling-1'], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: 'user-1', + }); + }); + + it('a refused withdrawal books nothing and records no event', async () => { + await expect( + service.withdraw( + { kind: 'bottling', id: 'bottling-1' }, + { quantity: 11, reason: 'BRUCH' }, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.batchMovement.create).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + + it('a raw material withdrawal records StockChanged with the lot kind and no shop ids', async () => { + await service.withdraw( + { kind: 'raw', id: 'lot-a' }, + { quantity: 100, reason: 'SONSTIGES' }, + 'user-1', + ); + + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'StockChanged', + aggregateType: 'BatchMovement', + payload: { + bottlingBatchIds: [], + productIds: [], + rawMaterialKinds: ['BEESWAX'], + }, + }), + ); + }); + + it('an inventory correction of a production batch records StockChanged for its article', async () => { + await service.correct( + { kind: 'production', id: 'batch-1' }, + { countedQuantity: 3, notes: 'gezählt' }, + 'user-1', + ); + + expect(tx.batchMovement.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + productionBatchId: 'batch-1', + type: 'CORRECTION', + quantity: -2, + }) as unknown, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'BatchMovement' }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'StockChanged', + aggregateType: 'BatchMovement', + aggregateId: 'movement-1', + payload: { + bottlingBatchIds: [], + productIds: ['product-1'], + rawMaterialKinds: [], + }, + actorUserId: 'user-1', + }); + }); + }); + + describe('shop bookings', () => { + function sale( + externalId: string, + target: Partial, + ): ShopStockRequestInput { + return { externalId, kind: 'SALE', quantity: 1, ...target }; + } + + it('a shop request records one StockChanged per accepted booking', async () => { + const results = await service.applyShopRequests( + [ + sale('s-1', { bottlingBatchId: 'bottling-1' }), + // Rejected: names both a bottling and an article. + sale('s-2', { bottlingBatchId: 'bottling-1', productId: 'p-1' }), + // Rejected: unknown bottling. + sale('s-3', { bottlingBatchId: 'unknown' }), + sale('s-4', { bottlingBatchId: 'bottling-2' }), + ], + 'api-key-1', + ); + + expect(results.map((r) => r.status)).toEqual([ + 'created', + 'rejected', + 'rejected', + 'created', + ]); + expect(events.record).toHaveBeenCalledTimes(2); + expect(events.record).toHaveBeenNthCalledWith(1, tx, { + type: 'StockChanged', + aggregateType: 'ShopStockRequest', + aggregateId: 'request-s-1', + payload: { + bottlingBatchIds: ['bottling-1'], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: null, + }); + expect(events.record).toHaveBeenNthCalledWith(2, tx, { + type: 'StockChanged', + aggregateType: 'ShopStockRequest', + aggregateId: 'request-s-4', + payload: { + bottlingBatchIds: ['bottling-2'], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: null, + }); + }); + + it('a repeated shop request books nothing and records no event', async () => { + storedRequests.set('s-1', { + id: 'request-s-1', + externalId: 's-1', + kind: 'SALE', + bottlingBatchId: 'bottling-1', + productId: null, + movements: [], + }); + + const [result] = await service.applyShopRequests( + [sale('s-1', { bottlingBatchId: 'bottling-1' })], + 'api-key-1', + ); + + expect(result.status).toBe('duplicate'); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + }); + + describe('stockChangedEvent', () => { + it('builds the event once per id and kind, and nothing for an empty change', () => { + expect( + service.stockChangedEvent( + { bottlingBatchIds: [], productIds: [], rawMaterialKinds: [] }, + 'user-1', + { type: 'BottlingBatch', id: 'b-1' }, + ), + ).toBeNull(); + expect( + service.stockChangedEvent( + { + bottlingBatchIds: ['b-1', 'b-1'], + productIds: [], + rawMaterialKinds: [], + }, + 'user-1', + { type: 'BottlingBatch', id: 'b-1' }, + ), + ).toEqual({ + type: 'StockChanged', + aggregateType: 'BottlingBatch', + aggregateId: 'b-1', + payload: { + bottlingBatchIds: ['b-1'], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: 'user-1', + }); + // A builder only: nothing is written. + expect(events.record).not.toHaveBeenCalled(); + }); + }); + + describe('recordChange', () => { + it('records nothing for an empty change', async () => { + await service.recordChange( + tx as never, + { bottlingBatchIds: [], productIds: [], rawMaterialKinds: [] }, + 'user-1', + { type: 'ProductionBatch', id: 'batch-1' }, + ); + expect(events.record).not.toHaveBeenCalled(); + }); + + it('records each id and kind once', async () => { + await service.recordChange( + tx as never, + { + bottlingBatchIds: [], + productIds: ['p-1', 'p-1'], + rawMaterialKinds: ['BEESWAX', 'PROPOLIS', 'BEESWAX'], + }, + null, + { type: 'ProductionBatch', id: 'batch-1' }, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'StockChanged', + aggregateType: 'ProductionBatch', + aggregateId: 'batch-1', + payload: { + bottlingBatchIds: [], + productIds: ['p-1'], + rawMaterialKinds: ['BEESWAX', 'PROPOLIS'], + }, + actorUserId: null, + }); + }); + + it('refuses the root client, so the event cannot commit on its own', async () => { + await expect( + service.recordChange( + // Only the root client has $connect. + { ...prisma, $connect: jest.fn() } as never, + { bottlingBatchIds: ['b-1'], productIds: [], rawMaterialKinds: [] }, + null, + { type: 'BottlingBatch', id: 'b-1' }, + ), + ).rejects.toThrow( + 'StockService.recordChange needs the transaction client', + ); + }); + }); + + describe('production inputs', () => { + it('consumeForProduction books every lot and returns the kinds it drew on', async () => { + const kinds = await service.consumeForProduction( + tx as never, + 'batch-1', + 'HS-1', + [ + { rawMaterialLotId: 'lot-b', quantity: 50 }, + { rawMaterialLotId: 'lot-a', quantity: 100 }, + { rawMaterialLotId: 'lot-a', quantity: 25 }, + ], + 'user-1', + ); + + expect([...kinds].sort()).toEqual(['BEESWAX', 'PROPOLIS']); + expect(tx.productionInput.create).toHaveBeenCalledTimes(2); + expect(tx.batchMovement.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + rawMaterialLotId: 'lot-a', + type: 'CONSUMPTION', + quantity: -125, + }) as unknown, + }); + }); + + it('consumeForProduction refuses more than a lot holds', async () => { + await expect( + service.consumeForProduction( + tx as never, + 'batch-1', + 'HS-1', + [{ rawMaterialLotId: 'lot-b', quantity: 201 }], + 'user-1', + ), + ).rejects.toThrow('Rohstoff lot-b-nr: nur noch 200 g vorhanden, 201 g'); + }); + + it('returnProductionInputs gives every input back and returns their kinds', async () => { + tx.productionInput.findMany.mockResolvedValueOnce([ + { + rawMaterialLotId: 'lot-a', + quantity: 100, + rawMaterialLot: { kind: 'BEESWAX' }, + }, + { + rawMaterialLotId: 'lot-c', + quantity: 30, + rawMaterialLot: { kind: 'BEESWAX' }, + }, + { + rawMaterialLotId: 'lot-b', + quantity: 50, + rawMaterialLot: { kind: 'PROPOLIS' }, + }, + ]); + + const kinds = await service.returnProductionInputs( + tx as never, + 'batch-1', + 'HS-1', + 'user-1', + ); + + expect([...kinds].sort()).toEqual(['BEESWAX', 'PROPOLIS']); + expect(tx.batchMovement.create).toHaveBeenCalledTimes(3); + expect(tx.batchMovement.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + rawMaterialLotId: 'lot-a', + type: 'CONSUMPTION', + quantity: 100, + notes: 'Rückgabe: Charge HS-1 gelöscht', + }) as unknown, + }); + }); + }); + + describe('lock', () => { + const lockedSql = () => + (tx.$queryRaw.mock.calls as [[{ sql: string }]]).map( + ([query]) => query.sql, + ); + + it('locks the bottling, the raw lot and (for a batch) its Product through lockRowForUpdate', async () => { + await service.lock(tx as never, { kind: 'bottling', id: 'bottling-1' }); + await service.lock(tx as never, { kind: 'raw', id: 'lot-a' }); + await service.lock(tx as never, { kind: 'production', id: 'batch-1' }); + + expect(lockedSql()).toEqual([ + 'SELECT id FROM "BottlingBatch" WHERE id = ? FOR UPDATE', + 'SELECT id FROM "RawMaterialLot" WHERE id = ? FOR UPDATE', + 'SELECT id FROM "Product" WHERE id = ? FOR UPDATE', + ]); + }); + + it('throws NotFound when the raw lot is gone', async () => { + tx.$queryRaw.mockResolvedValue([]); + + await expect( + service.lock(tx as never, { kind: 'raw', id: 'gone' }), + ).rejects.toThrow('Raw material lot not found'); + }); + }); +}); diff --git a/ApiCore-Backend/src/stock/stock.service.ts b/ApiCore-Backend/src/stock/stock.service.ts index 2a2d4dd..773f55b 100644 --- a/ApiCore-Backend/src/stock/stock.service.ts +++ b/ApiCore-Backend/src/stock/stock.service.ts @@ -5,7 +5,10 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import type { NewDomainEvent } from '../domain-events/domain-event-types'; +import { assertTransactionClient, DbClient } from '../prisma/db-client'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { Prisma, RawMaterialKind, @@ -35,10 +38,22 @@ export interface ProductionInputRequest { quantity: number; } -/** What changed - the payload of the stock.changed webhook. */ +/** + * What changed - the payload of StockChanged. The shop hears about + * bottlings and articles (stock.changed webhook); the raw material kinds + * feed the minimum-stock rule (automation.raw-material-low). + */ export interface StockChange { bottlingBatchIds: string[]; productIds: string[]; + rawMaterialKinds: RawMaterialKind[]; +} + +/** The record a StockChanged event hangs on. */ +export interface StockChangeAggregate { + type: + 'BatchMovement' | 'BottlingBatch' | 'ProductionBatch' | 'ShopStockRequest'; + id: string; } const movementSelect = { @@ -115,30 +130,39 @@ export class StockService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly webhooks: WebhooksService, + private readonly events: DomainEventsService, ) {} // ------------------------------------------------------------------------- // Reading // ------------------------------------------------------------------------- - /** Remaining stock per bottling; all bottlings when `ids` is omitted. */ - async remainingByBottling(ids?: string[]): Promise> { - return this.remainingFor('bottling', ids); + /** + * Remaining stock per bottling; all bottlings when `ids` is omitted. With + * `tx` it reads what the caller's transaction sees (e.g. a corrected jar + * count before commit). + */ + async remainingByBottling( + ids?: string[], + tx?: Tx, + ): Promise> { + return this.remainingFor('bottling', ids, tx); } /** Remaining stock per production batch; all when `ids` is omitted. */ async remainingByProductionBatch( ids?: string[], + tx?: Tx, ): Promise> { - return this.remainingFor('production', ids); + return this.remainingFor('production', ids, tx); } /** Remaining grams per raw material lot; all when `ids` is omitted. */ async remainingByRawMaterialLot( ids?: string[], + tx?: Tx, ): Promise> { - return this.remainingFor('raw', ids); + return this.remainingFor('raw', ids, tx); } /** Remaining grams of one raw material kind, summed over all its lots. */ @@ -189,8 +213,10 @@ export class StockService { return { summary: summarizeStock(initial, movements), movements }; } - async hasMovements(target: StockTarget): Promise { - const count = await this.prisma.batchMovement.count({ + /** With `tx` (under the batch's lock) a concurrent booking cannot slip by. */ + async hasMovements(target: StockTarget, tx?: Tx): Promise { + const db: DbClient = tx ?? this.prisma; + const count = await db.batchMovement.count({ where: this.movementWhere(target), }); return count > 0; @@ -210,7 +236,7 @@ export class StockService { input: { quantity: number; reason: WithdrawalReason; notes?: string }, actingUserId: string, ): Promise { - const movement = await this.prisma.$transaction(async (tx) => { + await this.prisma.$transaction(async (tx) => { await this.lock(tx, target); const remaining = await this.remainingInTx(tx, target); if (input.quantity > remaining) { @@ -218,7 +244,7 @@ export class StockService { `Entnahme übersteigt den Rest (${remaining})`, ); } - return tx.batchMovement.create({ + const movement = await tx.batchMovement.create({ data: { ...this.movementTarget(target), type: 'WITHDRAWAL', @@ -228,8 +254,8 @@ export class StockService { userId: actingUserId, }, }); + await this.recordMovement(tx, target, movement, actingUserId); }); - await this.recordAndNotify(target, movement, actingUserId); return this.stockOf(target); } @@ -242,7 +268,7 @@ export class StockService { input: { countedQuantity: number; notes: string }, actingUserId: string, ): Promise { - const movement = await this.prisma.$transaction(async (tx) => { + await this.prisma.$transaction(async (tx) => { await this.lock(tx, target); const remaining = await this.remainingInTx(tx, target); const difference = input.countedQuantity - remaining; @@ -251,7 +277,7 @@ export class StockService { 'Gezählter Bestand entspricht dem gebuchten Rest', ); } - return tx.batchMovement.create({ + const movement = await tx.batchMovement.create({ data: { ...this.movementTarget(target), type: 'CORRECTION', @@ -260,8 +286,8 @@ export class StockService { userId: actingUserId, }, }); + await this.recordMovement(tx, target, movement, actingUserId); }); - await this.recordAndNotify(target, movement, actingUserId); return this.stockOf(target); } @@ -274,7 +300,8 @@ export class StockService { * caller's transaction, so the batch and its consumption exist together * or not at all. Never more than a lot's remaining stock: the person * producing can weigh again. Lots are locked in id order, so two - * productions drawing on the same lots cannot deadlock. + * productions drawing on the same lots cannot deadlock. Returns the raw + * material kinds drawn on - for the caller's StockChanged. */ async consumeForProduction( tx: Tx, @@ -282,7 +309,8 @@ export class StockService { lotNumber: string, inputs: ProductionInputRequest[], actingUserId: string, - ): Promise { + ): Promise { + const kinds = new Set(); const perLot = new Map(); for (const input of inputs) { perLot.set( @@ -295,16 +323,18 @@ export class StockService { )) { const target: StockTarget = { kind: 'raw', id: rawMaterialLotId }; await this.lock(tx, target); + // The lock throws for an unknown lot, so the row is there. + const lot = await tx.rawMaterialLot.findUniqueOrThrow({ + where: { id: rawMaterialLotId }, + select: { lotNumber: true, kind: true }, + }); const remaining = await this.remainingInTx(tx, target); if (quantity > remaining) { - const lot = await tx.rawMaterialLot.findUnique({ - where: { id: rawMaterialLotId }, - select: { lotNumber: true }, - }); throw new BadRequestException( - `Rohstoff ${lot?.lotNumber ?? rawMaterialLotId}: nur noch ${remaining} g vorhanden, ${quantity} g angegeben`, + `Rohstoff ${lot.lotNumber}: nur noch ${remaining} g vorhanden, ${quantity} g angegeben`, ); } + kinds.add(lot.kind); await tx.productionInput.create({ data: { productionBatchId, rawMaterialLotId, quantity }, }); @@ -318,6 +348,7 @@ export class StockService { }, }); } + return [...kinds]; } /** @@ -345,16 +376,22 @@ export class StockService { /** * Gives the raw material of a production batch back - only for a batch * deleted as a typo. The give-back is a movement of its own, so the raw - * lot's ledger still shows that it was booked and returned. + * lot's ledger still shows that it was booked and returned. Returns the + * raw material kinds given back - for the caller's StockChanged. */ async returnProductionInputs( tx: Tx, productionBatchId: string, lotNumber: string, actingUserId: string, - ): Promise { + ): Promise { const inputs = await tx.productionInput.findMany({ where: { productionBatchId }, + select: { + rawMaterialLotId: true, + quantity: true, + rawMaterialLot: { select: { kind: true } }, + }, }); for (const input of inputs) { await tx.batchMovement.create({ @@ -367,6 +404,7 @@ export class StockService { }, }); } + return [...new Set(inputs.map((input) => input.rawMaterialLot.kind))]; } // ------------------------------------------------------------------------- @@ -376,26 +414,16 @@ export class StockService { /** * Books the shop's sales and returns. Each request runs on its own, so one * invalid request does not hold back the rest of the batch. A request with - * a known externalId books nothing and reports the original result. + * a known externalId books nothing and reports the original result. Each + * accepted booking records its own StockChanged in its transaction. */ async applyShopRequests( requests: ShopStockRequestInput[], apiKeyId: string | null, ): Promise { const results: ShopStockResult[] = []; - const change: StockChange = { bottlingBatchIds: [], productIds: [] }; for (const request of requests) { - const result = await this.applyShopRequest(request, apiKeyId); - results.push(result); - if (result.status === 'created') { - if (request.bottlingBatchId) { - change.bottlingBatchIds.push(request.bottlingBatchId); - } - if (request.productId) change.productIds.push(request.productId); - } - } - if (change.bottlingBatchIds.length > 0 || change.productIds.length > 0) { - await this.notify(change); + results.push(await this.applyShopRequest(request, apiKeyId)); } return results; } @@ -441,6 +469,19 @@ export class StockService { apiKeyId, })), }); + // Booked by the shop's API key, not by a user. + await this.recordChange( + tx, + { + bottlingBatchIds: request.bottlingBatchId + ? [request.bottlingBatchId] + : [], + productIds: request.productId ? [request.productId] : [], + rawMaterialKinds: [], + }, + null, + { type: 'ShopStockRequest', id: created.id }, + ); }); } catch (error: unknown) { if (error instanceof RejectedRequest) { @@ -650,79 +691,140 @@ export class StockService { // Helpers // ------------------------------------------------------------------------- - /** Tells subscribers (the shop) that stock changed; they fetch the numbers. */ - async notify(change: StockChange): Promise { - // Raw material is not sold in the shop - nothing to tell. - if ( - change.bottlingBatchIds.length === 0 && - change.productIds.length === 0 - ) { - return; + /** + * Records StockChanged in the caller's transaction, so the booking and + * its announcement commit together. Nothing for an empty change. + * Receivers fetch the numbers themselves. + */ + async recordChange( + tx: Tx, + change: StockChange, + actingUserId: string | null, + aggregate: StockChangeAggregate, + ): Promise { + assertTransactionClient(tx, 'StockService.recordChange'); + const event = this.stockChangedEvent(change, actingUserId, aggregate); + if (event !== null) { + await this.events.record(tx, event); } - await this.webhooks.dispatch('stock.changed', { - bottlingBatchIds: [...new Set(change.bottlingBatchIds)], - productIds: [...new Set(change.productIds)], - }); } - /** The stock.changed payload for one batch. */ - async changeFor(target: StockTarget): Promise { + /** + * The StockChanged event of `change` - every id and kind once - or null + * when nothing changed. For a write that records it together with other + * events of the same action in one recordAll, so they share one + * correlation id; everyone else uses recordChange. + */ + stockChangedEvent( + change: StockChange, + actingUserId: string | null, + aggregate: StockChangeAggregate, + ): NewDomainEvent<'StockChanged'> | null { + const payload: StockChange = { + bottlingBatchIds: [...new Set(change.bottlingBatchIds)], + productIds: [...new Set(change.productIds)], + rawMaterialKinds: [...new Set(change.rawMaterialKinds)], + }; + if ( + payload.bottlingBatchIds.length === 0 && + payload.productIds.length === 0 && + payload.rawMaterialKinds.length === 0 + ) { + return null; + } + return { + type: 'StockChanged', + aggregateType: aggregate.type, + aggregateId: aggregate.id, + payload, + actorUserId: actingUserId, + }; + } + + /** What a movement on `target` changes: its bottling, article or raw kind. */ + private async changeOf(tx: Tx, target: StockTarget): Promise { if (target.kind === 'bottling') { - return { bottlingBatchIds: [target.id], productIds: [] }; + return { + bottlingBatchIds: [target.id], + productIds: [], + rawMaterialKinds: [], + }; } if (target.kind === 'raw') { - return { bottlingBatchIds: [], productIds: [] }; + const lot = await tx.rawMaterialLot.findUniqueOrThrow({ + where: { id: target.id }, + select: { kind: true }, + }); + return { + bottlingBatchIds: [], + productIds: [], + rawMaterialKinds: [lot.kind], + }; } - const batch = await this.prisma.productionBatch.findUnique({ + const batch = await tx.productionBatch.findUniqueOrThrow({ where: { id: target.id }, select: { productId: true }, }); - return { bottlingBatchIds: [], productIds: batch ? [batch.productId] : [] }; + return { + bottlingBatchIds: [], + productIds: [batch.productId], + rawMaterialKinds: [], + }; } - private async recordAndNotify( + /** Audit entry and StockChanged of a manual booking, in its transaction. */ + private async recordMovement( + tx: Tx, target: StockTarget, movement: { id: string }, actingUserId: string, ): Promise { - await this.auditLog.record({ - entityType: 'BatchMovement', - entityId: movement.id, - action: 'CREATE', - userId: actingUserId, - afterData: movement, + await this.auditLog.record( + { + entityType: 'BatchMovement', + entityId: movement.id, + action: 'CREATE', + userId: actingUserId, + afterData: movement, + }, + tx, + ); + await this.recordChange(tx, await this.changeOf(tx, target), actingUserId, { + type: 'BatchMovement', + id: movement.id, }); - await this.notify(await this.changeFor(target)); } private async remainingFor( kind: StockTarget['kind'], ids?: string[], + tx?: Tx, ): Promise> { + const db: DbClient = tx ?? this.prisma; const where = { id: ids ? { in: ids } : undefined }; const initial = kind === 'bottling' ? ( - await this.prisma.bottlingBatch.findMany({ + await db.bottlingBatch.findMany({ where, select: { id: true, jarCount: true }, }) ).map((row) => ({ id: row.id, initial: row.jarCount })) : kind === 'production' ? ( - await this.prisma.productionBatch.findMany({ + await db.productionBatch.findMany({ where, select: { id: true, quantity: true }, }) ).map((row) => ({ id: row.id, initial: row.quantity })) : ( - await this.prisma.rawMaterialLot.findMany({ + await db.rawMaterialLot.findMany({ where, select: { id: true, quantity: true }, }) ).map((row) => ({ id: row.id, initial: row.quantity })); const sums = await this.sumMovements( - this.prisma, + db, kind, initial.map((row) => row.id), ); @@ -732,7 +834,7 @@ export class StockService { } private async sumMovements( - db: Tx | PrismaService, + db: DbClient, kind: StockTarget['kind'], ids: string[], ): Promise> { @@ -774,7 +876,7 @@ export class StockService { } private async initialQuantity( - db: Tx | PrismaService, + db: DbClient, target: StockTarget, ): Promise { if (target.kind === 'bottling') { @@ -816,16 +918,20 @@ export class StockService { return { productionBatchId: target.id }; } - private async lock(tx: Tx, target: StockTarget): Promise { + /** + * Locks the row every booking on `target` serializes on: the bottling, + * the raw material lot - or, for a production batch, its article, since + * a shop sale allocates across all batches of the article. A caller that + * also locks the production batch row takes this lock first: a shop sale + * holds the article while its movement key-share locks the batch. + */ + async lock(tx: Tx, target: StockTarget): Promise { if (target.kind === 'bottling') { - await tx.$queryRaw`SELECT id FROM "BottlingBatch" WHERE id = ${target.id} FOR UPDATE`; + await lockRowForUpdate(tx, 'BottlingBatch', target.id); return; } if (target.kind === 'raw') { - const locked = await tx.$queryRaw< - { id: string }[] - >`SELECT id FROM "RawMaterialLot" WHERE id = ${target.id} FOR UPDATE`; - if (locked.length === 0) { + if (!(await lockRowForUpdate(tx, 'RawMaterialLot', target.id))) { throw new NotFoundException('Raw material lot not found'); } return; @@ -839,6 +945,6 @@ export class StockService { } private async lockProduct(tx: Tx, productId: string): Promise { - await tx.$queryRaw`SELECT id FROM "Product" WHERE id = ${productId} FOR UPDATE`; + await lockRowForUpdate(tx, 'Product', productId); } } diff --git a/ApiCore-Backend/src/system/dto/system-list-query.dto.ts b/ApiCore-Backend/src/system/dto/system-list-query.dto.ts new file mode 100644 index 0000000..8f943c6 --- /dev/null +++ b/ApiCore-Backend/src/system/dto/system-list-query.dto.ts @@ -0,0 +1,32 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { JOB_NAMES, type JobName } from '../../jobs/job-names'; + +export const SYSTEM_LIST_DEFAULT_LIMIT = 50; +export const SYSTEM_LIST_MAX_LIMIT = 100; + +export class SystemListQueryDto { + @ApiPropertyOptional({ + minimum: 1, + maximum: SYSTEM_LIST_MAX_LIMIT, + default: SYSTEM_LIST_DEFAULT_LIMIT, + description: 'Anzahl der neuesten Einträge', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(SYSTEM_LIST_MAX_LIMIT) + limit?: number; +} + +export class JobRunListQueryDto extends SystemListQueryDto { + @ApiPropertyOptional({ + enum: JOB_NAMES, + description: 'Nur Läufe dieses Jobs', + }) + @IsOptional() + @IsIn(JOB_NAMES) + job?: JobName; +} diff --git a/ApiCore-Backend/src/system/job-run.service.spec.ts b/ApiCore-Backend/src/system/job-run.service.spec.ts new file mode 100644 index 0000000..ec80719 --- /dev/null +++ b/ApiCore-Backend/src/system/job-run.service.spec.ts @@ -0,0 +1,287 @@ +import { Logger } from '@nestjs/common'; +import { JobRun, Prisma } from '../../generated/prisma'; +import { JOB_NAMES } from '../jobs/job-names'; +import { NotificationsService } from '../notifications/notifications.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { JOB_ABORTED_ERROR, JobRunService } from './job-run.service'; + +type RawCall = [readonly string[], ...unknown[]]; + +const STARTED_AT = new Date('2026-10-13T04:00:00.000Z'); + +function runRow(overrides: Partial = {}): JobRun { + return { + id: 'run-1', + job: 'daily-evaluation', + status: 'RUNNING', + startedAt: STARTED_AT, + finishedAt: null, + result: null, + error: null, + ...overrides, + }; +} + +describe('JobRunService', () => { + let jobRun: { + create: jest.Mock; + findMany: jest.Mock; + findFirst: jest.Mock; + }; + let tx: { + $executeRaw: jest.Mock, RawCall>; + $queryRaw: jest.Mock, RawCall>; + }; + let prisma: { + jobRun: typeof jobRun; + $transaction: jest.Mock; + $queryRaw: jest.Mock, RawCall>; + }; + let notifications: { recordForPermission: jest.Mock }; + let service: JobRunService; + let errorSpy: jest.SpyInstance; + + const sqlText = (call: RawCall): string => call[0].join('?'); + + beforeEach(() => { + jobRun = { + create: jest.fn(({ data }: { data: { job: string } }) => + Promise.resolve(runRow({ job: data.job })), + ), + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue(null), + }; + tx = { + $executeRaw: jest.fn, RawCall>().mockResolvedValue(1), + $queryRaw: jest + .fn, RawCall>() + .mockResolvedValue([{ notified: false }]), + }; + prisma = { + jobRun, + $transaction: jest.fn((work: (client: typeof tx) => Promise) => + work(tx), + ), + // Finishing a run: UPDATE … RETURNING *, the status is the first value. + $queryRaw: jest.fn, RawCall>((_sql, status) => + Promise.resolve([runRow({ status: status as JobRun['status'] })]), + ), + }; + notifications = { recordForPermission: jest.fn().mockResolvedValue(2) }; + service = new JobRunService( + prisma as unknown as PrismaService, + notifications as unknown as NotificationsService, + ); + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('run', () => { + it('records RUNNING first, then SUCCEEDED with the result', async () => { + const work = jest.fn(() => { + expect(jobRun.create).toHaveBeenCalledWith({ + data: { job: 'cleanup' }, + }); + return Promise.resolve({ deleted: 3, dryRun: false }); + }); + + const run = await service.run('cleanup', work); + + expect(work).toHaveBeenCalledTimes(1); + const [finish] = prisma.$queryRaw.mock.calls; + expect(finish.slice(1)).toEqual([ + 'SUCCEEDED', + '{"deleted":3,"dryRun":false}', + null, + 'run-1', + ]); + expect(run.status).toBe('SUCCEEDED'); + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + + it('records FAILED with the loggable message, never the Prisma message', async () => { + const run = await service.run('cleanup', () => + Promise.reject( + new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', { + code: 'P2002', + clientVersion: '7.9.1', + meta: { modelName: 'Task' }, + }), + ), + ); + + expect(run.status).toBe('FAILED'); + const [finish] = prisma.$queryRaw.mock.calls; + expect(finish.slice(1)).toEqual([ + 'FAILED', + null, + 'PrismaClientKnownRequestError P2002 (Task)', + 'run-1', + ]); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Mustermann'); + }); + + // finishedAt of every run - finished, failed or aborted - comes from + // the database clock, like the comparisons of cleanup. + it.each([ + ['succeeds', () => Promise.resolve({ deleted: 1 })], + ['fails', () => Promise.reject(new Error('boom'))], + ])( + 'sets finishedAt to now() when the run %s', + async (_case, work: () => Promise>) => { + await service.run('cleanup', work); + + const [finish] = prisma.$queryRaw.mock.calls; + expect(sqlText(finish)).toContain('"finishedAt" = now()'); + expect(sqlText(finish)).toContain('RETURNING *'); + expect(finish.slice(1).some((value) => value instanceof Date)).toBe( + false, + ); + }, + ); + + it('notifies holders of the job permission when a run fails', async () => { + await service.run('daily-evaluation', () => + Promise.reject(new Error('boom')), + ); + + expect(notifications.recordForPermission).toHaveBeenCalledWith( + 'automation:update', + { + priority: 'WARNUNG', + message: 'Job „Tägliche Auswertung“ fehlgeschlagen', + relatedType: 'JobRun', + relatedId: 'run-1', + }, + tx, + ); + }); + + // The hourly app-backup check would otherwise report one outage every + // hour. The check and the write share a transaction behind a lock per + // job, so two failing runs at once cannot both pass the check. + it('notifies at most once per job and UTC day, decided by the database clock', async () => { + tx.$queryRaw.mockResolvedValue([{ notified: true }]); + + await service.run('app-backup', () => Promise.reject(new Error('boom'))); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + const lock = tx.$executeRaw.mock.calls[0]; + expect(sqlText(lock)).toContain('pg_advisory_xact_lock'); + expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']); + const check = tx.$queryRaw.mock.calls[0]; + expect(sqlText(check)).toContain(`date_trunc('day', now())`); + expect(check.slice(1)).toEqual(['JobRun', 'app-backup']); + expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan( + tx.$queryRaw.mock.invocationCallOrder[0], + ); + }); + + it('still returns the FAILED run when the notification cannot be written', async () => { + prisma.$transaction.mockRejectedValue(new Error('deadlock detected')); + + const run = await service.run('cleanup', () => + Promise.reject(new Error('boom')), + ); + + expect(run.status).toBe('FAILED'); + expect(JSON.stringify(errorSpy.mock.calls)).toContain( + 'deadlock detected', + ); + }); + }); + + describe('recent', () => { + it('returns the newest runs, optionally of one job', async () => { + await service.recent(20, 'cleanup'); + await service.recent(5); + + expect(jobRun.findMany).toHaveBeenNthCalledWith(1, { + where: { job: 'cleanup' }, + orderBy: [{ startedAt: 'desc' }, { id: 'desc' }], + take: 20, + }); + expect(jobRun.findMany).toHaveBeenNthCalledWith(2, { + where: {}, + orderBy: [{ startedAt: 'desc' }, { id: 'desc' }], + take: 5, + }); + }); + + it.each([0, -1, 1.5])('refuses the limit %p', async (limit) => { + await expect(service.recent(limit)).rejects.toThrow(RangeError); + }); + }); + + describe('latestPerJob', () => { + it('returns the newest run of every job, null for a job that never ran', async () => { + const cleanupRun = runRow({ job: 'cleanup', status: 'SUCCEEDED' }); + jobRun.findFirst.mockImplementation( + ({ where }: { where: { job: string } }) => + Promise.resolve(where.job === 'cleanup' ? cleanupRun : null), + ); + + const latest = await service.latestPerJob(); + + expect(Object.keys(latest)).toEqual([...JOB_NAMES]); + expect(latest.cleanup).toBe(cleanupRun); + expect(latest.rekey).toBeNull(); + expect(jobRun.findFirst).toHaveBeenCalledWith({ + where: { job: 'cleanup' }, + orderBy: [{ startedAt: 'desc' }, { id: 'desc' }], + }); + }); + }); + + describe('abortStale', () => { + it('marks runs RUNNING longer than the window as FAILED, by the database clock', async () => { + prisma.$queryRaw.mockResolvedValue([]); + + await expect(service.abortStale(6)).resolves.toBe(0); + + const [call] = prisma.$queryRaw.mock.calls; + const sql = sqlText(call); + expect(sql).toContain(`"status" = 'RUNNING'`); + expect(sql).toContain('"startedAt" < now()'); + expect(call.slice(1)).toEqual([JOB_ABORTED_ERROR, 6]); + expect(call.slice(1).some((value) => value instanceof Date)).toBe(false); + }); + + it('sends the failure notification once per job of the aborted runs', async () => { + prisma.$queryRaw.mockResolvedValue([ + { id: 'a', job: 'app-backup' }, + { id: 'b', job: 'app-backup' }, + { id: 'c', job: 'task-templates' }, + ]); + + await expect(service.abortStale(6)).resolves.toBe(3); + + expect(notifications.recordForPermission).toHaveBeenCalledTimes(2); + expect(notifications.recordForPermission).toHaveBeenCalledWith( + 'backup:configure', + expect.objectContaining({ + message: 'Job „App-Backup“ fehlgeschlagen', + relatedId: 'a', + }), + tx, + ); + expect(notifications.recordForPermission).toHaveBeenCalledWith( + 'automation:update', + expect.objectContaining({ relatedId: 'c' }), + tx, + ); + }); + + it('aborts but does not notify for a job name that no longer exists', async () => { + prisma.$queryRaw.mockResolvedValue([{ id: 'x', job: 'retired-job' }]); + + await expect(service.abortStale(6)).resolves.toBe(1); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ApiCore-Backend/src/system/job-run.service.ts b/ApiCore-Backend/src/system/job-run.service.ts new file mode 100644 index 0000000..8de0be2 --- /dev/null +++ b/ApiCore-Backend/src/system/job-run.service.ts @@ -0,0 +1,187 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { JobRun } from '../../generated/prisma'; +import { loggableError } from '../common/loggable-error'; +import { JobResult } from '../jobs/job'; +import { + isJobName, + JOB_FAILURE_PERMISSION, + JOB_LABELS_DE, + JOB_NAMES, + JobName, +} from '../jobs/job-names'; +import { NotificationsService } from '../notifications/notifications.service'; +import { PrismaService } from '../prisma/prisma.service'; + +/** JobRun.error of a run whose pod ended before it wrote a result. */ +export const JOB_ABORTED_ERROR = 'aborted (pod ended without result)'; + +/** Notification.relatedType of a job failure; relatedId is the JobRun id. */ +const JOB_RUN_RELATED_TYPE = 'JobRun'; + +const NEWEST_FIRST = [{ startedAt: 'desc' as const }, { id: 'desc' as const }]; + +type RunOutcome = + | { status: 'SUCCEEDED'; result: JobResult } + | { status: 'FAILED'; error: string }; + +/** + * Bookkeeping of the CronJob runs: every run is one JobRun row (RUNNING, + * then SUCCEEDED or FAILED), and a failed run notifies the holders of the + * job's permission (JOB_FAILURE_PERMISSION). + */ +@Injectable() +export class JobRunService { + private readonly logger = new Logger(JobRunService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + ) {} + + /** + * Runs `work` as one run of `job`. Resolves with the finished JobRun - a + * failure of `work` is recorded, not rethrown. Throws only if the run + * itself cannot be recorded. + */ + async run(job: JobName, work: () => Promise): Promise { + const started = await this.prisma.jobRun.create({ data: { job } }); + let result: JobResult; + try { + result = await work(); + } catch (error: unknown) { + return this.fail(job, started.id, error); + } + this.logger.log(`Job "${job}" succeeded: ${JSON.stringify(result)}`); + return this.finish(started.id, { status: 'SUCCEEDED', result }); + } + + /** The newest runs, of one job or of all. */ + async recent(limit: number, job?: JobName): Promise { + if (!Number.isInteger(limit) || limit < 1) { + throw new RangeError(`Limit must be a positive integer, got ${limit}`); + } + return this.prisma.jobRun.findMany({ + where: job ? { job } : {}, + orderBy: NEWEST_FIRST, + take: limit, + }); + } + + /** The newest run of every job, null for a job that never ran. */ + async latestPerJob(): Promise> { + const latest = await Promise.all( + JOB_NAMES.map((job) => + this.prisma.jobRun.findFirst({ where: { job }, orderBy: NEWEST_FIRST }), + ), + ); + return Object.fromEntries( + JOB_NAMES.map((job, index) => [job, latest[index]]), + ) as Record; + } + + /** + * Marks runs that are RUNNING for longer than `staleHours` as FAILED: + * their pod ended without a result (activeDeadlineSeconds, OOM, node + * loss). finishedAt is when this was noticed. Each affected job gets the + * usual failure notification - otherwise a job that is killed every time + * would fail without anyone hearing about it. Compared against now(), the + * database clock. Returns how many runs were aborted. + */ + async abortStale(staleHours: number): Promise { + const aborted = await this.prisma.$queryRaw<{ id: string; job: string }[]>` + UPDATE "JobRun" + SET "status" = 'FAILED', "finishedAt" = now(), "error" = ${JOB_ABORTED_ERROR} + WHERE "status" = 'RUNNING' + AND "startedAt" < now() - (${staleHours} * interval '1 hour') + RETURNING "id", "job"`; + const firstRunPerJob = new Map(); + for (const { id, job } of aborted) { + if (isJobName(job) && !firstRunPerJob.has(job)) { + firstRunPerJob.set(job, id); + } + } + for (const [job, runId] of firstRunPerJob) { + await this.notifyFailure(job, runId); + } + return aborted.length; + } + + private async fail( + job: JobName, + runId: string, + error: unknown, + ): Promise { + const { message, stack } = loggableError(error); + this.logger.error(`Job "${job}" failed: ${message}`, stack); + const failed = await this.finish(runId, { + status: 'FAILED', + error: message, + }); + await this.notifyFailure(job, runId); + return failed; + } + + /** + * Ends a run. finishedAt is now(), the database clock - the same clock + * abortStale and every cleanup comparison use. + */ + private async finish(runId: string, outcome: RunOutcome): Promise { + const result = + outcome.status === 'SUCCEEDED' ? JSON.stringify(outcome.result) : null; + const error = outcome.status === 'FAILED' ? outcome.error : null; + const [finished] = await this.prisma.$queryRaw` + UPDATE "JobRun" + SET "status" = CAST(${outcome.status} AS "JobRunStatus"), + "finishedAt" = now(), + "result" = CAST(${result} AS jsonb), + "error" = ${error} + WHERE "id" = ${runId} + RETURNING *`; + if (!finished) { + throw new Error(`JobRun ${runId} no longer exists`); + } + return finished; + } + + /** + * At most one notification per job and UTC day (database clock): the + * hourly app-backup check would otherwise report the same outage every + * hour. The advisory lock per job serialises check and write, so two + * runs failing at once cannot both pass the check. A notification that + * cannot be written is logged; the run stays FAILED either way. + */ + private async notifyFailure(job: JobName, runId: string): Promise { + try { + await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`job-failure-notification:${job}`}, 0))`; + const [{ notified }] = await tx.$queryRaw<{ notified: boolean }[]>` + SELECT EXISTS ( + SELECT 1 FROM "Notification" n + JOIN "JobRun" r ON r."id" = n."relatedId" + WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE} + AND r."job" = ${job} + AND n."createdAt" >= date_trunc('day', now()) + ) AS "notified"`; + if (notified) { + return; + } + await this.notifications.recordForPermission( + JOB_FAILURE_PERMISSION[job], + { + priority: 'WARNUNG', + message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`, + relatedType: JOB_RUN_RELATED_TYPE, + relatedId: runId, + }, + tx, + ); + }); + } catch (error: unknown) { + const { message, stack } = loggableError(error); + this.logger.error( + `Failure notification for job "${job}" could not be written: ${message}`, + stack, + ); + } + } +} diff --git a/ApiCore-Backend/src/system/system-queue.service.spec.ts b/ApiCore-Backend/src/system/system-queue.service.spec.ts new file mode 100644 index 0000000..ac6d1c4 --- /dev/null +++ b/ApiCore-Backend/src/system/system-queue.service.spec.ts @@ -0,0 +1,207 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { AuditLogService } from '../audit/audit-log.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobRunService } from './job-run.service'; +import { SystemQueueService } from './system-queue.service'; + +type RawCall = [readonly string[], ...unknown[]]; + +describe('SystemQueueService', () => { + let tx: { + $queryRaw: jest.Mock, RawCall>; + $executeRaw: jest.Mock, RawCall>; + }; + let prisma: { + $queryRaw: jest.Mock, RawCall>; + $transaction: jest.Mock; + domainEvent: { findMany: jest.Mock; findUnique: jest.Mock }; + webhookDelivery: { findMany: jest.Mock; findUnique: jest.Mock }; + }; + let jobRuns: { latestPerJob: jest.Mock }; + let audit: { record: jest.Mock }; + let service: SystemQueueService; + + const sqlText = (call: RawCall): string => call[0].join('?'); + + beforeEach(() => { + tx = { + $queryRaw: jest.fn, RawCall>().mockResolvedValue([]), + $executeRaw: jest.fn, RawCall>().mockResolvedValue(1), + }; + prisma = { + $queryRaw: jest.fn, RawCall>(), + $transaction: jest.fn((fn: (t: typeof tx) => Promise) => fn(tx)), + domainEvent: { findMany: jest.fn(), findUnique: jest.fn() }, + webhookDelivery: { findMany: jest.fn(), findUnique: jest.fn() }, + }; + jobRuns = { + latestPerJob: jest.fn().mockResolvedValue({ + 'daily-evaluation': null, + 'task-templates': null, + cleanup: null, + 'app-backup': null, + rekey: null, + }), + }; + audit = { record: jest.fn().mockResolvedValue(undefined) }; + service = new SystemQueueService( + prisma as unknown as PrismaService, + jobRuns as unknown as JobRunService, + audit as unknown as AuditLogService, + ); + }); + + it('reports the oldest pending age from the database clock', async () => { + prisma.$queryRaw + .mockResolvedValueOnce([ + { pending: 3, processing: 1, failed: 2, oldestPendingAgeSeconds: 42.5 }, + ]) + .mockResolvedValueOnce([{ pending: 4, failed: 1 }]); + + const overview = await service.overview(); + + expect(overview.domainEvents).toEqual({ + pending: 3, + processing: 1, + failed: 2, + oldestPendingAgeSeconds: 42.5, + }); + expect(overview.webhookDeliveries).toEqual({ pending: 4, failed: 1 }); + expect(overview.jobs).toHaveLength(5); + const sql = sqlText(prisma.$queryRaw.mock.calls[0]); + expect(sql).toContain('EXTRACT(EPOCH FROM now() - MIN("occurredAt")'); + }); + + it('reports null age when nothing is pending', async () => { + prisma.$queryRaw + .mockResolvedValueOnce([ + { pending: 0, processing: 0, failed: 0, oldestPendingAgeSeconds: null }, + ]) + .mockResolvedValueOnce([{ pending: 0, failed: 0 }]); + const overview = await service.overview(); + expect(overview.domainEvents.oldestPendingAgeSeconds).toBeNull(); + }); + + it('lists failed events without payload, with their failed handlers', async () => { + prisma.domainEvent.findMany.mockResolvedValue([ + { + id: 'e1', + type: 'x', + aggregateType: 'A', + aggregateId: 'a1', + occurredAt: new Date(), + attempts: 8, + lastError: 'boom', + deliveries: [{ handler: 'h1', lastError: 'bad' }], + }, + ]); + + const [view] = await service.failedDomainEvents(10); + + expect(view.failedHandlers).toEqual([{ handler: 'h1', lastError: 'bad' }]); + const args = ( + prisma.domainEvent.findMany.mock.calls as unknown[][] + )[0][0] as { + select: Record; + take: number; + }; + expect(args.select).not.toHaveProperty('payload'); + expect(args.take).toBe(10); + }); + + it('retry resets a FAILED event and its failed deliveries, writes an audit entry and wakes the worker', async () => { + tx.$queryRaw.mockResolvedValueOnce([{ attempts: 8 }]); + + await service.retryDomainEvent('e1', 'user-1'); + + const statements = [ + ...tx.$queryRaw.mock.calls, + ...tx.$executeRaw.mock.calls, + ].map(sqlText); + expect(statements[0]).toContain('"availableAt" = now()'); + expect(statements[0]).toContain('"attempts" = 0'); + expect(statements.some((s) => s.includes('"DomainEventDelivery"'))).toBe( + true, + ); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'DomainEvent', + entityId: 'e1', + action: 'UPDATE', + userId: 'user-1', + }), + tx, + ); + expect( + tx.$executeRaw.mock.calls.some((c) => + c.slice(1).includes('domain_event'), + ), + ).toBe(true); + }); + + it('retry of a DONE event is 409', async () => { + tx.$queryRaw.mockResolvedValueOnce([]); + prisma.domainEvent.findUnique.mockResolvedValue({ id: 'e1' }); + await expect(service.retryDomainEvent('e1', 'u')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('retry of an unknown event is 404', async () => { + tx.$queryRaw.mockResolvedValueOnce([]); + prisma.domainEvent.findUnique.mockResolvedValue(null); + await expect(service.retryDomainEvent('nope', 'u')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('shows only the host of a webhook target', async () => { + prisma.webhookDelivery.findMany.mockResolvedValue([ + { + id: 'd1', + subscriptionId: 's1', + event: 'harvest.created', + attempts: 8, + lastStatusCode: 500, + lastError: 'HTTP 500', + createdAt: new Date(), + subscription: { + url: 'https://user:pw@hooks.example.com:8443/hidden/path?key=abc', + }, + }, + ]); + + const [view] = await service.failedWebhookDeliveries(5); + + expect(view.targetHost).toBe('hooks.example.com:8443'); + expect(JSON.stringify(view)).not.toMatch(/hidden|key=|pw/); + expect(view).not.toHaveProperty('body'); + expect(view).not.toHaveProperty('subscription'); + }); + + it('retries a FAILED webhook delivery and wakes the delivery worker', async () => { + tx.$queryRaw.mockResolvedValueOnce([{ attempts: 8 }]); + await service.retryWebhookDelivery('d1', 'user-1'); + expect(sqlText(tx.$queryRaw.mock.calls[0])).toContain( + '"availableAt" = now()', + ); + expect( + tx.$executeRaw.mock.calls.some((c) => + c.slice(1).includes('webhook_delivery'), + ), + ).toBe(true); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'WebhookDelivery' }), + tx, + ); + }); + + it('retry of a non-FAILED delivery is 409', async () => { + tx.$queryRaw.mockResolvedValueOnce([]); + prisma.webhookDelivery.findUnique.mockResolvedValue({ id: 'd1' }); + await expect( + service.retryWebhookDelivery('d1', 'u'), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/ApiCore-Backend/src/system/system-queue.service.ts b/ApiCore-Backend/src/system/system-queue.service.ts new file mode 100644 index 0000000..28dba18 --- /dev/null +++ b/ApiCore-Backend/src/system/system-queue.service.ts @@ -0,0 +1,291 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { JobRun } from '../../generated/prisma'; +import { AuditLogService } from '../audit/audit-log.service'; +import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup'; +import { JobResult } from '../jobs/job'; +import { isJobName, JOB_NAMES, JobName } from '../jobs/job-names'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobRunService } from './job-run.service'; + +export interface JobRunView { + id: string; + job: JobName; + status: JobRun['status']; + startedAt: Date; + finishedAt: Date | null; + error: string | null; + result: JobResult | null; +} + +export interface SystemOverview { + jobs: { job: JobName; lastRun: JobRunView | null }[]; + domainEvents: { + pending: number; + processing: number; + failed: number; + oldestPendingAgeSeconds: number | null; + }; + webhookDeliveries: { pending: number; failed: number }; +} + +export interface FailedDomainEventView { + id: string; + type: string; + aggregateType: string; + aggregateId: string; + occurredAt: Date; + attempts: number; + lastError: string | null; + failedHandlers: { handler: string; lastError: string | null }[]; +} + +export interface FailedWebhookDeliveryView { + id: string; + subscriptionId: string; + targetHost: string; + event: string; + attempts: number; + lastStatusCode: number | null; + lastError: string | null; + createdAt: Date; +} + +/** Shown when a stored webhook URL cannot be parsed. */ +const UNKNOWN_HOST = 'unknown'; + +interface DomainEventCounts { + pending: number; + processing: number; + failed: number; + oldestPendingAgeSeconds: number | null; +} + +interface WebhookCounts { + pending: number; + failed: number; +} + +/** Host (and port) of a webhook URL - never path, query or credentials. */ +function hostOf(rawUrl: string): string { + try { + return new URL(rawUrl).host || UNKNOWN_HOST; + } catch { + return UNKNOWN_HOST; + } +} + +/** The row was not retryable: 404 if it does not exist, else 409. */ +function retryRejection( + existing: { id: string } | null, + label: string, +): NotFoundException | ConflictException { + return existing + ? new ConflictException(`${label} is not FAILED`) + : new NotFoundException(`${label} not found`); +} + +/** JobRun for the API: `job` narrowed, `result` as the stored JSON object. */ +export function toJobRunView(run: JobRun): JobRunView { + return { + id: run.id, + job: run.job as JobName, + status: run.status, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + error: run.error, + result: (run.result ?? null) as JobResult | null, + }; +} + +/** + * Read model and retry actions of the System page: the CronJob runs, the + * state of both outboxes and the re-queueing of FAILED entries. Views never + * carry payloads, webhook bodies, secrets or full URLs; `lastError` is + * shown as stored (written via loggableError). + */ +@Injectable() +export class SystemQueueService { + constructor( + private readonly prisma: PrismaService, + private readonly jobRuns: JobRunService, + private readonly audit: AuditLogService, + ) {} + + async overview(): Promise { + const [latest, [events], [webhooks]] = await Promise.all([ + this.jobRuns.latestPerJob(), + // The age is computed by the database: the API pod's clock may differ. + this.prisma.$queryRaw` + SELECT + count(*) FILTER (WHERE "status" = 'PENDING')::int AS "pending", + count(*) FILTER (WHERE "status" = 'PROCESSING')::int AS "processing", + count(*) FILTER (WHERE "status" = 'FAILED')::int AS "failed", + EXTRACT(EPOCH FROM now() - MIN("occurredAt") FILTER (WHERE "status" = 'PENDING'))::float8 AS "oldestPendingAgeSeconds" + FROM "DomainEvent"`, + this.prisma.$queryRaw` + SELECT + count(*) FILTER (WHERE "status" = 'PENDING')::int AS "pending", + count(*) FILTER (WHERE "status" = 'FAILED')::int AS "failed" + FROM "WebhookDelivery"`, + ]); + return { + jobs: JOB_NAMES.map((job) => ({ + job, + lastRun: latest[job] ? toJobRunView(latest[job]) : null, + })), + domainEvents: { + pending: events.pending, + processing: events.processing, + failed: events.failed, + oldestPendingAgeSeconds: events.oldestPendingAgeSeconds, + }, + webhookDeliveries: { pending: webhooks.pending, failed: webhooks.failed }, + }; + } + + /** The newest runs, of one job or of all (limit validated by the DTO). */ + async jobRunViews(limit: number, job?: JobName): Promise { + const runs = await this.jobRuns.recent(limit, job); + return runs.filter((run) => isJobName(run.job)).map(toJobRunView); + } + + async failedDomainEvents(limit: number): Promise { + const events = await this.prisma.domainEvent.findMany({ + where: { status: 'FAILED' }, + orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }], + take: limit, + // Never the payload: it holds the full record the event was about. + select: { + id: true, + type: true, + aggregateType: true, + aggregateId: true, + occurredAt: true, + attempts: true, + lastError: true, + deliveries: { + where: { status: 'FAILED' }, + orderBy: { handler: 'asc' }, + select: { handler: true, lastError: true }, + }, + }, + }); + return events.map(({ deliveries, ...event }) => ({ + ...event, + failedHandlers: deliveries, + })); + } + + /** + * Re-queues a FAILED event and its FAILED handler deliveries (DONE + * deliveries stay DONE and are skipped, so no effect repeats). Resets + * attempts and availableAt in SQL (database clock), audits the change in + * the same transaction and wakes the worker. 404 unknown, 409 not FAILED. + */ + async retryDomainEvent(id: string, actingUserId: string): Promise { + await this.prisma.$transaction(async (tx) => { + const [before] = await tx.$queryRaw<{ attempts: number }[]>` + WITH old AS ( + SELECT "id", "attempts" FROM "DomainEvent" + WHERE "id" = ${id} AND "status" = 'FAILED' + FOR UPDATE + ) + UPDATE "DomainEvent" e + SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(), + "claimedAt" = NULL, "processedAt" = NULL, "lastError" = NULL + FROM old + WHERE e."id" = old."id" + RETURNING old."attempts" AS "attempts"`; + if (!before) { + const existing = await this.prisma.domainEvent.findUnique({ + where: { id }, + select: { id: true }, + }); + throw retryRejection(existing, 'Domain event'); + } + await tx.$executeRaw` + UPDATE "DomainEventDelivery" + SET "status" = 'PENDING', "attempts" = 0, "lastError" = NULL, + "updatedAt" = now() + WHERE "eventId" = ${id} AND "status" = 'FAILED'`; + await this.audit.record( + { + entityType: 'DomainEvent', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: { status: 'FAILED', attempts: before.attempts }, + afterData: { status: 'PENDING', attempts: 0 }, + }, + tx, + ); + await notifyWorker(tx, WAKE_CHANNELS.domainEvents); + }); + } + + async failedWebhookDeliveries( + limit: number, + ): Promise { + const deliveries = await this.prisma.webhookDelivery.findMany({ + where: { status: 'FAILED' }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: limit, + // Never the body; of the subscription only the URL, reduced to its host. + select: { + id: true, + subscriptionId: true, + event: true, + attempts: true, + lastStatusCode: true, + lastError: true, + createdAt: true, + subscription: { select: { url: true } }, + }, + }); + return deliveries.map(({ subscription, ...delivery }) => ({ + ...delivery, + targetHost: hostOf(subscription.url), + })); + } + + /** Re-queues a FAILED delivery; same rules as retryDomainEvent. */ + async retryWebhookDelivery(id: string, actingUserId: string): Promise { + await this.prisma.$transaction(async (tx) => { + const [before] = await tx.$queryRaw<{ attempts: number }[]>` + WITH old AS ( + SELECT "id", "attempts" FROM "WebhookDelivery" + WHERE "id" = ${id} AND "status" = 'FAILED' + FOR UPDATE + ) + UPDATE "WebhookDelivery" d + SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(), + "claimedAt" = NULL, "lastStatusCode" = NULL, "lastError" = NULL + FROM old + WHERE d."id" = old."id" + RETURNING old."attempts" AS "attempts"`; + if (!before) { + const existing = await this.prisma.webhookDelivery.findUnique({ + where: { id }, + select: { id: true }, + }); + throw retryRejection(existing, 'Webhook delivery'); + } + await this.audit.record( + { + entityType: 'WebhookDelivery', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: { status: 'FAILED', attempts: before.attempts }, + afterData: { status: 'PENDING', attempts: 0 }, + }, + tx, + ); + await notifyWorker(tx, WAKE_CHANNELS.webhookDeliveries); + }); + } +} diff --git a/ApiCore-Backend/src/system/system.controller.ts b/ApiCore-Backend/src/system/system.controller.ts new file mode 100644 index 0000000..b4c3999 --- /dev/null +++ b/ApiCore-Backend/src/system/system.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { + JobRunListQueryDto, + SYSTEM_LIST_DEFAULT_LIMIT, + SystemListQueryDto, +} from './dto/system-list-query.dto'; +import { + FailedDomainEventView, + FailedWebhookDeliveryView, + JobRunView, + SystemOverview, + SystemQueueService, +} from './system-queue.service'; + +@ApiTags('system') +@ApiBearerAuth() +@Controller('system') +export class SystemController { + constructor(private readonly queue: SystemQueueService) {} + + @RequirePermission('system:read') + @Get('overview') + overview(): Promise { + return this.queue.overview(); + } + + @RequirePermission('system:read') + @Get('job-runs') + jobRuns(@Query() query: JobRunListQueryDto): Promise { + return this.queue.jobRunViews( + query.limit ?? SYSTEM_LIST_DEFAULT_LIMIT, + query.job, + ); + } + + @RequirePermission('system:read') + @Get('domain-events/failed') + failedDomainEvents( + @Query() query: SystemListQueryDto, + ): Promise { + return this.queue.failedDomainEvents( + query.limit ?? SYSTEM_LIST_DEFAULT_LIMIT, + ); + } + + @RequirePermission('system:manage') + @Post('domain-events/:id/retry') + @HttpCode(HttpStatus.NO_CONTENT) + retryDomainEvent( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.queue.retryDomainEvent(id, actor.id); + } + + @RequirePermission('system:read') + @Get('webhook-deliveries/failed') + failedWebhookDeliveries( + @Query() query: SystemListQueryDto, + ): Promise { + return this.queue.failedWebhookDeliveries( + query.limit ?? SYSTEM_LIST_DEFAULT_LIMIT, + ); + } + + @RequirePermission('system:manage') + @Post('webhook-deliveries/:id/retry') + @HttpCode(HttpStatus.NO_CONTENT) + retryWebhookDelivery( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.queue.retryWebhookDelivery(id, actor.id); + } +} diff --git a/ApiCore-Backend/src/system/system.module.ts b/ApiCore-Backend/src/system/system.module.ts new file mode 100644 index 0000000..6fc4a5d --- /dev/null +++ b/ApiCore-Backend/src/system/system.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { JobRunService } from './job-run.service'; +import { SystemController } from './system.controller'; +import { SystemQueueService } from './system-queue.service'; + +/** + * Operations of the platform itself: the CronJob runs (JobRun). Imported by + * the API (System page) and by JobsModule (dist/cli.js). + */ +@Module({ + imports: [NotificationsModule], + controllers: [SystemController], + providers: [JobRunService, SystemQueueService], + exports: [JobRunService], +}) +export class SystemModule {} diff --git a/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts b/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts index 8978284..2e7a6f6 100644 --- a/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts +++ b/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts @@ -2,7 +2,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { TaskTemplatesService } from './task-templates.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; describe('TaskTemplatesService', () => { let service: TaskTemplatesService; @@ -12,12 +11,9 @@ describe('TaskTemplatesService', () => { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; - updateMany: jest.Mock; }; - task: { create: jest.Mock }; }; let auditLog: { record: jest.Mock }; - let webhooks: { dispatch: jest.Mock }; const template = { id: 'template-1', @@ -40,16 +36,12 @@ describe('TaskTemplatesService', () => { findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), - updateMany: jest.fn(), }, - task: { create: jest.fn() }, }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; - webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; service = new TaskTemplatesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, - webhooks as unknown as WebhooksService, ); }); @@ -198,43 +190,4 @@ describe('TaskTemplatesService', () => { ]; expect(updateArg.data.active).toBe(false); }); - - describe('generateDueTasks', () => { - it('creates a Task and advances nextDueDate for a due template', async () => { - prisma.taskTemplate.findMany.mockResolvedValue([template]); - prisma.taskTemplate.updateMany.mockResolvedValue({ count: 1 }); - prisma.task.create.mockResolvedValue({ id: 'task-1' }); - - await service.generateDueTasks(); - - const [[createArg]] = prisma.task.create.mock.calls as [ - [{ data: { title: string; sourceTemplateId: string } }], - ]; - expect(createArg.data.title).toBe(template.title); - expect(createArg.data.sourceTemplateId).toBe(template.id); - expect(webhooks.dispatch).toHaveBeenCalledWith( - 'task.created', - expect.objectContaining({ id: 'task-1' }), - ); - }); - - it('skips Task creation when the compare-and-swap claim loses the race', async () => { - prisma.taskTemplate.findMany.mockResolvedValue([template]); - prisma.taskTemplate.updateMany.mockResolvedValue({ count: 0 }); - - await service.generateDueTasks(); - - expect(prisma.task.create).not.toHaveBeenCalled(); - expect(webhooks.dispatch).not.toHaveBeenCalled(); - }); - - it('does nothing when no template is due', async () => { - prisma.taskTemplate.findMany.mockResolvedValue([]); - - await service.generateDueTasks(); - - expect(prisma.taskTemplate.updateMany).not.toHaveBeenCalled(); - expect(prisma.task.create).not.toHaveBeenCalled(); - }); - }); }); diff --git a/ApiCore-Backend/src/task-templates/task-templates.service.ts b/ApiCore-Backend/src/task-templates/task-templates.service.ts index 2c90a79..c0bc218 100644 --- a/ApiCore-Backend/src/task-templates/task-templates.service.ts +++ b/ApiCore-Backend/src/task-templates/task-templates.service.ts @@ -5,15 +5,10 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; -import { Task, TaskTemplate } from '../../generated/prisma'; +import { TaskTemplate } from '../../generated/prisma'; import { CreateTaskTemplateDto } from './dto/create-task-template.dto'; import { UpdateTaskTemplateDto } from './dto/update-task-template.dto'; -import { - computeInitialDueDate, - computeNextDueDate, - RecurrenceInput, -} from './recurrence'; +import { computeInitialDueDate, RecurrenceInput } from './recurrence'; function valueOrStored( value: number | null | undefined, @@ -41,7 +36,6 @@ export class TaskTemplatesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly webhooks: WebhooksService, ) {} async findAll(): Promise { @@ -145,53 +139,6 @@ export class TaskTemplatesService { return template; } - /** - * Lazily catches up any template whose nextDueDate has passed: creates ONE - * task for the most recent missed occurrence (due on that date) and moves - * nextDueDate past today. A weekly template nobody looked at for two months - * yields one reminder, not eight stale duplicates. - * - * The update's WHERE clause matches the previous nextDueDate - * (compare-and-swap), so concurrent calls from multiple API replicas cannot - * double-fire the same occurrence. - */ - async generateDueTasks(): Promise { - const now = new Date(); - const dueTemplates = await this.prisma.taskTemplate.findMany({ - where: { active: true, nextDueDate: { lte: now } }, - }); - - for (const template of dueTemplates) { - let occurrence = template.nextDueDate; - let nextDueDate = computeNextDueDate(template, occurrence); - // Bounded: even a daily template several years behind stays cheap. - for (let step = 0; step < 5000 && nextDueDate <= now; step++) { - occurrence = nextDueDate; - nextDueDate = computeNextDueDate(template, occurrence); - } - const claim = await this.prisma.taskTemplate.updateMany({ - where: { id: template.id, nextDueDate: template.nextDueDate }, - data: { nextDueDate }, - }); - if (claim.count === 0) { - continue; - } - - const task: Task = await this.prisma.task.create({ - data: { - title: template.title, - description: template.description, - priority: template.priority, - dueDate: occurrence, - assignedUserId: template.assignedUserId, - relatedType: 'GENERAL', - sourceTemplateId: template.id, - }, - }); - await this.webhooks.dispatch('task.created', task); - } - } - private assertRecurrenceFieldsPresent(input: RecurrenceInput): void { if ( input.recurrenceType === 'YEARLY' && diff --git a/ApiCore-Backend/src/tasks/tasks.module.ts b/ApiCore-Backend/src/tasks/tasks.module.ts index 6d38465..e3adeb3 100644 --- a/ApiCore-Backend/src/tasks/tasks.module.ts +++ b/ApiCore-Backend/src/tasks/tasks.module.ts @@ -1,10 +1,8 @@ import { Module } from '@nestjs/common'; -import { TaskTemplatesModule } from '../task-templates/task-templates.module'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; @Module({ - imports: [TaskTemplatesModule], controllers: [TasksController], providers: [TasksService], exports: [TasksService], diff --git a/ApiCore-Backend/src/tasks/tasks.service.spec.ts b/ApiCore-Backend/src/tasks/tasks.service.spec.ts index c73cfba..5511ac1 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.spec.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.spec.ts @@ -2,47 +2,88 @@ import { NotFoundException } from '@nestjs/common'; import { TasksService } from './tasks.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; -import { TaskTemplatesService } from '../task-templates/task-templates.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; describe('TasksService', () => { let service: TasksService; - let prisma: { + let tx: { task: { - findMany: jest.Mock; - findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; + updateMany: jest.Mock; + findUnique: jest.Mock; + findUniqueOrThrow: jest.Mock; }; + taskTemplate: { updateMany: jest.Mock }; + }; + let prisma: { + $connect: jest.Mock; + $transaction: jest.Mock; + task: { findMany: jest.Mock; findUnique: jest.Mock }; + taskTemplate: { findMany: jest.Mock }; }; let auditLog: { record: jest.Mock }; - let taskTemplates: { generateDueTasks: jest.Mock }; - let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; + let events: { record: jest.Mock }; - const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' }; + const task = { + id: 'task-1', + title: 'Beute prüfen', + status: 'OPEN', + dueDate: null, + relatedType: 'GENERAL', + relatedId: null, + completedAt: null, + }; + + const template = { + id: 'template-1', + title: 'Varroa-Kontrolle', + description: 'Windel einlegen', + priority: 'HIGH', + assignedUserId: 'user-2', + recurrenceType: 'MONTHLY', + recurrenceMonth: null, + recurrenceDayOfMonth: 15, + intervalDays: null, + nextDueDate: new Date('2026-03-15T00:00:00.000Z'), + active: true, + }; beforeEach(() => { - prisma = { + tx = { task: { - findMany: jest.fn(), - findUnique: jest.fn(), - create: jest.fn(), + // The stored row: defaults filled in like the database does. + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ + id: 'task-new', + status: 'OPEN', + relatedType: 'GENERAL', + relatedId: null, + dueDate: null, + ...data, + }), + ), update: jest.fn(), + updateMany: jest.fn(), + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn(), }, + taskTemplate: { updateMany: jest.fn() }, }; - auditLog = { record: jest.fn() }; - const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; - taskTemplates = { - generateDueTasks: jest.fn().mockResolvedValue(undefined), + prisma = { + $connect: jest.fn(), + $transaction: jest.fn((work: (client: typeof tx) => Promise) => + work(tx), + ), + task: { findMany: jest.fn(), findUnique: jest.fn() }, + taskTemplate: { findMany: jest.fn().mockResolvedValue([]) }, }; - calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + events = { record: jest.fn().mockResolvedValue(undefined) }; service = new TasksService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, - webhooks as unknown as WebhooksService, - taskTemplates as unknown as TaskTemplatesService, - calendarSync as unknown as CalendarSyncService, + events as unknown as DomainEventsService, ); }); @@ -51,7 +92,13 @@ describe('TasksService', () => { const result = await service.findAll({}); - expect(taskTemplates.generateDueTasks).toHaveBeenCalled(); + expect(prisma.taskTemplate.findMany).toHaveBeenCalledWith({ + where: { active: true, nextDueDate: { lte: expect.any(Date) as Date } }, + }); + // Generated first, so the list already contains the new tasks. + const [generatedAt] = prisma.taskTemplate.findMany.mock.invocationCallOrder; + const [listedAt] = prisma.task.findMany.mock.invocationCallOrder; + expect(generatedAt).toBeLessThan(listedAt); expect(result).toEqual([task]); }); @@ -62,69 +109,416 @@ describe('TasksService', () => { ); }); - it('create stores the acting user as creator', async () => { - prisma.task.create.mockResolvedValue(task); + describe('createInTx', () => { + it('createInTx writes task, audit entry and TaskCreated on the same client', async () => { + await service.createInTx( + tx as never, + { title: 'T', dueDate: new Date('2027-05-01T00:00:00Z') }, + 'u1', + ); - await service.create({ title: 'Beute prüfen' }, 'user-1'); + expect(tx.task.create).toHaveBeenCalledWith({ + data: { + title: 'T', + dueDate: new Date('2027-05-01T00:00:00Z'), + createdByUserId: 'u1', + }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Task', + entityId: 'task-new', + action: 'CREATE', + userId: 'u1', + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'TaskCreated', + aggregateType: 'Task', + aggregateId: 'task-new', + actorUserId: 'u1', + payload: { + relatedType: 'GENERAL', + relatedId: null, + dueDate: '2027-05-01', + }, + }), + ); + }); - const [[createArg]] = prisma.task.create.mock.calls as [ - [{ data: { title: string; createdByUserId: string } }], + it('announces a task without a due date with dueDate null', async () => { + await service.createInTx( + tx as never, + { title: 'T', relatedType: 'COLONY', relatedId: 'colony-1' }, + 'u1', + ); + + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + payload: { + relatedType: 'COLONY', + relatedId: 'colony-1', + dueDate: null, + }, + }), + ); + }); + + it('refuses the root client - the task would commit without its event', async () => { + await expect( + service.createInTx(prisma as never, { title: 'T' }, 'u1'), + ).rejects.toThrow('TasksService.createInTx needs the transaction client'); + expect(tx.task.create).not.toHaveBeenCalled(); + }); + }); + + it('create stores the acting user as creator in one transaction with its event', async () => { + await service.create( + { title: 'Beute prüfen', dueDate: '2026-08-01T00:00:00.000Z' }, + 'user-1', + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + const [[createArg]] = tx.task.create.mock.calls as [ + [{ data: { title: string; createdByUserId: string; dueDate: Date } }], ]; expect(createArg.data.title).toBe('Beute prüfen'); expect(createArg.data.createdByUserId).toBe('user-1'); + expect(createArg.data.dueDate).toEqual( + new Date('2026-08-01T00:00:00.000Z'), + ); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'TaskCreated', + payload: expect.objectContaining({ dueDate: '2026-08-01' }) as unknown, + }), + ); }); - it('complete sets status DONE and completedAt', async () => { - prisma.task.findUnique.mockResolvedValue(task); - prisma.task.update.mockResolvedValue({ ...task, status: 'DONE' }); + describe('update', () => { + const dueDate = new Date('2026-08-01T00:00:00.000Z'); - await service.complete('task-1', 'user-1'); + it('records TaskUpdated with status and due date next to the audit entry', async () => { + tx.task.findUnique.mockResolvedValue({ ...task, dueDate }); + const updated = { ...task, dueDate, status: 'IN_PROGRESS' }; + tx.task.update.mockResolvedValue(updated); - const [[updateArg]] = prisma.task.update.mock.calls as [ - [{ data: { status: string; completedAt: Date } }], - ]; - expect(updateArg.data.status).toBe('DONE'); - expect(updateArg.data.completedAt).toBeInstanceOf(Date); - }); + const result = await service.update( + 'task-1', + { status: 'IN_PROGRESS' }, + 'user-1', + ); - describe('update keeps the calendar entry in step', () => { - const dueDate = new Date('2026-08-01'); - - it('re-enqueues an open task with a due date so a moved date moves the event', async () => { - prisma.task.findUnique.mockResolvedValue({ ...task, dueDate }); - prisma.task.update.mockResolvedValue({ ...task, dueDate }); - - await service.update('task-1', { dueDate: '2026-08-01' }, 'user-1'); - - expect(calendarSync.enqueue).toHaveBeenCalledWith('TASK', 'task-1'); - expect(calendarSync.cancel).not.toHaveBeenCalled(); + expect(result).toBe(updated); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Task', + entityId: 'task-1', + action: 'UPDATE', + userId: 'user-1', + afterData: updated, + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'TaskUpdated', + aggregateType: 'Task', + aggregateId: 'task-1', + actorUserId: 'user-1', + payload: { status: 'IN_PROGRESS', dueDate: '2026-08-01' }, + }); }); - it('clears the due date on null and removes the event', async () => { - prisma.task.findUnique.mockResolvedValue({ ...task, dueDate }); - prisma.task.update.mockResolvedValue({ ...task, dueDate: null }); + it('clears the due date on null', async () => { + tx.task.findUnique.mockResolvedValue({ ...task, dueDate }); + tx.task.update.mockResolvedValue({ ...task, dueDate: null }); await service.update('task-1', { dueDate: null as never }, 'user-1'); - const [[updateArg]] = prisma.task.update.mock.calls as [ + const [[updateArg]] = tx.task.update.mock.calls as [ [{ data: { dueDate?: Date | null } }], ]; expect(updateArg.data.dueDate).toBeNull(); - expect(calendarSync.cancel).toHaveBeenCalledWith('TASK', 'task-1'); - expect(calendarSync.enqueue).not.toHaveBeenCalled(); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'TaskUpdated', + payload: { status: 'OPEN', dueDate: null }, + }), + ); }); - it.each(['DONE', 'CANCELLED'] as const)( - 'removes the event when the task is set to %s directly', - async (status) => { - prisma.task.findUnique.mockResolvedValue({ ...task, dueDate }); - prisma.task.update.mockResolvedValue({ ...task, dueDate, status }); + describe('into DONE', () => { + const done = { ...task, status: 'DONE', completedAt: new Date() }; - await service.update('task-1', { status }, 'user-1'); + it('completes through the conditional write and records TaskCompleted once, next to TaskUpdated', async () => { + tx.task.findUnique.mockResolvedValue(task); + tx.task.updateMany.mockResolvedValue({ count: 1 }); + tx.task.update.mockResolvedValue(done); - expect(calendarSync.cancel).toHaveBeenCalledWith('TASK', 'task-1'); - expect(calendarSync.enqueue).not.toHaveBeenCalled(); - }, - ); + await service.update('task-1', { status: 'DONE' }, 'user-1'); + + // Same rule as complete(): status and completedAt only if not DONE. + const [[markArg]] = tx.task.updateMany.mock.calls as [ + [{ where: unknown; data: { status: string; completedAt: Date } }], + ]; + expect(markArg.where).toEqual({ + id: 'task-1', + status: { not: 'DONE' }, + }); + expect(markArg.data.status).toBe('DONE'); + expect(markArg.data.completedAt).toBeInstanceOf(Date); + // The edit itself leaves completedAt to that write. + const [[updateArg]] = tx.task.update.mock.calls as [ + [{ data: { completedAt?: Date | null } }], + ]; + expect(updateArg.data.completedAt).toBeUndefined(); + expect(tx.task.updateMany.mock.invocationCallOrder[0]).toBeLessThan( + tx.task.update.mock.invocationCallOrder[0], + ); + const recorded = events.record.mock.calls as [unknown, unknown][]; + expect(recorded.map(([, event]) => event)).toEqual([ + expect.objectContaining({ + type: 'TaskUpdated', + payload: { status: 'DONE', dueDate: null }, + }), + { + type: 'TaskCompleted', + aggregateType: 'Task', + aggregateId: 'task-1', + actorUserId: 'user-1', + payload: { relatedType: 'GENERAL', relatedId: null }, + }, + ]); + expect(recorded.every(([client]) => client === tx)).toBe(true); + }); + + it('records no TaskCompleted when a parallel request completed the task first', async () => { + tx.task.findUnique.mockResolvedValue(task); + tx.task.updateMany.mockResolvedValue({ count: 0 }); + tx.task.update.mockResolvedValue(done); + + await service.update('task-1', { status: 'DONE' }, 'user-1'); + + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ type: 'TaskUpdated' }), + ); + }); + + it('records no TaskCompleted for a task that is DONE already', async () => { + tx.task.findUnique.mockResolvedValue(done); + tx.task.update.mockResolvedValue({ ...done, title: 'Neu' }); + + await service.update( + 'task-1', + { status: 'DONE', title: 'Neu' }, + 'user-1', + ); + + expect(tx.task.updateMany).not.toHaveBeenCalled(); + const [[updateArg]] = tx.task.update.mock.calls as [ + [{ data: { completedAt?: Date | null } }], + ]; + expect(updateArg.data.completedAt).toBeUndefined(); + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ type: 'TaskUpdated' }), + ); + }); + }); + + it('clears completedAt when a done task is reopened', async () => { + tx.task.findUnique.mockResolvedValue({ + ...task, + status: 'DONE', + completedAt: new Date(), + }); + tx.task.update.mockResolvedValue(task); + + await service.update('task-1', { status: 'OPEN' }, 'user-1'); + + const [[updateArg]] = tx.task.update.mock.calls as [ + [{ data: { completedAt?: Date | null } }], + ]; + expect(updateArg.data.completedAt).toBeNull(); + expect(tx.task.updateMany).not.toHaveBeenCalled(); + expect(events.record).toHaveBeenCalledTimes(1); + }); + + it('throws NotFoundException and records nothing for an unknown task', async () => { + tx.task.findUnique.mockResolvedValue(null); + + await expect( + service.update('missing', { title: 'x' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(events.record).not.toHaveBeenCalled(); + }); + }); + + describe('complete', () => { + it('complete records TaskCompleted once and nothing on a repeated complete', async () => { + const done = { + ...task, + relatedType: 'COLONY', + relatedId: 'colony-1', + status: 'DONE', + completedAt: new Date(), + }; + tx.task.findUnique.mockResolvedValueOnce({ + ...task, + relatedType: 'COLONY', + relatedId: 'colony-1', + }); + tx.task.updateMany.mockResolvedValueOnce({ count: 1 }); + tx.task.findUniqueOrThrow.mockResolvedValueOnce(done); + + await expect(service.complete('task-1', 'user-1')).resolves.toBe(done); + + const [[updateArg]] = tx.task.updateMany.mock.calls as [ + [{ where: unknown; data: { status: string; completedAt: Date } }], + ]; + expect(updateArg.where).toEqual({ + id: 'task-1', + status: { not: 'DONE' }, + }); + expect(updateArg.data.status).toBe('DONE'); + expect(updateArg.data.completedAt).toBeInstanceOf(Date); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Task', + action: 'UPDATE', + afterData: done, + }), + tx, + ); + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'TaskCompleted', + aggregateType: 'Task', + aggregateId: 'task-1', + actorUserId: 'user-1', + payload: { relatedType: 'COLONY', relatedId: 'colony-1' }, + }); + + // The second tap: already DONE - no write, no audit entry, no event. + tx.task.findUnique.mockResolvedValueOnce(done); + + await expect(service.complete('task-1', 'user-1')).resolves.toBe(done); + + expect(tx.task.updateMany).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledTimes(1); + }); + + it('records nothing when a parallel request completed the task first', async () => { + const done = { ...task, status: 'DONE', completedAt: new Date() }; + tx.task.findUnique.mockResolvedValue(task); + tx.task.updateMany.mockResolvedValue({ count: 0 }); + tx.task.findUniqueOrThrow.mockResolvedValue(done); + + await expect(service.complete('task-1', 'user-1')).resolves.toBe(done); + + expect(auditLog.record).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException for an unknown task', async () => { + tx.task.findUnique.mockResolvedValue(null); + + await expect( + service.complete('missing', 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(tx.task.updateMany).not.toHaveBeenCalled(); + }); + }); + + describe('generateFromTemplates', () => { + const now = new Date('2026-05-20T08:00:00.000Z'); + + it('generateFromTemplates creates no task when another replica already claimed the occurrence', async () => { + prisma.taskTemplate.findMany.mockResolvedValue([template]); + tx.taskTemplate.updateMany.mockResolvedValue({ count: 0 }); + + await expect(service.generateFromTemplates(now)).resolves.toBe(0); + + expect(tx.task.create).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + + it('creates one task for the latest missed occurrence and moves nextDueDate past now', async () => { + prisma.taskTemplate.findMany.mockResolvedValue([template]); + tx.taskTemplate.updateMany.mockResolvedValue({ count: 1 }); + + await expect(service.generateFromTemplates(now)).resolves.toBe(1); + + // 2026-03-15 and 2026-04-15 were missed; 2026-05-15 is the latest. + expect(tx.taskTemplate.updateMany).toHaveBeenCalledWith({ + where: { id: 'template-1', nextDueDate: template.nextDueDate }, + data: { nextDueDate: new Date('2026-06-15T00:00:00.000Z') }, + }); + expect(tx.task.create).toHaveBeenCalledTimes(1); + expect(tx.task.create).toHaveBeenCalledWith({ + data: { + title: 'Varroa-Kontrolle', + description: 'Windel einlegen', + priority: 'HIGH', + dueDate: new Date('2026-05-15T00:00:00.000Z'), + assignedUserId: 'user-2', + relatedType: 'GENERAL', + sourceTemplateId: 'template-1', + createdByUserId: undefined, + }, + }); + // Claim and task in one transaction: both or neither. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('a template task gets an audit entry without a user', async () => { + prisma.taskTemplate.findMany.mockResolvedValue([template]); + tx.taskTemplate.updateMany.mockResolvedValue({ count: 1 }); + + await service.generateFromTemplates(now); + + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Task', + action: 'CREATE', + userId: undefined, + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'TaskCreated', + actorUserId: null, + payload: { + relatedType: 'GENERAL', + relatedId: null, + dueDate: '2026-05-15', + }, + }), + ); + }); + + it('does nothing when no template is due', async () => { + prisma.taskTemplate.findMany.mockResolvedValue([]); + + await expect(service.generateFromTemplates(now)).resolves.toBe(0); + + expect(prisma.taskTemplate.findMany).toHaveBeenCalledWith({ + where: { active: true, nextDueDate: { lte: now } }, + }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); }); }); diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts index b191ed3..9d611a9 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -1,27 +1,39 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { assertTransactionClient, DbClient } from '../prisma/db-client'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhooksService } from '../webhooks/webhooks.service'; -import { TaskTemplatesService } from '../task-templates/task-templates.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; -import { Task, TaskPriority, TaskStatus } from '../../generated/prisma'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { utcDay } from '../domain-events/domain-event-types'; +import { + AutomationTriggerType, + Prisma, + Task, + TaskPriority, + TaskRelatedType, + TaskStatus, + TaskTemplate, +} from '../../generated/prisma'; +import { computeNextDueDate } from '../task-templates/recurrence'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; import { optionalDate } from '../common/patch-values'; -/** - * Keeps completedAt in step with status when the status is edited directly: - * set when a task becomes DONE, cleared when it is reopened or cancelled - - * a reopened task must not keep claiming it was finished. - */ -function completedAtForStatusChange( - before: Task, - nextStatus: TaskStatus | undefined, -): Date | null | undefined { - if (nextStatus === undefined || nextStatus === before.status) { - return undefined; - } - return nextStatus === 'DONE' ? new Date() : null; +// Bounds the catch-up of one template: even a daily template several years +// behind stays cheap. +const MAX_CATCH_UP_STEPS = 5000; + +/** A new task, wherever it is started: task page, inspection, template, rule. */ +export interface NewTaskInput { + title: string; + description?: string | null; + priority?: TaskPriority; + dueDate?: Date | null; + assignedUserId?: string | null; + relatedType?: TaskRelatedType; + relatedId?: string | null; + sourceInspectionId?: string | null; + sourceTemplateId?: string | null; + sourceAutomationTrigger?: AutomationTriggerType | null; } export interface TaskFilter { @@ -29,18 +41,68 @@ export interface TaskFilter { priority?: TaskPriority; } +/** + * Keeps completedAt in step with status when the status is edited directly: + * cleared when a task is reopened or cancelled - a reopened task must not + * keep claiming it was finished. Into DONE it is set together with the + * status by TasksService.markDoneIn, so it is left alone here. + */ +function completedAtForStatusChange( + before: Task, + nextStatus: TaskStatus | undefined, +): null | undefined { + if ( + nextStatus === undefined || + nextStatus === before.status || + nextStatus === 'DONE' + ) { + return undefined; + } + return null; +} + +/** The due date as event payloads carry it: its UTC day, or null. */ +function dueDay(dueDate: Date | null): string | null { + return dueDate === null ? null : utcDay(dueDate); +} + +/** + * The most recent occurrence a template missed up to `now`, and the due date + * after it. A weekly template nobody looked at for two months yields one + * reminder, not eight stale duplicates. + */ +function latestMissedOccurrence( + template: TaskTemplate, + now: Date, +): { occurrence: Date; nextDueDate: Date } { + let occurrence = template.nextDueDate; + let nextDueDate = computeNextDueDate(template, occurrence); + for (let step = 0; step < MAX_CATCH_UP_STEPS && nextDueDate <= now; step++) { + occurrence = nextDueDate; + nextDueDate = computeNextDueDate(template, occurrence); + } + return { occurrence, nextDueDate }; +} + +/** + * Every task the app creates or edits goes through here (automation follows + * with Task 10; backup restore excepted), together with its audit entry and + * domain event in one transaction: TaskCreated, TaskUpdated, TaskCompleted. + * The calendar entry and the webhooks follow from the event (handlers + * calendar-sync.task and webhooks.forward), never from a direct call. + */ @Injectable() export class TasksService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly webhooks: WebhooksService, - private readonly taskTemplates: TaskTemplatesService, - private readonly calendarSync: CalendarSyncService, + private readonly events: DomainEventsService, ) {} async findAll(filter: TaskFilter): Promise { - await this.taskTemplates.generateDueTasks(); + // Lazily, so a due template shows up without waiting for the + // task-templates job. + await this.generateFromTemplates(); return this.prisma.task.findMany({ where: filter, orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }], @@ -48,33 +110,55 @@ export class TasksService { } async findById(id: string): Promise { - const task = await this.prisma.task.findUnique({ where: { id } }); - if (!task) { - throw new NotFoundException('Task not found'); - } + return this.findIn(this.prisma, id); + } + + /** + * Creates a task with its audit entry and TaskCreated on the caller's + * transaction - all or nothing. `actingUserId` is null for a task the + * system creates (template, automation rule). + */ + async createInTx( + tx: Prisma.TransactionClient, + input: NewTaskInput, + actingUserId: string | null, + ): Promise { + assertTransactionClient(tx, 'TasksService.createInTx'); + const task = await tx.task.create({ + data: { ...input, createdByUserId: actingUserId ?? undefined }, + }); + await this.auditLog.record( + { + entityType: 'Task', + entityId: task.id, + action: 'CREATE', + userId: actingUserId ?? undefined, + afterData: task, + }, + tx, + ); + await this.events.record(tx, { + type: 'TaskCreated', + aggregateType: 'Task', + aggregateId: task.id, + payload: { + relatedType: task.relatedType, + relatedId: task.relatedId, + dueDate: dueDay(task.dueDate), + }, + actorUserId: actingUserId, + }); return task; } async create(dto: CreateTaskDto, actingUserId: string): Promise { - const task = await this.prisma.task.create({ - data: { - ...dto, - dueDate: optionalDate(dto.dueDate), - createdByUserId: actingUserId, - }, - }); - await this.auditLog.record({ - entityType: 'Task', - entityId: task.id, - action: 'CREATE', - userId: actingUserId, - afterData: task, - }); - await this.webhooks.dispatch('task.created', task); - if (task.dueDate) { - await this.calendarSync.enqueue('TASK', task.id); - } - return task; + return this.prisma.$transaction((tx) => + this.createInTx( + tx, + { ...dto, dueDate: optionalDate(dto.dueDate) }, + actingUserId, + ), + ); } async update( @@ -82,54 +166,171 @@ export class TasksService { dto: UpdateTaskDto, actingUserId: string, ): Promise { - const before = await this.findById(id); - const task = await this.prisma.task.update({ - where: { id }, - data: { - ...dto, - dueDate: optionalDate(dto.dueDate), - completedAt: completedAtForStatusChange(before, dto.status), - }, + return this.prisma.$transaction(async (tx) => { + const before = await this.findIn(tx, id); + // An edit into DONE completes the task like "Erledigt" does - by the + // same conditional write, so it is announced once, also when both + // arrive at the same moment. + const completed = + dto.status === 'DONE' && before.status !== 'DONE' + ? await this.markDoneIn(tx, id) + : false; + const task = await tx.task.update({ + where: { id }, + data: { + ...dto, + dueDate: optionalDate(dto.dueDate), + completedAt: completedAtForStatusChange(before, dto.status), + }, + }); + await this.auditLog.record( + { + entityType: 'Task', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: task, + }, + tx, + ); + // The calendar follows: a moved date moves the event, a finished or + // cancelled task leaves it, a reopened one returns to it. + await this.events.record(tx, { + type: 'TaskUpdated', + aggregateType: 'Task', + aggregateId: id, + payload: { status: task.status, dueDate: dueDay(task.dueDate) }, + actorUserId: actingUserId, + }); + if (completed) { + await this.recordCompleted(tx, task, actingUserId); + } + return task; }); - await this.auditLog.record({ - entityType: 'Task', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: task, - }); - // Like complete(): a finished or cancelled task leaves the calendar, - // a reopened one returns to it. - if (task.dueDate && task.status !== 'DONE' && task.status !== 'CANCELLED') { - await this.calendarSync.enqueue('TASK', task.id); - } else { - await this.calendarSync.cancel('TASK', task.id); - } - return task; } async complete(id: string, actingUserId: string): Promise { - const before = await this.findById(id); - // A second tap on "Erledigt" (or a retried request) must not move the - // completion time or announce the completion a second time. - if (before.status === 'DONE') { - return before; - } - const task = await this.prisma.task.update({ - where: { id }, + return this.prisma.$transaction(async (tx) => { + const before = await this.findIn(tx, id); + // A second tap on "Erledigt" (or a retried request) must not move the + // completion time or announce the completion a second time. + if (before.status === 'DONE') { + return before; + } + const completed = await this.markDoneIn(tx, id); + const task = await tx.task.findUniqueOrThrow({ where: { id } }); + if (!completed) { + return task; + } + await this.auditLog.record( + { + entityType: 'Task', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: task, + }, + tx, + ); + await this.recordCompleted(tx, task, actingUserId); + return task; + }); + } + + /** + * The transition into DONE, as a conditional write: of two requests at + * the same moment (complete, or an edit into DONE) the second one waits + * for the first one's commit and then matches nothing. True if this call + * completed the task. + */ + private async markDoneIn( + tx: Prisma.TransactionClient, + id: string, + ): Promise { + const { count } = await tx.task.updateMany({ + where: { id, status: { not: 'DONE' } }, data: { status: 'DONE', completedAt: new Date() }, }); - await this.auditLog.record({ - entityType: 'Task', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: task, + return count === 1; + } + + private async recordCompleted( + tx: Prisma.TransactionClient, + task: Task, + actingUserId: string, + ): Promise { + await this.events.record(tx, { + type: 'TaskCompleted', + aggregateType: 'Task', + aggregateId: task.id, + payload: { relatedType: task.relatedType, relatedId: task.relatedId }, + actorUserId: actingUserId, }); - await this.webhooks.dispatch('task.completed', task); - await this.calendarSync.cancel('TASK', task.id); + } + + /** + * Catches up every active template whose nextDueDate has passed: ONE task + * for the most recent missed occurrence (due on that date), and + * nextDueDate moved past `now`. Runs lazily before the task list and as + * the task-templates job. Returns how many tasks were created. + */ + async generateFromTemplates(now: Date = new Date()): Promise { + const dueTemplates = await this.prisma.taskTemplate.findMany({ + where: { active: true, nextDueDate: { lte: now } }, + }); + let created = 0; + for (const template of dueTemplates) { + const task = await this.prisma.$transaction((tx) => + this.generateFromTemplateIn(tx, template, now), + ); + if (task !== null) { + created += 1; + } + } + return created; + } + + /** + * The claim is a compare-and-swap on the nextDueDate that was read: of + * several replicas generating at once, exactly one gets the occurrence. + * Claim and task share the transaction, so a failed task write gives the + * occurrence back instead of losing it. + */ + private async generateFromTemplateIn( + tx: Prisma.TransactionClient, + template: TaskTemplate, + now: Date, + ): Promise { + const { occurrence, nextDueDate } = latestMissedOccurrence(template, now); + const claim = await tx.taskTemplate.updateMany({ + where: { id: template.id, nextDueDate: template.nextDueDate }, + data: { nextDueDate }, + }); + if (claim.count === 0) { + return null; + } + return this.createInTx( + tx, + { + title: template.title, + description: template.description, + priority: template.priority, + dueDate: occurrence, + assignedUserId: template.assignedUserId, + relatedType: 'GENERAL', + sourceTemplateId: template.id, + }, + null, + ); + } + + private async findIn(db: DbClient, id: string): Promise { + const task = await db.task.findUnique({ where: { id } }); + if (!task) { + throw new NotFoundException('Task not found'); + } return task; } } diff --git a/ApiCore-Backend/src/treatments/dto/bulk-create-treatment.dto.ts b/ApiCore-Backend/src/treatments/dto/bulk-create-treatment.dto.ts new file mode 100644 index 0000000..1923a8e --- /dev/null +++ b/ApiCore-Backend/src/treatments/dto/bulk-create-treatment.dto.ts @@ -0,0 +1,16 @@ +import { OmitType } from '@nestjs/swagger'; +import { IsBulkColonyIds } from '../../common/bulk/bulk-validation'; +import { CreateTreatmentDto } from './create-treatment.dto'; + +/** + * One treatment for several colonies: every field of a single treatment, + * entered once and stored on each record. `batchNumber` is the drug lot from + * the package (Bestandsbuch) - entered by the user, never generated; the + * records of one bulk entry are grouped by `bulkId` instead. + */ +export class BulkCreateTreatmentDto extends OmitType(CreateTreatmentDto, [ + 'colonyId', +] as const) { + @IsBulkColonyIds() + colonyIds!: string[]; +} diff --git a/ApiCore-Backend/src/treatments/treatments.controller.ts b/ApiCore-Backend/src/treatments/treatments.controller.ts index 62c335e..e440409 100644 --- a/ApiCore-Backend/src/treatments/treatments.controller.ts +++ b/ApiCore-Backend/src/treatments/treatments.controller.ts @@ -12,6 +12,7 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateTreatmentDto } from './dto/create-treatment.dto'; +import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto'; import { WithdrawalStatusQueryDto } from './dto/withdrawal-status-query.dto'; import { LastByProductQueryDto } from './dto/last-by-product-query.dto'; @@ -75,6 +76,17 @@ export class TreatmentsController { return this.treatmentsService.create(dto, actor.id); } + /** One treatment for several colonies - all records or none. */ + @RequirePermission('treatments:create') + @Idempotent() + @Post('bulk') + createBulk( + @Body() dto: BulkCreateTreatmentDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.treatmentsService.createBulk(dto, actor.id); + } + @RequirePermission('treatments:update') @Patch(':id') update( diff --git a/ApiCore-Backend/src/treatments/treatments.module.ts b/ApiCore-Backend/src/treatments/treatments.module.ts index 15b7c2f..4be56f2 100644 --- a/ApiCore-Backend/src/treatments/treatments.module.ts +++ b/ApiCore-Backend/src/treatments/treatments.module.ts @@ -1,12 +1,10 @@ import { Module } from '@nestjs/common'; -import { AutomationModule } from '../automation/automation.module'; import { TreatmentsController } from './treatments.controller'; import { ColonyTreatmentsController } from './colony-treatments.controller'; import { TreatmentPrefillService } from './treatment-prefill.service'; import { TreatmentsService } from './treatments.service'; @Module({ - imports: [AutomationModule], controllers: [TreatmentsController, ColonyTreatmentsController], providers: [TreatmentsService, TreatmentPrefillService], exports: [TreatmentsService], diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts index e5979ed..f5ed36e 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts @@ -1,23 +1,27 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { AutomationService } from '../automation/automation.service'; import { TreatmentsService } from './treatments.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits'; describe('TreatmentsService', () => { let service: TreatmentsService; let prisma: { + treatment: { findMany: jest.Mock; findUnique: jest.Mock }; + $transaction: jest.Mock; + }; + let tx: { treatment: { - findMany: jest.Mock; findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; }; - colony: { findUnique: jest.Mock }; + colony: { findUnique: jest.Mock; findMany: jest.Mock }; + $queryRaw: jest.Mock; }; let auditLog: { record: jest.Mock }; - let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; + let events: { record: jest.Mock }; const treatment = { id: 'treatment-1', @@ -26,24 +30,30 @@ describe('TreatmentsService', () => { }; beforeEach(() => { - prisma = { + tx = { treatment: { - findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), }, colony: { findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }), + findMany: jest.fn().mockResolvedValue([]), }, + $queryRaw: jest.fn().mockResolvedValue([]), + }; + prisma = { + treatment: { findMany: jest.fn(), findUnique: jest.fn() }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), }; auditLog = { record: jest.fn() }; - calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + events = { record: jest.fn() }; service = new TreatmentsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, - calendarSync as unknown as CalendarSyncService, - { evaluateTreatment: jest.fn() } as unknown as AutomationService, + events as unknown as DomainEventsService, ); }); @@ -55,14 +65,14 @@ describe('TreatmentsService', () => { }); it('create stores the acting user and product', async () => { - prisma.treatment.create.mockResolvedValue(treatment); + tx.treatment.create.mockResolvedValue(treatment); await service.create( { colonyId: 'colony-1', date: '2026-01-01', product: 'Ameisensäure' }, 'user-1', ); - const [[createArg]] = prisma.treatment.create.mock.calls as [ + const [[createArg]] = tx.treatment.create.mock.calls as [ [ { data: { @@ -82,9 +92,9 @@ describe('TreatmentsService', () => { }); it('update writes an audit log entry with before and after data', async () => { - prisma.treatment.findUnique.mockResolvedValue(treatment); + tx.treatment.findUnique.mockResolvedValue(treatment); const updated = { ...treatment, notes: 'geändert' }; - prisma.treatment.update.mockResolvedValue(updated); + tx.treatment.update.mockResolvedValue(updated); const result = await service.update( 'treatment-1', @@ -101,16 +111,17 @@ describe('TreatmentsService', () => { beforeData: treatment, afterData: updated, }), + tx, ); }); it('update clears start and end date on null', async () => { - prisma.treatment.findUnique.mockResolvedValue({ + tx.treatment.findUnique.mockResolvedValue({ ...treatment, startDate: new Date('2026-07-01'), endDate: new Date('2026-07-14'), }); - prisma.treatment.update.mockResolvedValue(treatment); + tx.treatment.update.mockResolvedValue(treatment); await service.update( 'treatment-1', @@ -118,22 +129,266 @@ describe('TreatmentsService', () => { 'user-1', ); - const [[updateArg]] = prisma.treatment.update.mock.calls as [ + const [[updateArg]] = tx.treatment.update.mock.calls as [ [{ data: { startDate?: Date | null; endDate?: Date | null } }], ]; expect(updateArg.data.startDate).toBeNull(); expect(updateArg.data.endDate).toBeNull(); }); - it('update re-enqueues the calendar sync so a changed date moves the event', async () => { - prisma.treatment.findUnique.mockResolvedValue(treatment); - prisma.treatment.update.mockResolvedValue(treatment); + it('create writes treatment, audit entry and TreatmentRecorded in one transaction', async () => { + tx.treatment.create.mockResolvedValue(treatment); + + await service.create( + { colonyId: 'colony-1', date: '2026-01-01', product: 'Ameisensäure' }, + 'user-1', + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Treatment', + entityId: 'treatment-1', + action: 'CREATE', + userId: 'user-1', + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'TreatmentRecorded', + aggregateType: 'Treatment', + aggregateId: 'treatment-1', + payload: { colonyId: 'colony-1' }, + actorUserId: 'user-1', + }); + }); + + it('update takes "before" from the locked row inside the transaction, not from a pre-read', async () => { + const stale = { ...treatment, notes: 'veraltet' }; + const locked = { ...treatment, notes: 'gesperrt gelesen' }; + prisma.treatment.findUnique.mockResolvedValue(stale); + tx.treatment.findUnique.mockResolvedValue(locked); + tx.treatment.update.mockResolvedValue(treatment); + + await service.update('treatment-1', { notes: 'neu' }, 'user-1'); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ beforeData: locked }), + tx, + ); + }); + + it('update reads the new colony apiary inside the transaction when re-filed', async () => { + tx.treatment.findUnique.mockResolvedValue(treatment); + tx.colony.findUnique.mockResolvedValue({ apiaryId: 'apiary-2' }); + tx.treatment.update.mockResolvedValue(treatment); + + await service.update('treatment-1', { colonyId: 'colony-2' }, 'user-1'); + + const [[updateArg]] = tx.treatment.update.mock.calls as [ + [{ data: { apiaryId?: string } }], + ]; + expect(updateArg.data.apiaryId).toBe('apiary-2'); + }); + + it('create reads and locks the colony inside the transaction', async () => { + tx.treatment.create.mockResolvedValue(treatment); + + await service.create( + { colonyId: 'colony-1', date: '2026-01-01', product: 'Ameisensäure' }, + 'user-1', + ); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + expect(tx.colony.findUnique).toHaveBeenCalled(); + }); + + describe('createBulk', () => { + const shared = { + date: '2026-08-01', + product: 'Oxuvar', + batchNumber: 'CH-1', + }; + type CreateArg = { data: Record & { colonyId: string } }; + + beforeEach(() => { + tx.colony.findMany.mockResolvedValue([ + { id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' }, + { id: 'colony-2', status: 'WEAK', apiaryId: 'apiary-2' }, + ]); + tx.treatment.create.mockImplementation(({ data }: CreateArg) => + Promise.resolve({ ...data, id: `treatment-of-${data.colonyId}` }), + ); + }); + + it('createBulk writes one treatment per colony with the shared fields and that colony apiary', async () => { + const result = await service.createBulk( + { ...shared, colonyIds: ['colony-2', 'colony-1'] }, + 'user-1', + ); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + timeout: BULK_TRANSACTION_TIMEOUT_MS, + }); + expect(result.bulkId).toEqual(expect.any(String)); + expect(result.records.map((record) => record.id)).toEqual([ + 'treatment-of-colony-2', + 'treatment-of-colony-1', + ]); + const rows = (tx.treatment.create.mock.calls as [CreateArg][]).map( + ([arg]) => arg.data, + ); + expect(rows).toEqual([ + expect.objectContaining({ + colonyId: 'colony-2', + apiaryId: 'apiary-2', + product: 'Oxuvar', + batchNumber: 'CH-1', + date: new Date('2026-08-01'), + userId: 'user-1', + }), + expect.objectContaining({ + colonyId: 'colony-1', + apiaryId: 'apiary-1', + product: 'Oxuvar', + batchNumber: 'CH-1', + date: new Date('2026-08-01'), + userId: 'user-1', + }), + ]); + expect(rows[0]).not.toHaveProperty('colonyIds'); + }); + + it('createBulk writes an audit entry and a TreatmentRecorded with the bulkId per record', async () => { + const { bulkId } = await service.createBulk( + { ...shared, colonyIds: ['colony-1', 'colony-2'] }, + 'user-1', + ); + + expect(auditLog.record).toHaveBeenCalledTimes(2); + for (const colonyId of ['colony-1', 'colony-2']) { + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Treatment', + entityId: `treatment-of-${colonyId}`, + action: 'CREATE', + userId: 'user-1', + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'TreatmentRecorded', + aggregateType: 'Treatment', + aggregateId: `treatment-of-${colonyId}`, + payload: { colonyId, bulkId }, + actorUserId: 'user-1', + }); + } + expect(events.record).toHaveBeenCalledTimes(2); + }); + + it('createBulk stores the same row as a single create', async () => { + await service.create({ ...shared, colonyId: 'colony-1' }, 'user-1'); + await service.createBulk( + { ...shared, colonyIds: ['colony-1'] }, + 'user-1', + ); + + const [[single], [bulk]] = tx.treatment.create.mock.calls as [ + CreateArg, + ][]; + expect(bulk).toEqual(single); + }); + + it('createBulk writes nothing when a colony has ended', async () => { + tx.colony.findMany.mockResolvedValue([ + { id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' }, + { id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' }, + ]); + + const error: unknown = await service + .createBulk( + { ...shared, colonyIds: ['colony-1', 'colony-2'] }, + 'user-1', + ) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(BadRequestException); + expect((error as BadRequestException).getResponse()).toEqual( + expect.objectContaining({ invalidColonyIds: ['colony-2'] }), + ); + expect(tx.treatment.create).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + + it('createBulk rolls back every record when an audit write fails', async () => { + // A transaction that keeps its writes only when the work succeeds. + const committed: unknown[] = []; + prisma.$transaction.mockImplementation( + async (work: (txArg: typeof tx) => Promise) => { + const pending: unknown[] = []; + tx.treatment.create.mockImplementation(({ data }: CreateArg) => { + pending.push(data); + return Promise.resolve({ + ...data, + id: `treatment-of-${data.colonyId}`, + }); + }); + const result = await work(tx); + committed.push(...pending); + return result; + }, + ); + auditLog.record + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('audit write failed')); + + await expect( + service.createBulk( + { ...shared, colonyIds: ['colony-1', 'colony-2'] }, + 'user-1', + ), + ).rejects.toThrow('audit write failed'); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.treatment.create).toHaveBeenCalledTimes(2); + expect(committed).toEqual([]); + // Every write went through the one transaction client. + for (const [, client] of auditLog.record.mock.calls as [ + unknown, + unknown, + ][]) { + expect(client).toBe(tx); + } + for (const [client] of events.record.mock.calls as [unknown][]) { + expect(client).toBe(tx); + } + }); + }); + + it('update records TreatmentCorrected and no automation', async () => { + tx.treatment.findUnique.mockResolvedValue(treatment); + tx.treatment.update.mockResolvedValue(treatment); await service.update('treatment-1', { date: '2026-05-02' }, 'user-1'); - expect(calendarSync.enqueue).toHaveBeenCalledWith( - 'TREATMENT', - 'treatment-1', + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'UPDATE' }), + tx, + ); + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'TreatmentCorrected', + aggregateType: 'Treatment', + aggregateId: 'treatment-1', + payload: { colonyId: 'colony-1' }, + }), ); }); diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts index 0a214d2..3ef269f 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.ts @@ -3,13 +3,19 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { AutomationService } from '../automation/automation.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; -import { Treatment } from '../../generated/prisma'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { Prisma, Treatment } from '../../generated/prisma'; import { optionalDate } from '../common/patch-values'; +import { + BulkCreateResult, + recordedForColony, +} from '../common/bulk/bulk-create'; +import { runBulk } from '../common/bulk/run-bulk'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { CreateTreatmentDto } from './dto/create-treatment.dto'; +import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto'; export interface WithdrawalStatus { @@ -28,13 +34,31 @@ function treatmentStart( : treatment.date; } +// The row a new treatment is stored as - for create and createBulk alike, so +// a bulk entry stores exactly what single entries would. Where the colony +// stands today is where it was treated: the apiary is kept on the treatment +// for the Bestandsbuch, the colony may be moved later. +function treatmentRow( + dto: CreateTreatmentDto, + apiaryId: string, + actingUserId: string, +): Prisma.TreatmentUncheckedCreateInput { + return { + ...dto, + apiaryId, + date: new Date(dto.date), + startDate: optionalDate(dto.startDate), + endDate: optionalDate(dto.endDate), + userId: actingUserId, + }; +} + @Injectable() export class TreatmentsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly calendarSync: CalendarSyncService, - private readonly automation: AutomationService, + private readonly events: DomainEventsService, ) {} async findAll(): Promise { @@ -110,34 +134,75 @@ export class TreatmentsService { dto: CreateTreatmentDto, actingUserId: string, ): Promise { - const colony = await this.prisma.colony.findUnique({ - where: { id: dto.colonyId }, - select: { apiaryId: true }, + return this.prisma.$transaction(async (tx) => { + // Locked, so the apiary kept for the Bestandsbuch is the one the + // colony really stands at when the treatment commits. + await lockRowForUpdate(tx, 'Colony', dto.colonyId); + const colony = await tx.colony.findUnique({ + where: { id: dto.colonyId }, + select: { apiaryId: true }, + }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + return this.recordIn( + tx, + treatmentRow(dto, colony.apiaryId, actingUserId), + actingUserId, + ); }); - if (!colony) { - throw new NotFoundException('Colony not found'); - } - const treatment = await this.prisma.treatment.create({ - data: { - ...dto, - // Where the colony stands today is where it was treated - kept on the - // treatment for the Bestandsbuch, the colony may be moved later. - apiaryId: colony.apiaryId, - date: new Date(dto.date), - startDate: optionalDate(dto.startDate), - endDate: optionalDate(dto.endDate), + } + + /** + * One treatment per colony, all or none (see runBulk). Every record gets + * its own audit entry and TreatmentRecorded, so calendar entry, follow-up + * check and webhook follow per colony exactly as for a single treatment. + * The apiary comes from each colony as read under the share lock. + */ + async createBulk( + dto: BulkCreateTreatmentDto, + actingUserId: string, + ): Promise> { + const { colonyIds, ...shared } = dto; + return runBulk( + this.prisma, + colonyIds.map((colonyId) => ({ colonyId })), + (tx, { colonyId }, colony, bulkId) => + this.recordIn( + tx, + treatmentRow({ ...shared, colonyId }, colony.apiaryId, actingUserId), + actingUserId, + bulkId, + ), + ); + } + + // Treatment, audit entry and TreatmentRecorded in the caller's + // transaction; calendar entry and follow-up check follow from the event. + private async recordIn( + tx: Prisma.TransactionClient, + data: Prisma.TreatmentUncheckedCreateInput, + actingUserId: string, + bulkId?: string, + ): Promise { + const treatment = await tx.treatment.create({ data }); + await this.auditLog.record( + { + entityType: 'Treatment', + entityId: treatment.id, + action: 'CREATE', userId: actingUserId, + afterData: treatment, }, + tx, + ); + await this.events.record(tx, { + type: 'TreatmentRecorded', + aggregateType: 'Treatment', + aggregateId: treatment.id, + payload: recordedForColony(treatment.colonyId, bulkId), + actorUserId: actingUserId, }); - await this.auditLog.record({ - entityType: 'Treatment', - entityId: treatment.id, - action: 'CREATE', - userId: actingUserId, - afterData: treatment, - }); - await this.calendarSync.enqueue('TREATMENT', treatment.id); - await this.automation.evaluateTreatment(treatment); return treatment; } @@ -146,36 +211,54 @@ export class TreatmentsService { dto: UpdateTreatmentDto, actingUserId: string, ): Promise { - const before = await this.findById(id); - // Re-filed to another colony: take that colony's apiary as the location. - const movedColony = - dto.colonyId !== undefined && dto.colonyId !== before.colonyId - ? await this.prisma.colony.findUnique({ - where: { id: dto.colonyId }, - select: { apiaryId: true }, - }) - : null; - const treatment = await this.prisma.treatment.update({ - where: { id }, - data: { - ...dto, - apiaryId: movedColony ? movedColony.apiaryId : undefined, - date: dto.date ? new Date(dto.date) : undefined, - startDate: optionalDate(dto.startDate), - endDate: optionalDate(dto.endDate), - }, + // TreatmentCorrected moves the calendar event (changed date, colony or + // product) but starts no follow-up check. + return this.prisma.$transaction(async (tx) => { + // The audit "before" is read under the row lock, not before the + // transaction: a concurrent edit cannot slip in between. + await lockRowForUpdate(tx, 'Treatment', id); + const before = await tx.treatment.findUnique({ where: { id } }); + if (!before) { + throw new NotFoundException('Treatment not found'); + } + // Re-filed to another colony: take that colony's apiary as the location. + let movedColony: { apiaryId: string | null } | null = null; + if (dto.colonyId !== undefined && dto.colonyId !== before.colonyId) { + await lockRowForUpdate(tx, 'Colony', dto.colonyId); + movedColony = await tx.colony.findUnique({ + where: { id: dto.colonyId }, + select: { apiaryId: true }, + }); + } + const treatment = await tx.treatment.update({ + where: { id }, + data: { + ...dto, + apiaryId: movedColony ? movedColony.apiaryId : undefined, + date: dto.date ? new Date(dto.date) : undefined, + startDate: optionalDate(dto.startDate), + endDate: optionalDate(dto.endDate), + }, + }); + await this.auditLog.record( + { + entityType: 'Treatment', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: treatment, + }, + tx, + ); + await this.events.record(tx, { + type: 'TreatmentCorrected', + aggregateType: 'Treatment', + aggregateId: id, + payload: { colonyId: treatment.colonyId }, + actorUserId: actingUserId, + }); + return treatment; }); - await this.auditLog.record({ - entityType: 'Treatment', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: treatment, - }); - // A changed date, colony or product must move the calendar event, not - // leave the old one behind. - await this.calendarSync.enqueue('TREATMENT', treatment.id); - return treatment; } } diff --git a/ApiCore-Backend/src/varroa/dto/bulk-create-varroa-measurement.dto.spec.ts b/ApiCore-Backend/src/varroa/dto/bulk-create-varroa-measurement.dto.spec.ts new file mode 100644 index 0000000..d423732 --- /dev/null +++ b/ApiCore-Backend/src/varroa/dto/bulk-create-varroa-measurement.dto.spec.ts @@ -0,0 +1,72 @@ +import { plainToInstance } from 'class-transformer'; +import { validate, ValidationError } from 'class-validator'; +import { BulkCreateVarroaMeasurementDto } from './bulk-create-varroa-measurement.dto'; + +// Every constraint message, nested entries included. +function messagesOf(errors: ValidationError[]): string[] { + return errors.flatMap((error) => [ + ...Object.values(error.constraints ?? {}), + ...messagesOf(error.children ?? []), + ]); +} + +async function check(body: Record): Promise { + const dto = plainToInstance(BulkCreateVarroaMeasurementDto, body); + return messagesOf( + await validate(dto, { whitelist: true, forbidNonWhitelisted: true }), + ); +} + +const shared = { + date: '2026-09-10', + method: 'NATURAL_MITE_FALL', + durationMinutes: 4320, +}; + +describe('BulkCreateVarroaMeasurementDto', () => { + it('accepts one entry per colony with its own mite count', async () => { + await expect( + check({ + ...shared, + entries: [ + { colonyId: 'colony-1', miteCount: 30, assessment: 'hoch' }, + { colonyId: 'colony-2', miteCount: 6 }, + ], + }), + ).resolves.toEqual([]); + }); + + it('rejects an empty colonyId in an entry with the message of the colonyIds path', async () => { + await expect( + check({ ...shared, entries: [{ colonyId: '', miteCount: 3 }] }), + ).resolves.toEqual(['colonyId enthält eine leere Volk-ID']); + }); + + it('rejects a colonyId that is no text', async () => { + await expect( + check({ ...shared, entries: [{ colonyId: 7, miteCount: 3 }] }), + ).resolves.toContain('colonyId enthält eine ungültige Volk-ID'); + }); + + it('rejects a colony that appears twice', async () => { + await expect( + check({ + ...shared, + entries: [ + { colonyId: 'colony-1', miteCount: 3 }, + { colonyId: 'colony-1', miteCount: 4 }, + ], + }), + ).resolves.toEqual(['entries enthält ein Volk doppelt']); + }); + + it('rejects a shared mite count', async () => { + const messages = await check({ + ...shared, + miteCount: 5, + entries: [{ colonyId: 'colony-1' }], + }); + + expect(messages).toEqual(['property miteCount should not exist']); + }); +}); diff --git a/ApiCore-Backend/src/varroa/dto/bulk-create-varroa-measurement.dto.ts b/ApiCore-Backend/src/varroa/dto/bulk-create-varroa-measurement.dto.ts new file mode 100644 index 0000000..5d765a4 --- /dev/null +++ b/ApiCore-Backend/src/varroa/dto/bulk-create-varroa-measurement.dto.ts @@ -0,0 +1,35 @@ +import { OmitType, PickType } from '@nestjs/swagger'; +import { + IsBulkEntries, + IsBulkEntryColonyId, +} from '../../common/bulk/bulk-validation'; +import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto'; + +const OWN_READINGS = ['miteCount', 'calculatedValue', 'assessment'] as const; + +/** + * What each colony has of its own in a bulk count: its mites and their + * reading, validated exactly as on a single measurement. The colony id + * follows the bulk rule (non-empty, as in colonyIds). + */ +export class BulkVarroaEntryDto extends PickType( + CreateVarroaMeasurementDto, + OWN_READINGS, +) { + @IsBulkEntryColonyId() + colonyId!: string; +} + +/** + * One varroa count for several colonies: date, method, duration and notes + * are shared, every colony brings its own mite count in `entries` - there is + * no shared mite count. calculatedValue is derived per entry as for a single + * measurement unless the entry carries one. + */ +export class BulkCreateVarroaMeasurementDto extends OmitType( + CreateVarroaMeasurementDto, + ['colonyId', ...OWN_READINGS] as const, +) { + @IsBulkEntries(BulkVarroaEntryDto) + entries!: BulkVarroaEntryDto[]; +} diff --git a/ApiCore-Backend/src/varroa/varroa.controller.ts b/ApiCore-Backend/src/varroa/varroa.controller.ts index 452e10c..a0c1f89 100644 --- a/ApiCore-Backend/src/varroa/varroa.controller.ts +++ b/ApiCore-Backend/src/varroa/varroa.controller.ts @@ -4,6 +4,7 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; +import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto'; import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; import { VarroaService } from './varroa.service'; import { VarroaThresholdService } from './varroa-threshold.service'; @@ -47,6 +48,17 @@ export class VarroaController { return this.varroaService.create(dto, actor.id); } + /** One count for several colonies, each with its own mites - all or none. */ + @RequirePermission('varroa:create') + @Idempotent() + @Post('bulk') + createBulk( + @Body() dto: BulkCreateVarroaMeasurementDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.varroaService.createBulk(dto, actor.id); + } + @RequirePermission('varroa:update') @Patch(':id') update( diff --git a/ApiCore-Backend/src/varroa/varroa.module.ts b/ApiCore-Backend/src/varroa/varroa.module.ts index 8354d63..a99442f 100644 --- a/ApiCore-Backend/src/varroa/varroa.module.ts +++ b/ApiCore-Backend/src/varroa/varroa.module.ts @@ -1,12 +1,15 @@ 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'; import { VarroaThresholdService } from './varroa-threshold.service'; +/** + * The threshold rule and the varroa.measured webhook follow from the + * measurement's domain events (DomainEventsModule is global), so no + * AutomationModule here. + */ @Module({ - imports: [AutomationModule], controllers: [VarroaController, ColonyVarroaMeasurementsController], providers: [VarroaService, VarroaThresholdService], exports: [VarroaService], diff --git a/ApiCore-Backend/src/varroa/varroa.service.spec.ts b/ApiCore-Backend/src/varroa/varroa.service.spec.ts index 57eee4e..6118c00 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.spec.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.spec.ts @@ -1,12 +1,25 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, 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'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { Prisma } from '../../generated/prisma'; +import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits'; describe('VarroaService', () => { let service: VarroaService; + let tx: { + $queryRaw: jest.Mock; + varroaMeasurement: { + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + colony: { findMany: jest.Mock }; + }; let prisma: { + $connect: jest.Mock; + $transaction: jest.Mock; varroaMeasurement: { findMany: jest.Mock; findUnique: jest.Mock; @@ -15,7 +28,7 @@ describe('VarroaService', () => { }; }; let auditLog: { record: jest.Mock }; - let automation: { evaluateVarroaMeasurement: jest.Mock }; + let events: { record: jest.Mock }; const measurement = { id: 'varroa-1', @@ -24,7 +37,20 @@ describe('VarroaService', () => { }; beforeEach(() => { + tx = { + $queryRaw: jest.fn().mockResolvedValue([]), + varroaMeasurement: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + colony: { findMany: jest.fn().mockResolvedValue([]) }, + }; prisma = { + $connect: jest.fn(), + $transaction: jest.fn((work: (client: typeof tx) => Promise) => + work(tx), + ), varroaMeasurement: { findMany: jest.fn(), findUnique: jest.fn(), @@ -32,17 +58,23 @@ describe('VarroaService', () => { update: jest.fn(), }, }; - auditLog = { record: jest.fn() }; - automation = { - evaluateVarroaMeasurement: jest.fn().mockResolvedValue(undefined), - }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + events = { record: jest.fn().mockResolvedValue(undefined) }; service = new VarroaService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, - automation as unknown as AutomationService, + events as unknown as DomainEventsService, ); }); + it('has no side-effect dependency: the threshold rule and webhook follow from its events', () => { + expect(Reflect.getMetadata('design:paramtypes', VarroaService)).toEqual([ + PrismaService, + AuditLogService, + DomainEventsService, + ]); + }); + it('findById throws NotFoundException when missing', async () => { prisma.varroaMeasurement.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( @@ -50,43 +82,154 @@ describe('VarroaService', () => { ); }); - it('create stores the acting user as the measurer', async () => { - prisma.varroaMeasurement.create.mockResolvedValue(measurement); + it('create writes measurement, audit entry and VarroaMeasured in one transaction', async () => { + tx.varroaMeasurement.create.mockResolvedValue(measurement); - await service.create( + const result = await service.create( { colonyId: 'colony-1', date: '2026-01-01', method: 'NATURAL_MITE_FALL' }, 'user-1', ); - const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [ + expect(result).toBe(measurement); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [ [{ data: { colonyId: string; userId: string } }], ]; + // The acting user is the measurer. expect(createArg.data.colonyId).toBe('colony-1'); expect(createArg.data.userId).toBe('user-1'); + expect(auditLog.record).toHaveBeenCalledWith( + { + entityType: 'VarroaMeasurement', + entityId: 'varroa-1', + action: 'CREATE', + userId: 'user-1', + afterData: measurement, + }, + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'VarroaMeasured', + aggregateType: 'VarroaMeasurement', + aggregateId: 'varroa-1', + payload: { colonyId: 'colony-1' }, + actorUserId: 'user-1', + }); + expect(prisma.varroaMeasurement.create).not.toHaveBeenCalled(); }); - it('update writes an audit log entry and re-evaluates automation', async () => { - prisma.varroaMeasurement.findUnique.mockResolvedValue(measurement); - const updated = { ...measurement, miteCount: 12 }; - prisma.varroaMeasurement.update.mockResolvedValue(updated); + it('create fails as a whole when its event cannot be written', async () => { + tx.varroaMeasurement.create.mockResolvedValue(measurement); + events.record.mockRejectedValue(new Error('outbox down')); + + await expect( + service.create( + { + colonyId: 'colony-1', + date: '2026-01-01', + method: 'NATURAL_MITE_FALL', + }, + 'user-1', + ), + ).rejects.toThrow('outbox down'); + }); + + it('update writes the correction, audit entry and VarroaCorrected in one transaction', async () => { + tx.varroaMeasurement.findUnique.mockResolvedValue(measurement); + const updated = { ...measurement, colonyId: 'colony-2', miteCount: 12 }; + tx.varroaMeasurement.update.mockResolvedValue(updated); const result = await service.update( 'varroa-1', - { miteCount: 12 }, + { colonyId: 'colony-2', miteCount: 12 }, 'user-1', ); expect(result).toBe(updated); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.varroaMeasurement.findUnique).toHaveBeenCalledWith({ + where: { id: 'varroa-1' }, + }); expect(auditLog.record).toHaveBeenCalledWith( - expect.objectContaining({ + { entityType: 'VarroaMeasurement', entityId: 'varroa-1', action: 'UPDATE', + userId: 'user-1', beforeData: measurement, afterData: updated, - }), + }, + tx, ); - expect(automation.evaluateVarroaMeasurement).toHaveBeenCalledWith(updated); + // The threshold rule re-evaluates the correction from the event + // (automation.varroa-threshold), not from here. + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'VarroaCorrected', + aggregateType: 'VarroaMeasurement', + aggregateId: 'varroa-1', + payload: { colonyId: 'colony-2' }, + actorUserId: 'user-1', + }); + expect(prisma.varroaMeasurement.update).not.toHaveBeenCalled(); + }); + + it('update locks the measurement row before re-reading it as "before"', async () => { + tx.varroaMeasurement.findUnique.mockResolvedValue(measurement); + tx.varroaMeasurement.update.mockResolvedValue(measurement); + + await service.update('varroa-1', { miteCount: 3 }, 'user-1'); + + const [[lock]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]]; + expect(lock.sql).toBe( + 'SELECT id FROM "VarroaMeasurement" WHERE id = ? FOR UPDATE', + ); + expect(lock.values).toEqual(['varroa-1']); + const [lockedAt] = tx.$queryRaw.mock.invocationCallOrder; + const [readAt] = tx.varroaMeasurement.findUnique.mock.invocationCallOrder; + expect(lockedAt).toBeLessThan(readAt); + }); + + it('update derives the rate from and audits against the locked row, not a read outside the transaction', async () => { + const stale = { + ...measurement, + method: 'NATURAL_MITE_FALL', + miteCount: 45, + durationMinutes: 3 * 24 * 60, + calculatedValue: 15, + }; + // Another request corrected the count to 60 while this one waited. + const locked = { ...stale, miteCount: 60, calculatedValue: 20 }; + prisma.varroaMeasurement.findUnique.mockResolvedValue(stale); + tx.varroaMeasurement.findUnique.mockResolvedValue(locked); + tx.varroaMeasurement.update.mockResolvedValue(locked); + + await service.update( + 'varroa-1', + { durationMinutes: 5 * 24 * 60 }, + 'user-1', + ); + + expect(prisma.varroaMeasurement.findUnique).not.toHaveBeenCalled(); + const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [ + [{ data: { calculatedValue?: number } }], + ]; + // 60 mites over 5 days, not the stale 45. + expect(updateArg.data.calculatedValue).toBe(12); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ beforeData: locked }), + tx, + ); + }); + + it('update throws NotFoundException for a missing measurement and writes nothing', async () => { + tx.varroaMeasurement.findUnique.mockResolvedValue(null); + + await expect( + service.update('missing', { miteCount: 3 }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(tx.varroaMeasurement.update).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); }); it('findByColony orders results chronologically', async () => { @@ -103,7 +246,7 @@ describe('VarroaService', () => { describe('calculatedValue auto-computation', () => { it('computes mites/day for a natural mite fall count on create', async () => { - prisma.varroaMeasurement.create.mockResolvedValue(measurement); + tx.varroaMeasurement.create.mockResolvedValue(measurement); await service.create( { @@ -116,14 +259,14 @@ describe('VarroaService', () => { 'user-1', ); - const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [ + const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [ [{ data: { calculatedValue?: number } }], ]; expect(createArg.data.calculatedValue).toBe(15); }); it('does not compute calculatedValue for a non-time-based method', async () => { - prisma.varroaMeasurement.create.mockResolvedValue(measurement); + tx.varroaMeasurement.create.mockResolvedValue(measurement); await service.create( { @@ -135,14 +278,14 @@ describe('VarroaService', () => { 'user-1', ); - const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [ + const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [ [{ data: { calculatedValue?: number } }], ]; expect(createArg.data.calculatedValue).toBeUndefined(); }); it('respects an explicitly provided calculatedValue instead of computing one', async () => { - prisma.varroaMeasurement.create.mockResolvedValue(measurement); + tx.varroaMeasurement.create.mockResolvedValue(measurement); await service.create( { @@ -156,21 +299,21 @@ describe('VarroaService', () => { 'user-1', ); - const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [ + const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [ [{ data: { calculatedValue?: number } }], ]; expect(createArg.data.calculatedValue).toBe(99); }); it('backfills calculatedValue on update when it was never set', async () => { - prisma.varroaMeasurement.findUnique.mockResolvedValue({ + tx.varroaMeasurement.findUnique.mockResolvedValue({ ...measurement, method: 'NATURAL_MITE_FALL', miteCount: null, durationMinutes: null, calculatedValue: null, }); - prisma.varroaMeasurement.update.mockResolvedValue(measurement); + tx.varroaMeasurement.update.mockResolvedValue(measurement); await service.update( 'varroa-1', @@ -178,25 +321,25 @@ describe('VarroaService', () => { 'user-1', ); - const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [ + const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [ [{ data: { calculatedValue?: number } }], ]; expect(updateArg.data.calculatedValue).toBe(20); }); it('never overwrites an already-stored calculatedValue on an unrelated edit', async () => { - prisma.varroaMeasurement.findUnique.mockResolvedValue({ + tx.varroaMeasurement.findUnique.mockResolvedValue({ ...measurement, method: 'NATURAL_MITE_FALL', miteCount: 45, durationMinutes: 3 * 24 * 60, calculatedValue: 15, }); - prisma.varroaMeasurement.update.mockResolvedValue(measurement); + tx.varroaMeasurement.update.mockResolvedValue(measurement); await service.update('varroa-1', { assessment: 'stabil' }, 'user-1'); - const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [ + const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [ [{ data: { calculatedValue?: number } }], ]; expect(updateArg.data.calculatedValue).toBeUndefined(); @@ -215,10 +358,10 @@ describe('VarroaService', () => { before: object, dto: Parameters[1], ): Promise { - prisma.varroaMeasurement.findUnique.mockResolvedValue(before); - prisma.varroaMeasurement.update.mockResolvedValue(measurement); + tx.varroaMeasurement.findUnique.mockResolvedValue(before); + tx.varroaMeasurement.update.mockResolvedValue(measurement); await service.update('varroa-1', dto, 'user-1'); - const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [ + const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [ [{ data: { calculatedValue?: number | null } }], ]; return updateArg.data.calculatedValue; @@ -340,4 +483,150 @@ describe('VarroaService', () => { }); }); }); + + describe('createBulk', () => { + const shared = { + date: '2026-09-10', + method: 'NATURAL_MITE_FALL' as const, + durationMinutes: 4320, + notes: 'Windel', + }; + type CreateArg = { + data: Record & { + colonyId: string; + calculatedValue?: number; + }; + }; + + beforeEach(() => { + tx.colony.findMany.mockResolvedValue([ + { id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' }, + { id: 'colony-2', status: 'ACTIVE', apiaryId: 'apiary-1' }, + ]); + tx.varroaMeasurement.create.mockImplementation(({ data }: CreateArg) => + Promise.resolve({ ...data, id: `varroa-of-${data.colonyId}` }), + ); + }); + + it('createBulk derives mites per day per entry and keeps each entry own values', async () => { + const result = await service.createBulk( + { + ...shared, + entries: [ + { colonyId: 'colony-1', miteCount: 30, assessment: 'hoch' }, + { colonyId: 'colony-2', miteCount: 6 }, + ], + }, + 'user-1', + ); + + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + timeout: BULK_TRANSACTION_TIMEOUT_MS, + }); + const rows = ( + tx.varroaMeasurement.create.mock.calls as [CreateArg][] + ).map(([arg]) => arg.data); + expect(rows).toEqual([ + expect.objectContaining({ + ...shared, + colonyId: 'colony-1', + miteCount: 30, + calculatedValue: 10, + assessment: 'hoch', + date: new Date('2026-09-10'), + userId: 'user-1', + }), + expect.objectContaining({ + ...shared, + colonyId: 'colony-2', + miteCount: 6, + calculatedValue: 2, + date: new Date('2026-09-10'), + userId: 'user-1', + }), + ]); + expect(rows[1].assessment).toBeUndefined(); + expect(rows[0]).not.toHaveProperty('entries'); + expect(result.records).toHaveLength(2); + }); + + it('createBulk keeps a value entered by hand for an entry', async () => { + await service.createBulk( + { + ...shared, + entries: [ + { colonyId: 'colony-1', miteCount: 30, calculatedValue: 7 }, + ], + }, + 'user-1', + ); + + const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [ + CreateArg, + ][]; + expect(createArg.data.calculatedValue).toBe(7); + }); + + it('createBulk writes an audit entry and a VarroaMeasured with the bulkId per entry', async () => { + const { bulkId } = await service.createBulk( + { + ...shared, + entries: [ + { colonyId: 'colony-1', miteCount: 3 }, + { colonyId: 'colony-2', miteCount: 9 }, + ], + }, + 'user-1', + ); + + for (const colonyId of ['colony-1', 'colony-2']) { + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'VarroaMeasurement', + entityId: `varroa-of-${colonyId}`, + action: 'CREATE', + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'VarroaMeasured', + aggregateType: 'VarroaMeasurement', + aggregateId: `varroa-of-${colonyId}`, + payload: { colonyId, bulkId }, + actorUserId: 'user-1', + }); + } + }); + + it('createBulk stores the same row as a single create', async () => { + await service.create( + { ...shared, colonyId: 'colony-1', miteCount: 12 }, + 'user-1', + ); + await service.createBulk( + { ...shared, entries: [{ colonyId: 'colony-1', miteCount: 12 }] }, + 'user-1', + ); + + const [[single], [bulk]] = tx.varroaMeasurement.create.mock.calls as [ + CreateArg, + ][]; + expect(bulk).toEqual(single); + }); + + it('createBulk writes nothing when a colony has ended', async () => { + tx.colony.findMany.mockResolvedValue([ + { id: 'colony-1', status: 'DECEASED', apiaryId: 'apiary-1' }, + ]); + + await expect( + service.createBulk( + { ...shared, entries: [{ colonyId: 'colony-1', miteCount: 1 }] }, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.varroaMeasurement.create).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/varroa/varroa.service.ts b/ApiCore-Backend/src/varroa/varroa.service.ts index f1847f0..f266311 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.ts @@ -1,9 +1,21 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { DbClient } from '../prisma/db-client'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { AuditLogService } from '../audit/audit-log.service'; -import { AutomationService } from '../automation/automation.service'; -import { VarroaMeasurement, VarroaMethod } from '../../generated/prisma'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { + Prisma, + VarroaMeasurement, + VarroaMethod, +} from '../../generated/prisma'; +import { + BulkCreateResult, + recordedForColony, +} from '../common/bulk/bulk-create'; +import { runBulk } from '../common/bulk/run-bulk'; import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; +import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto'; import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; const MINUTES_PER_DAY = 24 * 60; @@ -80,12 +92,36 @@ function calculatedValueForUpdate( return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null; } +// The row a new measurement is stored as - for create and createBulk alike, +// so a bulk entry stores exactly what single entries would. A value entered +// by hand wins; otherwise the rate is derived where a rule exists. +function measurementRow( + dto: CreateVarroaMeasurementDto, + actingUserId: string, +): Prisma.VarroaMeasurementUncheckedCreateInput { + return { + ...dto, + calculatedValue: + dto.calculatedValue ?? + deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes), + date: new Date(dto.date), + userId: actingUserId, + }; +} + +/** + * Measurements are written together with their audit entry and domain event + * in one transaction: VarroaMeasured, VarroaCorrected. The threshold rule + * (automation.varroa-threshold, on both - a corrected count can cross the + * threshold) and the varroa.measured webhook (webhooks.forward) follow from + * the event, never from a direct call. + */ @Injectable() export class VarroaService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly automation: AutomationService, + private readonly events: DomainEventsService, ) {} async findAll(): Promise { @@ -95,13 +131,7 @@ export class VarroaService { } async findById(id: string): Promise { - const measurement = await this.prisma.varroaMeasurement.findUnique({ - where: { id }, - }); - if (!measurement) { - throw new NotFoundException('Varroa measurement not found'); - } - return measurement; + return this.findIn(this.prisma, id); } async findByColony(colonyId: string): Promise { @@ -115,26 +145,30 @@ export class VarroaService { dto: CreateVarroaMeasurementDto, actingUserId: string, ): Promise { - const calculatedValue = - dto.calculatedValue ?? - deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes); - const measurement = await this.prisma.varroaMeasurement.create({ - data: { - ...dto, - calculatedValue, - date: new Date(dto.date), - userId: actingUserId, - }, - }); - await this.auditLog.record({ - entityType: 'VarroaMeasurement', - entityId: measurement.id, - action: 'CREATE', - userId: actingUserId, - afterData: measurement, - }); - await this.automation.evaluateVarroaMeasurement(measurement); - return measurement; + return this.prisma.$transaction((tx) => + this.recordIn(tx, measurementRow(dto, actingUserId), actingUserId), + ); + } + + /** + * One measurement per entry, all or none (see runBulk). Date, method, + * duration and notes are shared; mite count, rate and assessment are each + * colony's own. Every record gets its own audit entry and VarroaMeasured, + * so the threshold rule and webhook follow per colony. + */ + async createBulk( + dto: BulkCreateVarroaMeasurementDto, + actingUserId: string, + ): Promise> { + const { entries, ...shared } = dto; + return runBulk(this.prisma, entries, (tx, entry, _colony, bulkId) => + this.recordIn( + tx, + measurementRow({ ...shared, ...entry }, actingUserId), + actingUserId, + bulkId, + ), + ); } async update( @@ -142,25 +176,79 @@ export class VarroaService { dto: UpdateVarroaMeasurementDto, actingUserId: string, ): Promise { - const before = await this.findById(id); - const calculatedValue = calculatedValueForUpdate(before, dto); - const measurement = await this.prisma.varroaMeasurement.update({ - where: { id }, - data: { - ...dto, - calculatedValue, - date: dto.date ? new Date(dto.date) : undefined, + return this.prisma.$transaction(async (tx) => { + // "before" is read under the row lock, so the rate is derived from + // and the audit entry describes the row this correction replaces - + // not one a concurrent correction already changed. + await lockRowForUpdate(tx, 'VarroaMeasurement', id); + const before = await this.findIn(tx, id); + const calculatedValue = calculatedValueForUpdate(before, dto); + const measurement = await tx.varroaMeasurement.update({ + where: { id }, + data: { + ...dto, + calculatedValue, + date: dto.date ? new Date(dto.date) : undefined, + }, + }); + await this.auditLog.record( + { + entityType: 'VarroaMeasurement', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: measurement, + }, + tx, + ); + await this.events.record(tx, { + type: 'VarroaCorrected', + aggregateType: 'VarroaMeasurement', + aggregateId: id, + payload: { colonyId: measurement.colonyId }, + actorUserId: actingUserId, + }); + return measurement; + }); + } + + // Measurement, audit entry and VarroaMeasured in the caller's transaction; + // threshold rule and webhook follow from the event. + private async recordIn( + tx: Prisma.TransactionClient, + data: Prisma.VarroaMeasurementUncheckedCreateInput, + actingUserId: string, + bulkId?: string, + ): Promise { + const measurement = await tx.varroaMeasurement.create({ data }); + await this.auditLog.record( + { + entityType: 'VarroaMeasurement', + entityId: measurement.id, + action: 'CREATE', + userId: actingUserId, + afterData: measurement, }, + tx, + ); + await this.events.record(tx, { + type: 'VarroaMeasured', + aggregateType: 'VarroaMeasurement', + aggregateId: measurement.id, + payload: recordedForColony(measurement.colonyId, bulkId), + actorUserId: actingUserId, }); - await this.auditLog.record({ - entityType: 'VarroaMeasurement', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: measurement, + return measurement; + } + + private async findIn(db: DbClient, id: string): Promise { + const measurement = await db.varroaMeasurement.findUnique({ + where: { id }, }); - await this.automation.evaluateVarroaMeasurement(measurement); + if (!measurement) { + throw new NotFoundException('Varroa measurement not found'); + } return measurement; } } diff --git a/ApiCore-Backend/src/webhooks/dto/list-webhook-deliveries.dto.ts b/ApiCore-Backend/src/webhooks/dto/list-webhook-deliveries.dto.ts new file mode 100644 index 0000000..51874bb --- /dev/null +++ b/ApiCore-Backend/src/webhooks/dto/list-webhook-deliveries.dto.ts @@ -0,0 +1,21 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export const WEBHOOK_DELIVERIES_DEFAULT_LIMIT = 50; +export const WEBHOOK_DELIVERIES_MAX_LIMIT = 100; + +export class ListWebhookDeliveriesDto { + @ApiPropertyOptional({ + minimum: 1, + maximum: WEBHOOK_DELIVERIES_MAX_LIMIT, + default: WEBHOOK_DELIVERIES_DEFAULT_LIMIT, + description: 'Anzahl der neuesten Zustellungen', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(WEBHOOK_DELIVERIES_MAX_LIMIT) + limit?: number; +} diff --git a/ApiCore-Backend/src/webhooks/webhook-delivery.spec.ts b/ApiCore-Backend/src/webhooks/webhook-delivery.spec.ts new file mode 100644 index 0000000..f7f4790 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook-delivery.spec.ts @@ -0,0 +1,134 @@ +import * as net from 'node:net'; +import { + postWebhook, + WEBHOOK_DEADLINE_MS, + WEBHOOK_TIMEOUT_MS, +} from './webhook-delivery'; + +// Real deliveries refuse loopback; these tests need a local receiver. The +// guard itself is covered by webhook-url-guard.spec.ts and webhooks.e2e. +jest.mock('./webhook-url-guard', () => ({ + ...jest.requireActual('./webhook-url-guard'), + isBlockedIp: () => false, +})); + +const DEADLINE_MESSAGE = 'Webhook receiver did not finish answering in time'; +/** Well below the 5 s idle timeout: only the deadline can end the exchange. */ +const TRICKLE_EVERY_MS = 50; +const TEST_DEADLINE_MS = 300; + +interface Receiver { + url: string; + /** Resolves once the receiver's side of the connection is closed. */ + closed: Promise; + stop: () => Promise; +} + +/** A raw TCP receiver: `answer` writes whatever bytes it wants. */ +async function startReceiver( + answer: (socket: net.Socket) => void, +): Promise { + let markClosed!: () => void; + const closed = new Promise((resolve) => { + markClosed = resolve; + }); + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('error', () => undefined); + socket.on('close', () => { + sockets.delete(socket); + markClosed(); + }); + socket.once('data', () => answer(socket)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as net.AddressInfo; + return { + url: `http://127.0.0.1:${port}/hook`, + closed, + stop: async () => { + sockets.forEach((socket) => socket.destroy()); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +/** Writes `chunk` every TRICKLE_EVERY_MS until the connection closes. */ +function trickle(socket: net.Socket, chunk: string): void { + const timer = setInterval(() => socket.write(chunk), TRICKLE_EVERY_MS); + socket.on('close', () => clearInterval(timer)); +} + +describe('postWebhook', () => { + let receiver: Receiver | undefined; + + afterEach(async () => { + jest.restoreAllMocks(); + await receiver?.stop(); + receiver = undefined; + }); + + it('pins the idle timeout and the deadline of a whole exchange', () => { + expect(WEBHOOK_TIMEOUT_MS).toBe(5_000); + expect(WEBHOOK_DEADLINE_MS).toBe(10_000); + }); + + it('aborts the request and closes the socket when the headers are not complete by the deadline', async () => { + receiver = await startReceiver((socket) => { + socket.write('HTTP/1.1 200 OK\r\n'); + trickle(socket, 'X-Slow: 1\r\n'); + }); + const startedAt = performance.now(); + + await expect( + postWebhook(receiver.url, {}, '{}', TEST_DEADLINE_MS), + ).rejects.toThrow(DEADLINE_MESSAGE); + await receiver.closed; + + expect(performance.now() - startedAt).toBeLessThan(2_000); + }, 3_000); + + it('cuts an answer body that is still trickling at the deadline', async () => { + receiver = await startReceiver((socket) => { + socket.write('HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n'); + trickle(socket, 'x'); + }); + const startedAt = performance.now(); + + // The status counts as soon as it arrives; the body is never read. + await expect( + postWebhook(receiver.url, {}, '{}', TEST_DEADLINE_MS), + ).resolves.toEqual({ status: 200 }); + await receiver.closed; + + expect(performance.now() - startedAt).toBeLessThan(2_000); + }, 3_000); + + it('answers a prompt receiver and clears its deadline, 10 s by default', async () => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + receiver = await startReceiver((socket) => { + socket.end('HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n'); + }); + + await expect(postWebhook(receiver.url, {}, '{}')).resolves.toEqual({ + status: 204, + }); + await receiver.closed; + + const deadlineCall = setTimeoutSpy.mock.calls.findIndex( + ([, ms]) => ms === WEBHOOK_DEADLINE_MS, + ); + expect(deadlineCall).toBeGreaterThanOrEqual(0); + const deadlineTimer = setTimeoutSpy.mock.results[deadlineCall] + .value as NodeJS.Timeout; + // Cleared once the request closes - a finished exchange is never cut. + const deadlineCleared = () => + clearTimeoutSpy.mock.calls.some(([timer]) => timer === deadlineTimer); + for (let i = 0; i < 50 && !deadlineCleared(); i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(deadlineCleared()).toBe(true); + }); +}); diff --git a/ApiCore-Backend/src/webhooks/webhook-delivery.ts b/ApiCore-Backend/src/webhooks/webhook-delivery.ts index 5e6d7a7..849bfc3 100644 --- a/ApiCore-Backend/src/webhooks/webhook-delivery.ts +++ b/ApiCore-Backend/src/webhooks/webhook-delivery.ts @@ -5,21 +5,30 @@ import * as net from 'node:net'; /** A receiver gets this long to answer; a hanging one must not pile up. */ export const WEBHOOK_TIMEOUT_MS = 5_000; +/** + * Upper bound for a whole exchange, answer included. WEBHOOK_TIMEOUT_MS is + * an idle timeout: a receiver that trickles its answer byte by byte would + * otherwise keep the connection - and a worker slot - open indefinitely. + */ +export const WEBHOOK_DEADLINE_MS = 10_000; export interface WebhookResponse { status: number; } /** - * POSTs a webhook without following redirects, with a hard timeout, and - * through guardedLookup, so the connection can only ever go to a public - * address - whatever the host name resolves to by then. The response body - * is discarded unread. + * POSTs a webhook without following redirects, with an idle timeout and a + * deadline, and through guardedLookup, so the connection can only ever go + * to a public address - whatever the host name resolves to by then. The + * status counts as soon as it arrives; the response body is discarded + * unread. At the deadline the request is destroyed, and with it the socket + * and an answer still in transfer - before the status, the promise rejects. */ export function postWebhook( rawUrl: string, headers: Record, body: string, + deadlineMs = WEBHOOK_DEADLINE_MS, ): Promise { return new Promise((resolve, reject) => { const url = new URL(rawUrl); @@ -45,6 +54,15 @@ export function postWebhook( resolve({ status: response.statusCode ?? 0 }); }, ); + const deadline = setTimeout( + () => + request.destroy( + new Error('Webhook receiver did not finish answering in time'), + ), + deadlineMs, + ); + // 'close' ends every exchange - answered, failed or destroyed. + request.on('close', () => clearTimeout(deadline)); request.on('timeout', () => request.destroy(new Error('Webhook receiver did not answer in time')), ); diff --git a/ApiCore-Backend/src/webhooks/webhook-delivery.worker.spec.ts b/ApiCore-Backend/src/webhooks/webhook-delivery.worker.spec.ts new file mode 100644 index 0000000..4abc137 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook-delivery.worker.spec.ts @@ -0,0 +1,561 @@ +import * as crypto from 'node:crypto'; +import { Logger } from '@nestjs/common'; +import { Prisma } from '../../generated/prisma'; +import { MAX_ATTEMPTS } from '../common/retry-policy'; +import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher'; +import { PrismaService } from '../prisma/prisma.service'; +import { postWebhook, WebhookResponse } from './webhook-delivery'; +import { + WEBHOOK_DELIVERY_BATCH_SIZE, + WEBHOOK_DELIVERY_CONCURRENCY, + WebhookDeliveryWorker, +} from './webhook-delivery.worker'; +import { sealWebhookSecret } from './webhook-secret'; + +jest.mock('./webhook-delivery', () => ({ + ...jest.requireActual('./webhook-delivery'), + postWebhook: jest.fn(), +})); + +const postWebhookMock = postWebhook as jest.MockedFunction; + +const SECRET = 'topsecret-signing-key'; +const CLAIMED_AT = new Date('2026-10-13T08:00:01.234Z'); +const BODY = JSON.stringify({ + event: 'colony.created', + data: { id: 'c1', notes: 'private note' }, +}); + +/** A claimed WebhookDelivery row, as RETURNING * yields it. */ +interface ClaimedRow { + id: string; + subscriptionId: string; + event: string; + body: string; + status: 'PROCESSING'; + attempts: number; + claimedAt: Date; + createdAt: Date; +} + +interface SubscriptionRow { + url: string; + secret: string; + active: boolean; +} + +interface StatusCodeUpdate { + where: { id: string; status: string; claimedAt: Date }; + data: { lastStatusCode: number | null }; +} + +function row(overrides: Partial = {}): ClaimedRow { + return { + id: 'd1', + subscriptionId: 'wh-1', + event: 'colony.created', + body: BODY, + status: 'PROCESSING', + attempts: 0, + claimedAt: CLAIMED_AT, + createdAt: new Date('2026-10-13T08:00:00.000Z'), + ...overrides, + }; +} + +function text(query: Prisma.Sql): string { + return query.sql.replace(/\s+/g, ' ').trim(); +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** + * Stands in for Postgres: a queue of claim results, the subscriptions, and + * every statement the worker sent. + */ +class FakeDb { + readonly claims: ClaimedRow[][] = []; + /** Rows the stale release returns, one list per call. */ + readonly releases: { id: string; status: 'PENDING' | 'FAILED' }[][] = []; + readonly subscriptions = new Map(); + + readonly tx = { + webhookDelivery: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + $executeRaw: jest.fn().mockResolvedValue(1), + }; + + readonly prisma = { + $queryRaw: jest.fn((query: Prisma.Sql) => + Promise.resolve( + (query.sql.includes('SKIP LOCKED') + ? this.claims.shift() + : this.releases.shift()) ?? [], + ), + ), + $transaction: jest.fn((fn: (tx: unknown) => Promise) => + fn(this.tx), + ), + webhookSubscription: { + findUnique: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve(this.subscriptions.get(where.id) ?? null), + ), + }, + }; + + claimQuery(call = 0): Prisma.Sql { + return (this.prisma.$queryRaw.mock.calls as unknown as [Prisma.Sql][])[ + call + ][0]; + } + + /** Settle statements (outbox-claims), in the order they were sent. */ + settles(): Prisma.Sql[] { + return (this.tx.$executeRaw.mock.calls as [Prisma.Sql][]).map( + ([query]) => query, + ); + } + + statusCodeUpdates(): StatusCodeUpdate[] { + return ( + this.tx.webhookDelivery.updateMany.mock.calls as [StatusCodeUpdate][] + ).map(([args]) => args); + } +} + +describe('WebhookDeliveryWorker', () => { + let db: FakeDb; + let worker: WebhookDeliveryWorker; + let logWarn: jest.SpyInstance; + let logError: jest.SpyInstance; + + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret-0123456789'; + db = new FakeDb(); + db.subscriptions.set('wh-1', { + url: 'https://receiver.example/hook', + secret: sealWebhookSecret(SECRET), + active: true, + }); + postWebhookMock.mockReset(); + postWebhookMock.mockResolvedValue({ status: 200 }); + worker = new WebhookDeliveryWorker(db.prisma as unknown as PrismaService); + logWarn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + logError = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function logLines(): string { + const calls = [ + ...(logWarn.mock.calls as unknown[][]), + ...(logError.mock.calls as unknown[][]), + ]; + return calls + .flat() + .map((part) => String(part)) + .join('\n'); + } + + it('pins batch size and concurrency', () => { + expect(WEBHOOK_DELIVERY_BATCH_SIZE).toBe(50); + expect(WEBHOOK_DELIVERY_CONCURRENCY).toBe(5); + }); + + it('claims at most 50 due deliveries, oldest first, skipping locked rows, by the database clock', async () => { + await worker.runOnce(); + + const claim = db.claimQuery(); + expect(text(claim)).toContain( + `UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`, + ); + expect(text(claim)).toContain( + `WHERE "status" = 'PENDING' AND "availableAt" <= now()`, + ); + expect(text(claim)).toContain( + 'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED', + ); + expect(claim.values).toEqual([50]); + }); + + it('restricts the claim to the given subscriptions', async () => { + await worker.runOnce({ subscriptionIds: ['wh-1', 'wh-2'] }); + + const claim = db.claimQuery(); + expect(text(claim)).toContain( + `"availableAt" <= now() AND "subscriptionId" = ANY(?) ORDER BY`, + ); + expect(claim.values).toEqual([['wh-1', 'wh-2'], 50]); + }); + + it('signs ".." and sends the delivery id as X-ApiCore-Delivery', async () => { + db.claims.push([row()]); + + await expect(worker.runOnce()).resolves.toBe(1); + + expect(postWebhookMock).toHaveBeenCalledTimes(1); + const [url, headers, body] = postWebhookMock.mock.calls[0]; + expect(url).toBe('https://receiver.example/hook'); + // The body stored at enqueue time, byte for byte. + expect(body).toBe(BODY); + expect(headers).toEqual({ + 'Content-Type': 'application/json', + 'User-Agent': 'ApiCore-Webhooks/1', + 'X-ApiCore-Event': 'colony.created', + 'X-ApiCore-Delivery': 'd1', + 'X-ApiCore-Timestamp': expect.stringMatching(/^\d+$/) as unknown, + 'X-ApiCore-Signature': expect.stringMatching( + /^sha256=[0-9a-f]{64}$/, + ) as unknown, + }); + const expected = crypto + .createHmac('sha256', SECRET) + .update(`${headers['X-ApiCore-Timestamp']}.d1.${BODY}`) + .digest('hex'); + expect(headers['X-ApiCore-Signature']).toBe(`sha256=${expected}`); + }); + + it.each([200, 201, 204, 299])( + 'counts %i as delivered and stores the status code', + async (status) => { + postWebhookMock.mockResolvedValueOnce({ status }); + db.claims.push([row()]); + + await worker.runOnce(); + + expect(db.statusCodeUpdates()).toEqual([ + { + where: { id: 'd1', status: 'PROCESSING', claimedAt: CLAIMED_AT }, + data: { lastStatusCode: status }, + }, + ]); + const [settle] = db.settles(); + expect(text(settle)).toContain('"deliveredAt" = now()'); + expect(text(settle)).toContain( + `WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`, + ); + expect(settle.values).toEqual(['DELIVERED', 'd1', CLAIMED_AT]); + }, + ); + + it.each([301, 302, 400, 404, 429, 500, 503])( + 'counts only 2xx as delivered: %i is a failed attempt and its status code is stored', + async (status) => { + postWebhookMock.mockResolvedValueOnce({ status }); + db.claims.push([row()]); + + await worker.runOnce(); + + expect(db.statusCodeUpdates()[0].data).toEqual({ + lastStatusCode: status, + }); + const [settle] = db.settles(); + expect(text(settle)).not.toContain('"deliveredAt"'); + expect(settle.values).toContain('PENDING'); + expect(settle.values).toContain(`Receiver answered HTTP ${status}`); + }, + ); + + it('schedules a retry 30 s ahead after a 500 and keeps the same id', async () => { + postWebhookMock + .mockResolvedValueOnce({ status: 500 }) + .mockResolvedValueOnce({ status: 200 }); + db.claims.push([row({ attempts: 0 })]); + + await worker.runOnce(); + + const [retry] = db.settles(); + expect(text(retry)).toContain(`"attempts" = "attempts" + 1`); + expect(text(retry)).toContain( + `"availableAt" = now() + (? * interval '1 millisecond')`, + ); + expect(text(retry)).toContain( + `WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`, + ); + expect(retry.values).toEqual([ + 'PENDING', + 'Receiver answered HTTP 500', + 30_000, + 'd1', + CLAIMED_AT, + ]); + + // The retry is the same row, claimed again: same id, same body. + const reclaimedAt = new Date('2026-10-13T08:00:31.500Z'); + db.claims.push([row({ attempts: 1, claimedAt: reclaimedAt })]); + await worker.runOnce(); + + const [first, second] = postWebhookMock.mock.calls; + expect(second[1]['X-ApiCore-Delivery']).toBe( + first[1]['X-ApiCore-Delivery'], + ); + expect(second[2]).toBe(first[2]); + expect(db.settles()[1].values).toEqual(['DELIVERED', 'd1', reclaimedAt]); + }); + + it('marks FAILED after the 8th attempt', async () => { + postWebhookMock.mockResolvedValueOnce({ status: 500 }); + db.claims.push([row({ attempts: MAX_ATTEMPTS - 1 })]); + + await worker.runOnce(); + + const [settle] = db.settles(); + expect(settle.values).toEqual([ + 'FAILED', + 'Receiver answered HTTP 500', + 'd1', + CLAIMED_AT, + ]); + expect(text(settle)).not.toContain('"availableAt"'); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining('Webhook delivery d1 (colony.created) FAILED'), + ); + }); + + it('fails a delivery for an inactive subscription without sending ("subscription inactive")', async () => { + db.subscriptions.set('wh-1', { + ...db.subscriptions.get('wh-1')!, + active: false, + }); + db.claims.push([row()]); + + await worker.runOnce(); + + expect(postWebhookMock).not.toHaveBeenCalled(); + expect(db.statusCodeUpdates()[0].data).toEqual({ lastStatusCode: null }); + const [settle] = db.settles(); + expect(settle.values).toEqual([ + 'PENDING', + 'subscription inactive', + 30_000, + 'd1', + CLAIMED_AT, + ]); + }); + + it('stores the loggable error of a failed connection without a status code', async () => { + postWebhookMock.mockRejectedValueOnce( + new Error('Webhook host receiver.example resolves to a blocked address'), + ); + db.claims.push([row()]); + + await worker.runOnce(); + + expect(db.statusCodeUpdates()[0].data).toEqual({ lastStatusCode: null }); + expect(db.settles()[0].values).toContain( + 'Webhook host receiver.example resolves to a blocked address', + ); + }); + + it('never stores the response body or the secret in lastError', async () => { + postWebhookMock + .mockResolvedValueOnce({ status: 500 }) + .mockRejectedValueOnce(new Error('socket hang up')); + db.claims.push([row({ id: 'd1' }), row({ id: 'd2' })]); + + await worker.runOnce(); + + const lastErrorOf = (id: string): string => + String( + db.settles().find((settle) => settle.values.includes(id))?.values[1], + ); + // A fixed text for an answer: postWebhook discards the body unread. + expect(lastErrorOf('d1')).toBe('Receiver answered HTTP 500'); + expect(lastErrorOf('d2')).toBe('socket hang up'); + const signatures = postWebhookMock.mock.calls.map(([, headers]) => + headers['X-ApiCore-Signature'].slice('sha256='.length), + ); + for (const stored of [lastErrorOf('d1'), lastErrorOf('d2'), logLines()]) { + expect(stored).not.toContain(SECRET); + expect(stored).not.toContain('private note'); + signatures.forEach((signature) => + expect(stored).not.toContain(signature), + ); + } + }); + + it('logs a Prisma error by name and code only', async () => { + db.prisma.webhookSubscription.findUnique.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError( + 'Invalid query: url = "https://receiver.example/hook?token=abc"', + { code: 'P2024', clientVersion: '7.9.1' }, + ), + ); + db.claims.push([row()]); + + await worker.runOnce(); + + expect(logLines()).toContain('P2024'); + expect(logLines()).not.toContain('token=abc'); + }); + + it('sends at most 5 at once', async () => { + const answers = Array.from({ length: 12 }, () => + deferred(), + ); + let inFlight = 0; + let maxInFlight = 0; + answers.forEach((answer) => + postWebhookMock.mockImplementationOnce(() => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + return answer.promise.finally(() => { + inFlight -= 1; + }); + }), + ); + db.claims.push( + Array.from({ length: 12 }, (_, index) => row({ id: `d${index}` })), + ); + + const run = worker.runOnce(); + await flush(); + expect(postWebhookMock).toHaveBeenCalledTimes(5); + + answers[0].resolve({ status: 200 }); + await flush(); + expect(postWebhookMock).toHaveBeenCalledTimes(6); + + answers.forEach((answer) => answer.resolve({ status: 200 })); + await expect(run).resolves.toBe(12); + expect(postWebhookMock).toHaveBeenCalledTimes(12); + expect(maxInFlight).toBe(5); + }); + + // postWebhook ends the exchange - request and socket - at its deadline + // (webhook-delivery.spec.ts); the worker keeps the default and counts it. + it('counts a missed deadline as a failed attempt without a status code', async () => { + postWebhookMock.mockRejectedValueOnce( + new Error('Webhook receiver did not finish answering in time'), + ); + db.claims.push([row()]); + + await expect(worker.runOnce()).resolves.toBe(1); + + expect(postWebhookMock.mock.calls[0]).toEqual([ + 'https://receiver.example/hook', + expect.any(Object), + BODY, + ]); + expect(db.statusCodeUpdates()[0].data).toEqual({ lastStatusCode: null }); + expect(db.settles()[0].values).toContain( + 'Webhook receiver did not finish answering in time', + ); + }); + + it('settles the rest of the batch when one delivery cannot be processed', async () => { + db.prisma.webhookSubscription.findUnique.mockRejectedValueOnce( + new Error('connection reset'), + ); + db.claims.push([row({ id: 'd1' }), row({ id: 'd2' })]); + + await expect(worker.runOnce()).resolves.toBe(2); + + expect(db.settles().map((settle) => settle.values)).toEqual([ + ['DELIVERED', 'd2', CLAIMED_AT], + ]); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining( + 'Webhook delivery d1 (colony.created) not settled', + ), + ); + }); + + it('changes nothing when the claim was lost before settling', async () => { + db.tx.webhookDelivery.updateMany.mockResolvedValueOnce({ count: 0 }); + db.claims.push([row()]); + + await worker.runOnce(); + + expect(db.settles()).toEqual([]); + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining('claim lost before settling'), + ); + }); + + describe('drain', () => { + it('runs batches until one claims nothing', async () => { + db.claims.push([row({ id: 'd1' })], [row({ id: 'd2' })]); + + await expect(worker.drain()).resolves.toBe(2); + + expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(3); + }); + + it(`stops after ${DRAIN_MAX_ROUNDS} rounds by default and after maxRounds when given`, async () => { + for (let i = 0; i < DRAIN_MAX_ROUNDS + 5; i++) { + db.claims.push([row({ id: `d${i}` })]); + } + + await expect(worker.drain(undefined, 2)).resolves.toBe(2); + await expect(worker.drain()).resolves.toBe(DRAIN_MAX_ROUNDS); + }); + + it('passes the subscription filter to every claim', async () => { + db.claims.push([row()]); + + await worker.drain({ subscriptionIds: ['wh-1'] }); + + expect(db.claimQuery(0).values).toEqual([['wh-1'], 50]); + expect(db.claimQuery(1).values).toEqual([['wh-1'], 50]); + }); + + it('claims no further batch once the signal aborts, but finishes the claimed one', async () => { + const stop = new AbortController(); + postWebhookMock.mockImplementationOnce(() => { + stop.abort(); + return Promise.resolve({ status: 200 }); + }); + db.claims.push([row({ id: 'd1' })], [row({ id: 'd2' })]); + + await expect(worker.drain(undefined, 20, stop.signal)).resolves.toBe(1); + + expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(1); + expect(db.settles()[0].values).toEqual(['DELIVERED', 'd1', CLAIMED_AT]); + }); + + it('claims nothing when the signal is already aborted', async () => { + const stop = new AbortController(); + stop.abort(); + + await expect(worker.drain(undefined, 20, stop.signal)).resolves.toBe(0); + + expect(db.prisma.$queryRaw).not.toHaveBeenCalled(); + }); + }); + + it('releases stale claims of the delivery table and logs the ones that FAILED', async () => { + db.releases.push([ + { id: 'd1', status: 'PENDING' }, + { id: 'd2', status: 'FAILED' }, + ]); + + await expect(worker.releaseStaleClaims()).resolves.toBe(2); + + const release = db.claimQuery(); + expect(text(release)).toContain('UPDATE "WebhookDelivery"'); + expect(text(release)).toContain( + `"claimedAt" < now() - (? * interval '1 millisecond')`, + ); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('d2')); + expect(logLines()).not.toContain('d1'); + }); +}); diff --git a/ApiCore-Backend/src/webhooks/webhook-delivery.worker.ts b/ApiCore-Backend/src/webhooks/webhook-delivery.worker.ts new file mode 100644 index 0000000..f7159f2 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook-delivery.worker.ts @@ -0,0 +1,285 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as crypto from 'node:crypto'; +import { Prisma } from '../../generated/prisma'; +import { loggableError } from '../common/loggable-error'; +import { + claimBatch, + releaseStaleClaimRows, + settleFailure, + settleSuccess, +} from '../common/outbox/outbox-claims'; +import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher'; +import { PrismaService } from '../prisma/prisma.service'; +import { postWebhook } from './webhook-delivery'; +import { openWebhookSecret } from './webhook-secret'; + +export const WEBHOOK_DELIVERY_BATCH_SIZE = 50; +export const WEBHOOK_DELIVERY_CONCURRENCY = 5; + +const SUBSCRIPTION_INACTIVE = 'subscription inactive'; + +export interface WebhookDeliveryFilter { + /** Only deliveries of these subscriptions (e2e tests: no foreign rows). */ + subscriptionIds?: readonly string[]; +} + +/** A claimed WebhookDelivery row, as RETURNING * yields it. */ +interface ClaimedWebhookDelivery { + id: string; + subscriptionId: string; + event: string; + body: string; + attempts: number; + claimedAt: Date; +} + +interface DeliveryTarget { + url: string; + secret: string; +} + +/** What one attempt came to; `error` is set exactly when not delivered. */ +type AttemptOutcome = + | { delivered: true; statusCode: number } + | { delivered: false; statusCode: number | null; error: string }; + +/** + * Sends the queued webhook deliveries (WebhooksService.enqueueDeliveries): + * claims up to 50 due rows, posts at most 5 at once and settles each one - + * DELIVERED on a 2xx answer, otherwise a failed attempt under the shared + * retry rule (retry-policy.ts), FAILED after the 8th. The delivery id is + * the row id, so a receiver sees the same X-ApiCore-Delivery and the same + * body on every retry and can drop duplicates. Run by the worker's + * webhook-deliveries loop. + */ +@Injectable() +export class WebhookDeliveryWorker { + private readonly logger = new Logger(WebhookDeliveryWorker.name); + + constructor(private readonly prisma: PrismaService) {} + + /** One batch: claims up to 50 deliveries and returns how many it claimed. */ + async runOnce(filter?: WebhookDeliveryFilter): Promise { + const deliveries = await claimBatch( + this.prisma, + 'WebhookDelivery', + { + limit: WEBHOOK_DELIVERY_BATCH_SIZE, + filter: claimFilter(filter), + orderBy: 'createdAt', + }, + ); + await forEachConcurrently( + deliveries, + WEBHOOK_DELIVERY_CONCURRENCY, + (delivery) => this.deliverSafely(delivery), + ); + return deliveries.length; + } + + /** + * Runs batches until one claims nothing, at most `maxRounds`. Returns the + * claimed total. Once `signal` aborts (worker shutdown), no further batch + * is claimed; the batch already claimed is finished, since its deliveries + * would otherwise wait for the stale-claim release. + */ + async drain( + filter?: WebhookDeliveryFilter, + maxRounds = DRAIN_MAX_ROUNDS, + signal?: AbortSignal, + ): Promise { + let total = 0; + for (let round = 0; round < maxRounds && !signal?.aborted; round++) { + const claimed = await this.runOnce(filter); + if (claimed === 0) { + break; + } + total += claimed; + } + return total; + } + + /** Puts deliveries whose worker died back in line (or to FAILED). */ + async releaseStaleClaims(): Promise { + const released = await releaseStaleClaimRows( + this.prisma, + 'WebhookDelivery', + ); + const failed = released + .filter((claim) => claim.status === 'FAILED') + .map((claim) => claim.id); + if (failed.length > 0) { + this.logger.error( + `Webhook deliveries FAILED, an expired claim used up their attempts: ${failed.join(', ')}`, + ); + } + return released.length; + } + + /** One delivery must not stop the batch; an unsettled one goes stale. */ + private async deliverSafely(delivery: ClaimedWebhookDelivery): Promise { + try { + await this.deliver(delivery); + } catch (error) { + this.logger.error( + `Webhook delivery ${delivery.id} (${delivery.event}) not settled: ${loggableError(error).message}`, + ); + } + } + + private async deliver(delivery: ClaimedWebhookDelivery): Promise { + const subscription = await this.prisma.webhookSubscription.findUnique({ + where: { id: delivery.subscriptionId }, + select: { url: true, secret: true, active: true }, + }); + // A paused subscription sends nothing; the attempt counts, so its + // deliveries go out if it is resumed within the retry window. + const outcome: AttemptOutcome = subscription?.active + ? await this.attempt(delivery, subscription) + : { delivered: false, statusCode: null, error: SUBSCRIPTION_INACTIVE }; + await this.settle(delivery, outcome); + } + + /** + * Signs and posts the stored body. Signed together with a timestamp and + * the delivery id, so a receiver can reject a captured request replayed + * later ("t.id.body", the same idea as Stripe's signatures). Only a 2xx + * answer counts as delivered; the answer's body is never read. + */ + private async attempt( + delivery: ClaimedWebhookDelivery, + target: DeliveryTarget, + ): Promise { + try { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = crypto + .createHmac('sha256', openWebhookSecret(target.secret)) + .update(`${timestamp}.${delivery.id}.${delivery.body}`) + .digest('hex'); + // No redirects, connect-time address check, 5 s idle timeout and a + // 10 s deadline that also closes the connection - a trickling + // receiver can hold neither a slot nor a socket. See postWebhook(). + const { status } = await postWebhook( + target.url, + { + 'Content-Type': 'application/json', + 'User-Agent': 'ApiCore-Webhooks/1', + 'X-ApiCore-Event': delivery.event, + 'X-ApiCore-Delivery': delivery.id, + 'X-ApiCore-Timestamp': timestamp, + 'X-ApiCore-Signature': `sha256=${signature}`, + }, + delivery.body, + ); + return status >= 200 && status < 300 + ? { delivered: true, statusCode: status } + : { + delivered: false, + statusCode: status, + error: `Receiver answered HTTP ${status}`, + }; + } catch (error) { + return { + delivered: false, + statusCode: null, + error: loggableError(error).message, + }; + } + } + + /** + * Stores the status code and settles the claim in one transaction. Both + * are a compare-and-set on id + claimedAt: a worker whose claim was + * released in the meantime changes nothing. + */ + private async settle( + delivery: ClaimedWebhookDelivery, + outcome: AttemptOutcome, + ): Promise { + const result = await this.prisma.$transaction(async (tx) => { + const { count } = await tx.webhookDelivery.updateMany({ + where: { + id: delivery.id, + status: 'PROCESSING', + claimedAt: delivery.claimedAt, + }, + data: { lastStatusCode: outcome.statusCode }, + }); + if (count === 0) { + return 'lost' as const; + } + if (outcome.delivered) { + await settleSuccess( + tx, + 'WebhookDelivery', + delivery.id, + delivery.claimedAt, + 'DELIVERED', + ); + return 'delivered' as const; + } + return settleFailure( + tx, + 'WebhookDelivery', + delivery.id, + delivery.claimedAt, + delivery.attempts, + outcome.error, + ); + }); + this.logOutcome(delivery, outcome, result); + } + + private logOutcome( + delivery: ClaimedWebhookDelivery, + outcome: AttemptOutcome, + result: 'lost' | 'delivered' | 'retry' | 'failed', + ): void { + const subject = `Webhook delivery ${delivery.id} (${delivery.event}) to subscription ${delivery.subscriptionId}`; + const error = outcome.delivered ? '' : outcome.error; + const attempt = delivery.attempts + 1; + if (result === 'lost') { + this.logger.warn( + `${subject}: claim lost before settling, left to its current owner`, + ); + } else if (result === 'failed') { + this.logger.error( + `Webhook delivery ${delivery.id} (${delivery.event}) FAILED after ${attempt} attempts to subscription ${delivery.subscriptionId}: ${error}`, + ); + } else if (result === 'retry') { + this.logger.warn( + `${subject}: attempt ${attempt} failed, retrying: ${error}`, + ); + } + } +} + +function claimFilter(filter?: WebhookDeliveryFilter): Prisma.Sql | undefined { + if (filter?.subscriptionIds === undefined) { + return undefined; + } + return Prisma.sql`"subscriptionId" = ANY(${[...filter.subscriptionIds]})`; +} + +/** + * Runs `work` for every item, at most `limit` at a time, and resolves once + * all are done. `work` must not reject - every delivery settles its own + * errors (deliverSafely). + */ +async function forEachConcurrently( + items: readonly T[], + limit: number, + work: (item: T) => Promise, +): Promise { + let next = 0; + const lane = async (): Promise => { + while (next < items.length) { + const item = items[next]; + next += 1; + await work(item); + } + }; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, lane), + ); +} diff --git a/ApiCore-Backend/src/webhooks/webhook-events.ts b/ApiCore-Backend/src/webhooks/webhook-events.ts index f65b368..bbc3597 100644 --- a/ApiCore-Backend/src/webhooks/webhook-events.ts +++ b/ApiCore-Backend/src/webhooks/webhook-events.ts @@ -1,6 +1,9 @@ export const WEBHOOK_EVENTS = [ 'colony.created', 'inspection.created', + 'varroa.measured', + 'treatment.created', + 'feeding.created', 'queen.created', 'queen.replaced', 'harvest.created', diff --git a/ApiCore-Backend/src/webhooks/webhook-secret.spec.ts b/ApiCore-Backend/src/webhooks/webhook-secret.spec.ts index b240e39..2bdb3f9 100644 --- a/ApiCore-Backend/src/webhooks/webhook-secret.spec.ts +++ b/ApiCore-Backend/src/webhooks/webhook-secret.spec.ts @@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common'; import { encryptMfaSecret } from '../auth/mfa/mfa-crypto.util'; import { KEY_ID_PATTERN } from '../common/crypto/key-ring'; import { + isPlaintextWebhookSecret, openWebhookSecret, sealWebhookSecret, webhookSecretNeedsRekey, @@ -46,6 +47,14 @@ describe('webhook-secret', () => { expect(webhookSecretNeedsRekey(LEGACY_PLAIN)).toBe(true); }); + it('tells plaintext secrets from sealed ones, so rekey can report them apart', () => { + expect(isPlaintextWebhookSecret(LEGACY_PLAIN)).toBe(true); + expect(isPlaintextWebhookSecret(LEGACY_SEALED)).toBe(false); + expect(isPlaintextWebhookSecret(sealWebhookSecret('topsecret'))).toBe( + false, + ); + }); + it('opens a secret sealed with WEBHOOK_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => { const stored = sealWebhookSecret('topsecret'); process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = diff --git a/ApiCore-Backend/src/webhooks/webhook-secret.ts b/ApiCore-Backend/src/webhooks/webhook-secret.ts index f12381e..53f380a 100644 --- a/ApiCore-Backend/src/webhooks/webhook-secret.ts +++ b/ApiCore-Backend/src/webhooks/webhook-secret.ts @@ -34,3 +34,8 @@ export function openWebhookSecret(stored: string): string { export function webhookSecretNeedsRekey(stored: string): boolean { return !isSealedWithCurrentKey(loadKeyRing('webhook'), stored); } + +/** True for a secret from before encryption, stored as it is (neither `k…:` nor `enc:v1:`). */ +export function isPlaintextWebhookSecret(stored: string): boolean { + return !KEY_ID_PATTERN.test(stored) && !stored.startsWith(LEGACY_PREFIX); +} diff --git a/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts b/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts new file mode 100644 index 0000000..b73ab68 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts @@ -0,0 +1,273 @@ +import { Prisma } from '../../generated/prisma'; +import type { + AnyDomainEventEnvelope, + DomainEventEnvelope, + DomainEventType, +} from '../domain-events/domain-event-types'; +import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; +import { + DOMAIN_EVENT_WEBHOOKS, + WebhookEventHandlers, +} from './webhook.handlers'; +import type { WebhooksService } from './webhooks.service'; + +type RecordModel = + | 'inspection' + | 'varroaMeasurement' + | 'treatment' + | 'feeding' + | 'harvest' + | 'honeyBatch' + | 'task'; + +const RECORD_MODELS: readonly RecordModel[] = [ + 'inspection', + 'varroaMeasurement', + 'treatment', + 'feeding', + 'harvest', + 'honeyBatch', + 'task', +]; + +function envelope( + type: T, + aggregateId: string, + payload: DomainEventEnvelope['payload'], +): AnyDomainEventEnvelope { + return { + id: 'e1', + type, + aggregateType: 'Inspection', + aggregateId, + payload, + actorUserId: 'u1', + correlationId: 'corr-1', + occurredAt: new Date('2026-10-13T08:00:00.000Z'), + attempts: 0, + } as AnyDomainEventEnvelope; +} + +describe('WebhookEventHandlers', () => { + let tx: Record; + let webhooks: { enqueueDeliveries: jest.Mock }; + let handlers: WebhookEventHandlers; + + const forward = (event: AnyDomainEventEnvelope) => + handlers.forward(event, tx as unknown as Prisma.TransactionClient); + + beforeEach(() => { + tx = Object.fromEntries( + RECORD_MODELS.map((model) => [ + model, + { findUnique: jest.fn().mockResolvedValue(null) }, + ]), + ) as Record; + webhooks = { enqueueDeliveries: jest.fn().mockResolvedValue(1) }; + handlers = new WebhookEventHandlers(webhooks as unknown as WebhooksService); + }); + + it('maps exactly these domain events to webhook events', () => { + expect(DOMAIN_EVENT_WEBHOOKS).toEqual({ + InspectionRecorded: 'inspection.created', + VarroaMeasured: 'varroa.measured', + TreatmentRecorded: 'treatment.created', + FeedingRecorded: 'feeding.created', + HarvestRecorded: 'harvest.created', + BatchBottled: 'batch.updated', + BottlingCorrected: 'batch.updated', + StockChanged: 'stock.changed', + TaskCreated: 'task.created', + TaskCompleted: 'task.completed', + }); + }); + + it('registers forward as "webhooks.forward" for every mapped type', () => { + // The decorator stores its metadata on the method itself. + const method = Reflect.get(WebhookEventHandlers.prototype, 'forward'); + expect(domainEventHandlerMetadata(method as object)).toEqual([ + { + types: Object.keys(DOMAIN_EVENT_WEBHOOKS), + handlerId: 'webhooks.forward', + }, + ]); + }); + + it('enqueues inspection.created with the inspection as data', async () => { + const inspection = { id: 'i1', colonyId: 'c1', notes: 'calm' }; + tx.inspection.findUnique.mockResolvedValueOnce(inspection); + + await forward(envelope('InspectionRecorded', 'i1', { colonyId: 'c1' })); + + expect(tx.inspection.findUnique).toHaveBeenCalledWith({ + where: { id: 'i1' }, + }); + // Through the delivery transaction: the rows commit with the delivery. + expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( + tx, + 'inspection.created', + inspection, + ); + }); + + it.each([ + ['VarroaMeasured', 'varroaMeasurement', 'varroa.measured'], + ['TreatmentRecorded', 'treatment', 'treatment.created'], + ['FeedingRecorded', 'feeding', 'feeding.created'], + ] as const)( + 'enqueues %s as %s record under %s', + async (type, model, webhookEvent) => { + const record = { id: 'r1', colonyId: 'c1' }; + tx[model].findUnique.mockResolvedValueOnce(record); + + await forward(envelope(type, 'r1', { colonyId: 'c1' })); + + expect(tx[model].findUnique).toHaveBeenCalledWith({ + where: { id: 'r1' }, + }); + expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( + tx, + webhookEvent, + record, + ); + }, + ); + + it.each([ + ['TaskCreated', 'task.created'], + ['TaskCompleted', 'task.completed'], + ] as const)('enqueues %s as %s with the task', async (type, webhookEvent) => { + const task = { id: 't1', title: 'Varroa zählen' }; + tx.task.findUnique.mockResolvedValueOnce(task); + + await forward( + envelope(type, 't1', { + relatedType: 'COLONY', + relatedId: 'c1', + dueDate: null, + }), + ); + + expect(tx.task.findUnique).toHaveBeenCalledWith({ where: { id: 't1' } }); + expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( + tx, + webhookEvent, + task, + ); + }); + + it('enqueues harvest.created with the harvest and its colonies', async () => { + const harvest = { + id: 'h1', + colonies: [{ colonyId: 'c1', colony: { id: 'c1', code: 'V-1' } }], + }; + tx.harvest.findUnique.mockResolvedValueOnce(harvest); + + await forward( + envelope('HarvestRecorded', 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }), + ); + + expect(tx.harvest.findUnique).toHaveBeenCalledWith({ + where: { id: 'h1' }, + include: { colonies: { include: { colony: true } } }, + }); + expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( + tx, + 'harvest.created', + harvest, + ); + }); + + it.each([ + ['BatchBottled', { honeyBatchId: 'hb1' }], + ['BottlingCorrected', { honeyBatchId: 'hb1', removed: true }], + ] as const)( + 'enqueues batch.updated with the honey batch for %s', + async (type, payload) => { + const honeyBatch = { id: 'hb1', lotNumber: 'L-2026-001' }; + tx.honeyBatch.findUnique.mockResolvedValueOnce(honeyBatch); + + // The aggregate is the bottling batch; the data is its honey batch, + // which still exists after a bottling was removed. + await forward( + envelope( + type, + 'bb1', + payload as DomainEventEnvelope<'BottlingCorrected'>['payload'], + ), + ); + + expect(tx.honeyBatch.findUnique).toHaveBeenCalledWith({ + where: { id: 'hb1' }, + }); + expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( + tx, + 'batch.updated', + honeyBatch, + ); + }, + ); + + it('enqueues stock.changed with the bottling batch and product ids, without raw material kinds', async () => { + await forward( + envelope('StockChanged', 'm1', { + bottlingBatchIds: ['b1', 'b1', 'b2'], + productIds: ['p1'], + rawMaterialKinds: ['BEESWAX'], + }), + ); + + expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( + tx, + 'stock.changed', + { bottlingBatchIds: ['b1', 'b2'], productIds: ['p1'] }, + ); + RECORD_MODELS.forEach((model) => + expect(tx[model].findUnique).not.toHaveBeenCalled(), + ); + }); + + it('enqueues nothing for StockChanged that only touches raw material', async () => { + await forward( + envelope('StockChanged', 'm1', { + bottlingBatchIds: [], + productIds: [], + rawMaterialKinds: ['PROPOLIS'], + }), + ); + + expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); + }); + + it('skips a record that no longer exists', async () => { + tx.inspection.findUnique.mockResolvedValueOnce(null); + + await forward(envelope('InspectionRecorded', 'gone', { colonyId: 'c1' })); + + expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); + }); + + it('enqueues nothing for a type without a webhook', async () => { + await forward(envelope('TaskDue', 't1', { dueOn: '2026-10-13' })); + + expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); + RECORD_MODELS.forEach((model) => + expect(tx[model].findUnique).not.toHaveBeenCalled(), + ); + }); + + // The dispatcher rolls the delivery transaction back and retries. + it('fails when the deliveries cannot be enqueued', async () => { + tx.task.findUnique.mockResolvedValueOnce({ id: 't1' }); + webhooks.enqueueDeliveries.mockRejectedValueOnce(new Error('db down')); + + await expect( + forward( + envelope('TaskCompleted', 't1', { + relatedType: 'GENERAL', + relatedId: null, + }), + ), + ).rejects.toThrow('db down'); + }); +}); diff --git a/ApiCore-Backend/src/webhooks/webhook.handlers.ts b/ApiCore-Backend/src/webhooks/webhook.handlers.ts new file mode 100644 index 0000000..d074025 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook.handlers.ts @@ -0,0 +1,117 @@ +import { Injectable } from '@nestjs/common'; +import type { + AnyDomainEventEnvelope, + DomainEventPayloads, + DomainEventType, +} from '../domain-events/domain-event-types'; +import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; +import { Prisma } from '../../generated/prisma'; +import { WebhookEvent } from './webhook-events'; +import { WebhooksService } from './webhooks.service'; + +/** Which domain event goes out as which webhook event. */ +export const DOMAIN_EVENT_WEBHOOKS: Readonly< + Partial> +> = Object.freeze({ + InspectionRecorded: 'inspection.created', + VarroaMeasured: 'varroa.measured', + TreatmentRecorded: 'treatment.created', + FeedingRecorded: 'feeding.created', + HarvestRecorded: 'harvest.created', + BatchBottled: 'batch.updated', + BottlingCorrected: 'batch.updated', + StockChanged: 'stock.changed', + TaskCreated: 'task.created', + TaskCompleted: 'task.completed', +}); + +/** + * Turns domain events into queued webhook deliveries. Pure DB writer: it + * reads the record through the delivery transaction and queues the rows in + * it (WebhooksService.enqueueDeliveries); the worker's webhook-deliveries + * loop sends them. Errors propagate, so a failed delivery is retried. + */ +@Injectable() +export class WebhookEventHandlers { + constructor(private readonly webhooks: WebhooksService) {} + + @OnDomainEvent( + Object.keys(DOMAIN_EVENT_WEBHOOKS) as DomainEventType[], + 'webhooks.forward', + ) + async forward( + event: AnyDomainEventEnvelope, + tx: Prisma.TransactionClient, + ): Promise { + const webhookEvent = DOMAIN_EVENT_WEBHOOKS[event.type]; + if (webhookEvent === undefined) { + return; + } + const data = await webhookData(event, tx); + // The record is gone (deleted before the event was dispatched), or the + // change is of no interest to receivers: nothing to send. + if (data === null) { + return; + } + await this.webhooks.enqueueDeliveries(tx, webhookEvent, data); + } +} + +/** + * The webhook's `data`, as the legacy path sent it: the record the event is + * about. Null when there is nothing to send. + */ +async function webhookData( + event: AnyDomainEventEnvelope, + tx: Prisma.TransactionClient, +): Promise { + const byAggregate = { where: { id: event.aggregateId } }; + switch (event.type) { + case 'InspectionRecorded': + return tx.inspection.findUnique(byAggregate); + case 'VarroaMeasured': + return tx.varroaMeasurement.findUnique(byAggregate); + case 'TreatmentRecorded': + return tx.treatment.findUnique(byAggregate); + case 'FeedingRecorded': + return tx.feeding.findUnique(byAggregate); + case 'HarvestRecorded': + return tx.harvest.findUnique({ + ...byAggregate, + include: { colonies: { include: { colony: true } } }, + }); + // The aggregate is the bottling batch; receivers get its honey batch, + // which outlives a removed bottling. + case 'BatchBottled': + case 'BottlingCorrected': + return tx.honeyBatch.findUnique({ + where: { id: event.payload.honeyBatchId }, + }); + case 'StockChanged': + return stockChange(event.payload); + case 'TaskCreated': + case 'TaskCompleted': + return tx.task.findUnique(byAggregate); + default: + return null; + } +} + +/** + * Receivers (the shop) fetch the numbers themselves. Raw material is not + * sold there, so a change that only touches raw material is not sent. + */ +function stockChange( + payload: DomainEventPayloads['StockChanged'], +): { bottlingBatchIds: string[]; productIds: string[] } | null { + if ( + payload.bottlingBatchIds.length === 0 && + payload.productIds.length === 0 + ) { + return null; + } + return { + bottlingBatchIds: [...new Set(payload.bottlingBatchIds)], + productIds: [...new Set(payload.productIds)], + }; +} diff --git a/ApiCore-Backend/src/webhooks/webhooks.controller.ts b/ApiCore-Backend/src/webhooks/webhooks.controller.ts index c839a42..3b83889 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.controller.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.controller.ts @@ -7,12 +7,17 @@ import { Param, Patch, Post, + Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateWebhookDto } from './dto/create-webhook.dto'; +import { + ListWebhookDeliveriesDto, + WEBHOOK_DELIVERIES_DEFAULT_LIMIT, +} from './dto/list-webhook-deliveries.dto'; import { UpdateWebhookDto } from './dto/update-webhook.dto'; import { WebhooksService } from './webhooks.service'; @@ -28,6 +33,19 @@ export class WebhooksController { return this.webhooksService.findAll(); } + /** The delivery log of one subscription, newest first, without bodies. */ + @RequirePermission('webhooks:read') + @Get(':id/deliveries') + findDeliveries( + @Param('id') id: string, + @Query() query: ListWebhookDeliveriesDto, + ) { + return this.webhooksService.findDeliveries( + id, + query.limit ?? WEBHOOK_DELIVERIES_DEFAULT_LIMIT, + ); + } + @RequirePermission('webhooks:create') @Post() create( diff --git a/ApiCore-Backend/src/webhooks/webhooks.module.ts b/ApiCore-Backend/src/webhooks/webhooks.module.ts index b137c71..1f2d938 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.module.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.module.ts @@ -1,11 +1,18 @@ import { Global, Module } from '@nestjs/common'; +import { WebhookDeliveryWorker } from './webhook-delivery.worker'; +import { WebhookEventHandlers } from './webhook.handlers'; import { WebhooksController } from './webhooks.controller'; import { WebhooksService } from './webhooks.service'; +/** + * Subscriptions, the delivery queue and the handler that fills it from + * domain events. WebhookDeliveryWorker sends the queue; only the worker + * runtime (and e2e tests) call it. + */ @Global() @Module({ controllers: [WebhooksController], - providers: [WebhooksService], - exports: [WebhooksService], + providers: [WebhooksService, WebhookDeliveryWorker, WebhookEventHandlers], + exports: [WebhooksService, WebhookDeliveryWorker], }) export class WebhooksModule {} diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts b/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts index 2fa9514..b646f07 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts @@ -1,15 +1,15 @@ -import * as crypto from 'node:crypto'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { WebhooksService } from './webhooks.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { postWebhook } from './webhook-delivery'; -import { openWebhookSecret, sealWebhookSecret } from './webhook-secret'; +import { DbClient } from '../prisma/db-client'; +import { openWebhookSecret } from './webhook-secret'; -jest.mock('./webhook-delivery', () => ({ postWebhook: jest.fn() })); - -const postWebhookMock = postWebhook as jest.MockedFunction; -const flushDeliveries = () => new Promise((resolve) => setImmediate(resolve)); +interface DeliveryRowInput { + subscriptionId: string; + event: string; + body: string; +} describe('WebhooksService', () => { let service: WebhooksService; @@ -21,13 +21,16 @@ describe('WebhooksService', () => { update: jest.Mock; delete: jest.Mock; }; + webhookDelivery: { + createMany: jest.Mock; + findMany: jest.Mock; + }; + $executeRaw: jest.Mock; }; let auditLog: { record: jest.Mock }; beforeEach(() => { process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret-0123456789'; - postWebhookMock.mockReset(); - postWebhookMock.mockResolvedValue({ status: 200 }); prisma = { webhookSubscription: { findMany: jest.fn().mockResolvedValue([]), @@ -36,6 +39,15 @@ describe('WebhooksService', () => { update: jest.fn(), delete: jest.fn(), }, + webhookDelivery: { + createMany: jest + .fn() + .mockImplementation(({ data }: { data: DeliveryRowInput[] }) => + Promise.resolve({ count: data.length }), + ), + findMany: jest.fn().mockResolvedValue([]), + }, + $executeRaw: jest.fn().mockResolvedValue(1), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; service = new WebhooksService( @@ -44,6 +56,10 @@ describe('WebhooksService', () => { ); }); + afterEach(() => jest.restoreAllMocks()); + + const db = () => prisma as unknown as DbClient; + it('create generates a random secret and stores it', async () => { prisma.webhookSubscription.create.mockImplementation( ({ data }: { data: Record }) => ({ @@ -85,108 +101,191 @@ describe('WebhooksService', () => { ); }); - it('dispatch only queries subscriptions matching the event and active flag', async () => { - await service.dispatch('colony.created', { id: 'colony-1' }); + describe('enqueueDeliveries', () => { + it('enqueueDeliveries writes one row per active subscription for the event and wakes the worker', async () => { + prisma.webhookSubscription.findMany.mockResolvedValueOnce([ + { id: 'wh-1' }, + { id: 'wh-2' }, + ]); + const payload = { id: 'colony-1', code: 'V-1' }; - expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({ - where: { active: true, events: { has: 'colony.created' } }, + await expect( + service.enqueueDeliveries(db(), 'colony.created', payload), + ).resolves.toBe(2); + + expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({ + where: { active: true, events: { has: 'colony.created' } }, + select: { id: true }, + }); + // The body is fixed here, so every retry sends the same bytes. + const body = JSON.stringify({ event: 'colony.created', data: payload }); + expect(prisma.webhookDelivery.createMany).toHaveBeenCalledWith({ + data: [ + { subscriptionId: 'wh-1', event: 'colony.created', body }, + { subscriptionId: 'wh-2', event: 'colony.created', body }, + ], + }); + expect(prisma.$executeRaw).toHaveBeenCalledTimes(1); + const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [ + [TemplateStringsArray, ...unknown[]], + ]; + expect(strings.join('$')).toBe("SELECT pg_notify($, '')"); + expect(values).toEqual(['webhook_delivery']); }); - }); - it('dispatch signs timestamp, delivery id and payload with HMAC-SHA256', async () => { - const subscription = { - id: 'wh-1', - url: 'https://example.test/hook', - secret: sealWebhookSecret('topsecret'), - active: true, - }; - prisma.webhookSubscription.findMany.mockResolvedValueOnce([subscription]); + it('writes nothing and wakes nobody without a matching subscription', async () => { + await expect( + service.enqueueDeliveries(db(), 'task.created', { id: 't1' }), + ).resolves.toBe(0); - await service.dispatch('colony.created', { id: 'colony-1' }); - await flushDeliveries(); + expect(prisma.webhookDelivery.createMany).not.toHaveBeenCalled(); + expect(prisma.$executeRaw).not.toHaveBeenCalled(); + }); - expect(postWebhookMock).toHaveBeenCalledTimes(1); - const [url, headers, body] = postWebhookMock.mock.calls[0]; - expect(url).toBe('https://example.test/hook'); - expect(headers['X-ApiCore-Event']).toBe('colony.created'); - expect(headers['X-ApiCore-Delivery']).toMatch(/^[0-9a-f-]{36}$/); - expect(Number(headers['X-ApiCore-Timestamp'])).toBeGreaterThan(0); + it('works on the client it is given, e.g. a handler transaction', async () => { + const tx = { + webhookSubscription: { + findMany: jest.fn().mockResolvedValue([{ id: 'wh-1' }]), + }, + webhookDelivery: { + createMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + $executeRaw: jest.fn().mockResolvedValue(1), + }; - const expectedSignature = crypto - .createHmac('sha256', 'topsecret') - .update( - `${headers['X-ApiCore-Timestamp']}.${headers['X-ApiCore-Delivery']}.${body}`, - ) - .digest('hex'); - expect(headers['X-ApiCore-Signature']).toBe(`sha256=${expectedSignature}`); - }); - - it('dispatch returns before a slow receiver has answered', async () => { - prisma.webhookSubscription.findMany.mockResolvedValueOnce([ - { id: 'wh-1', url: 'https://example.test/hook', secret: 's' }, - ]); - postWebhookMock.mockReturnValueOnce(new Promise(() => undefined)); - - await expect( - service.dispatch('colony.created', { id: 'colony-1' }), - ).resolves.toBeUndefined(); - }); - - it('dispatch does not throw when delivery fails', async () => { - prisma.webhookSubscription.findMany.mockResolvedValueOnce([ - { - id: 'wh-1', - url: 'https://example.test/hook', - secret: 's', - active: true, - }, - ]); - postWebhookMock.mockRejectedValueOnce(new Error('network down')); - - await expect( - service.dispatch('colony.created', { id: 'colony-1' }), - ).resolves.toBeUndefined(); - await flushDeliveries(); - }); - - // dispatch() runs after the event's record is committed: a failure must - // not fail that request (the client would retry and store it twice). - describe('dispatch after commit', () => { - let errorSpy: jest.SpyInstance; - - beforeEach(() => { - errorSpy = jest - .spyOn(Logger.prototype, 'error') - .mockImplementation(() => undefined); - prisma.webhookSubscription.findMany.mockRejectedValue( - new Error('db down'), + await service.enqueueDeliveries( + tx as unknown as DbClient, + 'stock.changed', + { bottlingBatchIds: ['b1'], productIds: [] }, ); - }); - afterEach(() => { - errorSpy.mockRestore(); + expect(tx.webhookDelivery.createMany).toHaveBeenCalledTimes(1); + expect(tx.$executeRaw).toHaveBeenCalledTimes(1); + expect(prisma.webhookDelivery.createMany).not.toHaveBeenCalled(); + expect(prisma.$executeRaw).not.toHaveBeenCalled(); }); + }); + + describe('dispatch (legacy path)', () => { + it('enqueues on the root client after the commit and sends nothing itself', async () => { + prisma.webhookSubscription.findMany.mockResolvedValueOnce([ + { id: 'wh-1' }, + ]); - it('never throws when the subscriptions cannot be loaded and logs the record', async () => { await expect( service.dispatch('colony.created', { id: 'colony-1' }), ).resolves.toBeUndefined(); - expect(errorSpy).toHaveBeenCalledTimes(1); - const [message] = errorSpy.mock.calls[0] as [string]; - expect(message).toContain('colony.created colony-1'); - expect(message).toContain('db down'); + expect(prisma.webhookSubscription.findMany).toHaveBeenCalledWith({ + where: { active: true, events: { has: 'colony.created' } }, + select: { id: true }, + }); + expect(prisma.webhookDelivery.createMany).toHaveBeenCalledWith({ + data: [ + { + subscriptionId: 'wh-1', + event: 'colony.created', + body: '{"event":"colony.created","data":{"id":"colony-1"}}', + }, + ], + }); + expect(prisma.$executeRaw).toHaveBeenCalledTimes(1); }); - it('also logs a payload without its own id', async () => { - await service.dispatch('stock.changed', { - bottlingBatchIds: ['b-1'], - productIds: [], + // dispatch() runs after the event's record is committed: a failure must + // not fail that request (the client would retry and store it twice). + describe('when enqueuing fails', () => { + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); }); - const [message] = errorSpy.mock.calls[0] as [string]; - expect(message).toContain('stock.changed'); - expect(message).toContain('db down'); + it('dispatch never throws when enqueuing fails (legacy path)', async () => { + prisma.webhookSubscription.findMany.mockResolvedValueOnce([ + { id: 'wh-1' }, + ]); + prisma.webhookDelivery.createMany.mockRejectedValueOnce( + new Error('db down'), + ); + + await expect( + service.dispatch('colony.created', { id: 'colony-1' }), + ).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const [message] = errorSpy.mock.calls[0] as [string]; + expect(message).toContain('colony.created colony-1'); + expect(message).toContain('db down'); + }); + + it('never throws when the subscriptions cannot be loaded and logs the record', async () => { + prisma.webhookSubscription.findMany.mockRejectedValueOnce( + new Error('db down'), + ); + + await expect( + service.dispatch('colony.created', { id: 'colony-1' }), + ).resolves.toBeUndefined(); + + const [message] = errorSpy.mock.calls[0] as [string]; + expect(message).toContain('colony.created colony-1'); + expect(message).toContain('db down'); + }); + + it('also logs a payload without its own id', async () => { + prisma.webhookSubscription.findMany.mockRejectedValueOnce( + new Error('db down'), + ); + + await service.dispatch('stock.changed', { + bottlingBatchIds: ['b-1'], + productIds: [], + }); + + const [message] = errorSpy.mock.calls[0] as [string]; + expect(message).toContain('stock.changed'); + expect(message).toContain('db down'); + }); + }); + }); + + describe('findDeliveries', () => { + it('lists the newest deliveries of a subscription without their body', async () => { + prisma.webhookSubscription.findUnique.mockResolvedValueOnce({ + id: 'wh-1', + }); + const rows = [{ id: 'd2' }, { id: 'd1' }]; + prisma.webhookDelivery.findMany.mockResolvedValueOnce(rows); + + await expect(service.findDeliveries('wh-1', 20)).resolves.toBe(rows); + + expect(prisma.webhookDelivery.findMany).toHaveBeenCalledWith({ + where: { subscriptionId: 'wh-1' }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: 20, + select: { + id: true, + event: true, + status: true, + attempts: true, + lastStatusCode: true, + lastError: true, + createdAt: true, + deliveredAt: true, + }, + }); + }); + + it('throws NotFoundException for an unknown subscription', async () => { + prisma.webhookSubscription.findUnique.mockResolvedValueOnce(null); + + await expect( + service.findDeliveries('missing', 50), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.webhookDelivery.findMany).not.toHaveBeenCalled(); }); }); diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.ts b/ApiCore-Backend/src/webhooks/webhooks.service.ts index dd0e94d..6813f41 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.service.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.service.ts @@ -2,17 +2,33 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import * as crypto from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; -import { WebhookSubscription } from '../../generated/prisma'; +import { + WebhookDeliveryStatus, + WebhookSubscription, +} from '../../generated/prisma'; +import { DbClient } from '../prisma/db-client'; import { CreateWebhookDto } from './dto/create-webhook.dto'; import { UpdateWebhookDto } from './dto/update-webhook.dto'; import { WebhookEvent } from './webhook-events'; import { assertPublicWebhookUrl } from './webhook-url-guard'; -import { postWebhook } from './webhook-delivery'; import { openWebhookSecret, sealWebhookSecret } from './webhook-secret'; import { runAfterCommit } from '../common/after-commit'; +import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup'; const SECRET_BYTES = 32; +/** One entry of a subscription's delivery log - without the body. */ +export interface WebhookDeliveryView { + id: string; + event: string; + status: WebhookDeliveryStatus; + attempts: number; + lastStatusCode: number | null; + lastError: string | null; + createdAt: Date; + deliveredAt: Date | null; +} + /** The record an event is about, for the log - some payloads have none. */ function payloadId(payload: unknown): string { if ( @@ -146,12 +162,46 @@ export class WebhooksService { } /** - * Starts delivery and returns without waiting for the receivers: a slow - * or hanging endpoint must never hold up the request that caused the - * event. Failures are logged by deliver(). + * Queues one WebhookDelivery per active subscription of `event` and wakes + * the worker, which sends them (webhook-delivery.worker.ts) - nothing is + * sent here. The body is fixed now, so every retry sends the same bytes + * under the same delivery id. On a transaction client the rows commit (and + * the wake-up fires) with the caller's transaction, e.g. a domain event + * handler's. Returns how many deliveries were queued. + */ + async enqueueDeliveries( + db: DbClient, + event: WebhookEvent, + payload: unknown, + ): Promise { + const subscriptions = await db.webhookSubscription.findMany({ + where: { active: true, events: { has: event } }, + select: { id: true }, + }); + if (subscriptions.length === 0) { + return 0; + } + const body = JSON.stringify({ event, data: payload }); + const { count } = await db.webhookDelivery.createMany({ + data: subscriptions.map((subscription) => ({ + subscriptionId: subscription.id, + event, + body, + })), + }); + if (count > 0) { + await notifyWorker(db, WAKE_CHANNELS.webhookDeliveries); + } + return count; + } + + /** + * Legacy path for producers that do not record a domain event yet: queues + * the deliveries right after the record was committed. A slow or hanging + * receiver can never hold up the request - the worker sends. * * Called after the event's record is committed, so it never throws - not - * even when the subscriptions cannot be loaded (see runAfterCommit). + * even when the deliveries cannot be queued (see runAfterCommit). */ async dispatch(event: WebhookEvent, payload: unknown): Promise { await runAfterCommit( @@ -162,51 +212,38 @@ export class WebhooksService { entityId: payloadId(payload), }, async () => { - const subscriptions = await this.prisma.webhookSubscription.findMany({ - where: { active: true, events: { has: event } }, - }); - for (const subscription of subscriptions) { - void this.deliver(subscription, event, payload); - } + await this.enqueueDeliveries(this.prisma, event, payload); }, ); } - private async deliver( - subscription: WebhookSubscription, - event: WebhookEvent, - payload: unknown, - ): Promise { - const body = JSON.stringify({ event, data: payload }); - // Signed together with a timestamp and a unique delivery id, so a - // receiver can reject a captured request replayed later - // ("t.id.body", the same idea as Stripe's signatures). - const deliveryId = crypto.randomUUID(); - const timestamp = Math.floor(Date.now() / 1000).toString(); - try { - const signature = crypto - .createHmac('sha256', openWebhookSecret(subscription.secret)) - .update(`${timestamp}.${deliveryId}.${body}`) - .digest('hex'); - // No redirects, 5 s timeout, connect-time address check - see - // postWebhook(). - await postWebhook( - subscription.url, - { - 'Content-Type': 'application/json', - 'User-Agent': 'ApiCore-Webhooks/1', - 'X-ApiCore-Event': event, - 'X-ApiCore-Delivery': deliveryId, - 'X-ApiCore-Timestamp': timestamp, - 'X-ApiCore-Signature': `sha256=${signature}`, - }, - body, - ); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - this.logger.warn( - `Webhook delivery to ${subscription.url} failed for event ${event}: ${message}`, - ); + /** The newest deliveries of a subscription, for its delivery log. */ + async findDeliveries( + subscriptionId: string, + limit: number, + ): Promise { + const subscription = await this.prisma.webhookSubscription.findUnique({ + where: { id: subscriptionId }, + select: { id: true }, + }); + if (!subscription) { + throw new NotFoundException('Webhook subscription not found'); } + // Never the body: it holds the full record the event was about. + return this.prisma.webhookDelivery.findMany({ + where: { subscriptionId }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: limit, + select: { + id: true, + event: true, + status: true, + attempts: true, + lastStatusCode: true, + lastError: true, + createdAt: true, + deliveredAt: true, + }, + }); } } diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts index 6cddbd0..4648c82 100644 --- a/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts @@ -1,10 +1,16 @@ import { WinterChecksService } from './winter-checks.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; describe('WinterChecksService', () => { - const livingColony = { id: 'c1', status: 'ACTIVE', apiaryId: 'a1' }; + const livingColony = { + id: 'c1', + status: 'ACTIVE', + healthStatus: null, + apiaryId: 'a1', + }; const lostCheck: CreateWinterCheckDto = { colonyId: 'c1', season: 'SPRING', @@ -16,8 +22,9 @@ describe('WinterChecksService', () => { let service: WinterChecksService; let tx: { + $queryRaw: jest.Mock; winterCheck: { create: jest.Mock }; - colony: { update: jest.Mock }; + colony: { update: jest.Mock; findUnique: jest.Mock }; auditLog: { create: jest.Mock }; }; let prisma: { @@ -25,9 +32,11 @@ describe('WinterChecksService', () => { $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; + let events: { recordAll: jest.Mock }; beforeEach(() => { tx = { + $queryRaw: jest.fn().mockResolvedValue([]), winterCheck: { create: jest .fn() @@ -36,6 +45,7 @@ describe('WinterChecksService', () => { ), }, colony: { + findUnique: jest.fn().mockResolvedValue(livingColony), update: jest .fn() .mockResolvedValue({ ...livingColony, status: 'DECEASED' }), @@ -51,9 +61,11 @@ describe('WinterChecksService', () => { ), }; auditLog = { record: jest.fn() }; + events = { recordAll: jest.fn().mockResolvedValue(0) }; service = new WinterChecksService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + events as unknown as DomainEventsService, ); }); @@ -83,4 +95,72 @@ describe('WinterChecksService', () => { ).map(([input]) => input.entityType); expect(auditedTypes).toEqual(['WinterCheck']); }); + + it('a winter loss with markColonyDeceased records ColonyEnded "DECEASED" in the same transaction', async () => { + await service.create(lostCheck, 'u1'); + + expect(events.recordAll).toHaveBeenCalledWith(tx, [ + expect.objectContaining({ + type: 'ColonyStatusChanged', + aggregateId: 'c1', + actorUserId: 'u1', + payload: { + from: { status: 'ACTIVE', healthStatus: null }, + to: { status: 'DECEASED', healthStatus: null }, + }, + }), + expect.objectContaining({ + type: 'ColonyEnded', + aggregateId: 'c1', + payload: { status: 'DECEASED' }, + }), + ]); + }); + + it('records no colony event when the colony is not to be marked deceased', async () => { + await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); + + expect(events.recordAll).not.toHaveBeenCalled(); + }); + + it('audits the winter check inside the transaction', async () => { + await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); + + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'WinterCheck', action: 'CREATE' }), + tx, + ); + }); + + it('locks the colony and uses the locked row as "before" for event and audit', async () => { + tx.colony.findUnique.mockResolvedValue({ + ...livingColony, + healthStatus: 'CRITICAL', + }); + + await service.create(lostCheck, 'u1'); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + expect(events.recordAll).toHaveBeenCalledWith(tx, [ + expect.objectContaining({ + payload: { + from: { status: 'ACTIVE', healthStatus: 'CRITICAL' }, + to: { status: 'DECEASED', healthStatus: null }, + }, + }), + expect.anything(), + ]); + }); + + it('does not mark a colony deceased that ended between read and lock', async () => { + tx.colony.findUnique.mockResolvedValue({ + ...livingColony, + status: 'DISSOLVED', + }); + + await service.create(lostCheck, 'u1'); + + expect(tx.colony.update).not.toHaveBeenCalled(); + expect(events.recordAll).not.toHaveBeenCalled(); + }); }); diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts index 155ecb7..8f96d5f 100644 --- a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts @@ -6,8 +6,10 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; import { Colony, Prisma, WinterCheck } from '../../generated/prisma'; import { isLivingColonyStatus } from '../colonies/colony-status'; +import { colonyTransitionEvents, lockColony } from '../colonies/colony-events'; import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; import { UpdateWinterCheckDto } from './dto/update-winter-check.dto'; import { WinterCheckQueryDto } from './dto/winter-check-query.dto'; @@ -43,6 +45,7 @@ export class WinterChecksService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly events: DomainEventsService, ) {} findAll(query: WinterCheckQueryDto): Promise { @@ -126,21 +129,24 @@ export class WinterChecksService { }); await this.markDeceasedIfRequested( tx, - colony, + colony.id, created, markColonyDeceased, actingUserId, ); + await this.auditLog.record( + { + entityType: 'WinterCheck', + entityId: created.id, + action: 'CREATE', + userId: actingUserId, + afterData: created, + }, + tx, + ); return created; }), ); - await this.auditLog.record({ - entityType: 'WinterCheck', - entityId: check.id, - action: 'CREATE', - userId: actingUserId, - afterData: check, - }); return check; } @@ -172,22 +178,25 @@ export class WinterChecksService { }); await this.markDeceasedIfRequested( tx, - colony, + colony.id, updated, markColonyDeceased, actingUserId, ); + await this.auditLog.record( + { + entityType: 'WinterCheck', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: updated, + }, + tx, + ); return updated; }), ); - await this.auditLog.record({ - entityType: 'WinterCheck', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: check, - }); return check; } @@ -215,17 +224,24 @@ export class WinterChecksService { */ private async markDeceasedIfRequested( tx: Prisma.TransactionClient, - colony: Colony, + colonyId: string, check: WinterCheck, requested: boolean | undefined, actingUserId: string, ): Promise { if (!requested || check.outcome !== 'LOST') return; + // Lock and re-read: the status decision and the event/audit "before" + // must come from the row as it is now, not from an earlier read. + const colony = await lockColony(tx, colonyId); if (!isLivingColonyStatus(colony.status)) return; const after = await tx.colony.update({ where: { id: colony.id }, data: { status: 'DECEASED', deactivatedAt: check.date }, }); + await this.events.recordAll( + tx, + colonyTransitionEvents(colony, after, actingUserId), + ); await this.auditLog.record( { entityType: 'Colony', diff --git a/ApiCore-Backend/src/worker/worker-runtime.module.ts b/ApiCore-Backend/src/worker/worker-runtime.module.ts index aa118c2..b43917e 100644 --- a/ApiCore-Backend/src/worker/worker-runtime.module.ts +++ b/ApiCore-Backend/src/worker/worker-runtime.module.ts @@ -4,8 +4,8 @@ import { WorkerRuntime } from './worker-runtime.service'; /** * The worker loops. Imported by WorkerModule (dist/worker.js) and * AllInOneModule ("all"), never by AppModule: API pods run no background - * loops. Its dependencies come from the global Prisma, DomainEvents and - * CalendarSync modules of the importing root module. + * loops. Its dependencies come from the global Prisma, DomainEvents, + * CalendarSync and Webhooks modules of the importing root module. */ @Module({ providers: [WorkerRuntime], diff --git a/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts b/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts index 4139a49..050204a 100644 --- a/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts +++ b/ApiCore-Backend/src/worker/worker-runtime.service.spec.ts @@ -7,12 +7,14 @@ import { DRAIN_MAX_ROUNDS, } from '../domain-events/domain-event-dispatcher'; import type { PrismaService } from '../prisma/prisma.service'; +import type { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker'; import { PgListener, PgListenerOptions } from './pg-listener'; import { HEALTH_STALE_MS } from './worker-health'; import { CALENDAR_OUTBOX_POLL_MS, DOMAIN_EVENT_POLL_MS, HEALTH_QUERY_TIMEOUT_MS, + WEBHOOK_DELIVERY_POLL_MS, WorkerRuntime, } from './worker-runtime.service'; @@ -47,6 +49,11 @@ class FakeDispatcher { } } +class FakeWebhookDeliveries { + releaseStaleClaims = jest.fn, []>().mockResolvedValue(0); + drain = jest.fn, unknown[]>().mockResolvedValue(0); +} + describe('WorkerRuntime', () => { const ListenerMock = PgListener as unknown as jest.Mock; const originalDatabaseUrl = process.env.DATABASE_URL; @@ -56,6 +63,7 @@ describe('WorkerRuntime', () => { let calendarSync: { processOutbox: jest.Mock, [AbortSignal?]>; }; + let webhookDeliveries: FakeWebhookDeliveries; let runtime: WorkerRuntime; let logError: jest.SpyInstance; @@ -84,6 +92,7 @@ describe('WorkerRuntime', () => { .fn, [AbortSignal?]>() .mockResolvedValue(undefined), }; + webhookDeliveries = new FakeWebhookDeliveries(); logError = jest .spyOn(Logger.prototype, 'error') .mockImplementation(() => undefined); @@ -92,6 +101,7 @@ describe('WorkerRuntime', () => { prisma as unknown as PrismaService, dispatcher as unknown as DomainEventDispatcher, calendarSync as unknown as CalendarSyncService, + webhookDeliveries as unknown as WebhookDeliveryWorker, ); }); @@ -110,6 +120,7 @@ describe('WorkerRuntime', () => { it('exposes the documented poll intervals', () => { expect(DOMAIN_EVENT_POLL_MS).toBe(5_000); expect(CALENDAR_OUTBOX_POLL_MS).toBe(60_000); + expect(WEBHOOK_DELIVERY_POLL_MS).toBe(5_000); expect(HEALTH_QUERY_TIMEOUT_MS).toBe(5_000); }); @@ -119,7 +130,7 @@ describe('WorkerRuntime', () => { expect(ListenerMock).toHaveBeenCalledWith( expect.objectContaining({ connectionString: DATABASE_URL, - channels: ['domain_event', 'calendar_sync'], + channels: ['domain_event', 'calendar_sync', 'webhook_delivery'], }), ); expect(listener.start).toHaveBeenCalledTimes(1); @@ -144,6 +155,41 @@ describe('WorkerRuntime', () => { expect(calendarSync.processOutbox).toHaveBeenCalledWith( expect.any(AbortSignal), ); + // Webhook deliveries: stale claims first, then every due delivery of + // the database (no filter), stoppable by shutdown. + expect(webhookDeliveries.releaseStaleClaims).toHaveBeenCalledTimes(1); + expect(webhookDeliveries.drain).toHaveBeenCalledWith( + undefined, + DRAIN_MAX_ROUNDS, + expect.any(AbortSignal), + ); + const [webhookReleaseOrder] = + webhookDeliveries.releaseStaleClaims.mock.invocationCallOrder; + const [webhookDrainOrder] = + webhookDeliveries.drain.mock.invocationCallOrder; + expect(webhookReleaseOrder).toBeLessThan(webhookDrainOrder); + }); + + it('polls webhook deliveries every 5 s', async () => { + await bootstrap(); + expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(WEBHOOK_DELIVERY_POLL_MS - 1); + expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + expect(webhookDeliveries.drain).toHaveBeenCalledTimes(2); + expect(webhookDeliveries.releaseStaleClaims).toHaveBeenCalledTimes(2); + }); + + it('wakes the webhook-deliveries loop on a webhook_delivery notification', async () => { + await bootstrap(); + + notify('webhook_delivery'); + await jest.advanceTimersByTimeAsync(0); + + expect(webhookDeliveries.drain).toHaveBeenCalledTimes(2); + expect(dispatcher.drain).toHaveBeenCalledTimes(1); + expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1); }); it('polls domain events every 5 s and the calendar outbox every 60 s', async () => { @@ -278,7 +324,7 @@ describe('WorkerRuntime', () => { }); // A running tick must not claim up to 19 more batches of 50 events or - // 19 more calendar entries while the pod is being stopped. + // deliveries, or 19 more calendar entries, while the pod is being stopped. it('hands the running work a stop signal that shutdown aborts', async () => { await bootstrap(); const [, , drainSignal] = dispatcher.drain.mock.calls[0] as [ @@ -287,8 +333,14 @@ describe('WorkerRuntime', () => { AbortSignal, ]; const [outboxSignal] = calendarSync.processOutbox.mock.calls[0]; + const [, , webhookSignal] = webhookDeliveries.drain.mock.calls[0] as [ + unknown, + unknown, + AbortSignal, + ]; expect(drainSignal.aborted).toBe(false); expect(outboxSignal).toBe(drainSignal); + expect(webhookSignal).toBe(drainSignal); await runtime.onModuleDestroy(); @@ -302,9 +354,11 @@ describe('WorkerRuntime', () => { expect(listener.stop).toHaveBeenCalledTimes(1); notify('domain_event'); + notify('webhook_delivery'); await jest.advanceTimersByTimeAsync(CALENDAR_OUTBOX_POLL_MS * 2); expect(dispatcher.drain).toHaveBeenCalledTimes(1); expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1); + expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1); }); // A pass cut off mid-entry would only be taken over after the 5 min diff --git a/ApiCore-Backend/src/worker/worker-runtime.service.ts b/ApiCore-Backend/src/worker/worker-runtime.service.ts index 5fa3748..471b570 100644 --- a/ApiCore-Backend/src/worker/worker-runtime.service.ts +++ b/ApiCore-Backend/src/worker/worker-runtime.service.ts @@ -13,12 +13,14 @@ import { DRAIN_MAX_ROUNDS, } from '../domain-events/domain-event-dispatcher'; import { PrismaService } from '../prisma/prisma.service'; +import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker'; import { PgListener } from './pg-listener'; import { PollingLoop } from './polling-loop'; import { HEALTH_STALE_MS, WorkerHealth } from './worker-health'; export const DOMAIN_EVENT_POLL_MS = 5_000; export const CALENDAR_OUTBOX_POLL_MS = 60_000; +export const WEBHOOK_DELIVERY_POLL_MS = 5_000; /** The health check's SELECT 1 must answer within this. */ export const HEALTH_QUERY_TIMEOUT_MS = 5_000; @@ -61,6 +63,7 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy { private readonly prisma: PrismaService, private readonly dispatcher: DomainEventDispatcher, private readonly calendarSync: CalendarSyncService, + private readonly webhookDeliveries: WebhookDeliveryWorker, ) { this.loops = new Map( this.workerLoops().map(({ channel, name, intervalMs, tick }) => [ @@ -85,6 +88,12 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy { channel: WAKE_CHANNELS.calendarSync, tick: () => this.calendarSync.processOutbox(this.stopping.signal), }, + { + name: 'webhook-deliveries', + intervalMs: WEBHOOK_DELIVERY_POLL_MS, + channel: WAKE_CHANNELS.webhookDeliveries, + tick: () => this.deliverWebhooks(), + }, ]; } @@ -172,4 +181,14 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy { this.noteDispatcherProgress(); } } + + private async deliverWebhooks(): Promise { + await this.webhookDeliveries.releaseStaleClaims(); + // No filter: every due delivery of the database. + await this.webhookDeliveries.drain( + undefined, + DRAIN_MAX_ROUNDS, + this.stopping.signal, + ); + } } diff --git a/ApiCore-Backend/test/app-backup-job.e2e-spec.ts b/ApiCore-Backend/test/app-backup-job.e2e-spec.ts new file mode 100644 index 0000000..6e115b6 --- /dev/null +++ b/ApiCore-Backend/test/app-backup-job.e2e-spec.ts @@ -0,0 +1,137 @@ +import { INestApplicationContext } from '@nestjs/common'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PermissionCatalogSyncService } from '../src/auth/permissions/permission-catalog-sync.service'; +import { BackupSchedulerService } from '../src/backup/backup-scheduler.service'; +import { createJobsContext, runCli } from '../src/jobs/run-cli'; +import { PrismaService } from '../src/prisma/prisma.service'; + +// Whole-database exports of the shared test database - well beyond the 5 s +// default when other specs run alongside. +const EXPORT_TIMEOUT_MS = 120_000; + +// Once a year (1 January, 03:00 Berlin): a second run right after the first +// is never due, whatever hour the test runs in. +const YEARLY = '0 3 1 1 *'; + +const backupFiles = async (directory: string): Promise => + (await fs.readdir(directory)).filter( + (name) => name.startsWith('apicore-backup-') && name.endsWith('.json'), + ); + +/** + * `node dist/cli.js app-backup` against the test database: runCli with the + * real JobsModule context, backups into a temporary directory. + */ +describe('app-backup job (e2e)', () => { + const testStart = new Date(Date.now() - 1000); + const originalEnv = { ...process.env }; + let prisma: PrismaService; + let root: string; + + beforeAll(async () => { + prisma = new PrismaService(); + await prisma.$connect(); + root = await fs.mkdtemp(path.join(os.tmpdir(), 'apicore-app-backup-')); + process.env.BACKUP_CRON_SCHEDULE = YEARLY; + process.env.BACKUP_RETENTION_COUNT = '5'; + }); + + afterAll(async () => { + const runs = await prisma.jobRun.findMany({ + where: { job: 'app-backup', startedAt: { gte: testStart } }, + select: { id: true }, + }); + const runIds = runs.map((run) => run.id); + await prisma.notification.deleteMany({ + where: { relatedType: 'JobRun', relatedId: { in: runIds } }, + }); + await prisma.jobRun.deleteMany({ where: { id: { in: runIds } } }); + await prisma.$disconnect(); + await fs.rm(root, { recursive: true, force: true }); + process.env = { ...originalEnv }; + }); + + it('runs in the job context without the in-process cron and without the catalog sync', async () => { + const app = await createJobsContext(); + try { + expect(app.get(BackupSchedulerService)).toBeInstanceOf( + BackupSchedulerService, + ); + expect(() => + app.get(PermissionCatalogSyncService, { strict: false }), + ).toThrow(); + } finally { + await app.close(); + } + }); + + it( + 'writes one backup for two runs in the same hour', + async () => { + const directory = path.join(root, 'twice'); + process.env.BACKUP_STORAGE_PATH = directory; + + await expect(runCli(['app-backup'])).resolves.toBe(0); + await expect(runCli(['app-backup'])).resolves.toBe(0); + + const files = await backupFiles(directory); + expect(files).toHaveLength(1); + const runs = await prisma.jobRun.findMany({ + where: { job: 'app-backup', startedAt: { gte: testStart } }, + orderBy: [{ startedAt: 'asc' }, { id: 'asc' }], + }); + expect(runs.map((run) => run.status)).toEqual(['SUCCEEDED', 'SUCCEEDED']); + const { size } = await fs.stat(path.join(directory, files[0])); + expect(runs[0].result).toEqual({ + written: true, + fileName: files[0], + sizeBytes: size, + pruned: 0, + }); + expect(runs[1].result).toEqual({ written: false, reason: 'not-due' }); + }, + EXPORT_TIMEOUT_MS, + ); + + it( + 'writes one backup when two job pods check at the same time', + async () => { + const directory = path.join(root, 'concurrent'); + process.env.BACKUP_STORAGE_PATH = directory; + // Two contexts, two connection pools: like two pods. + const pods: INestApplicationContext[] = await Promise.all([ + createJobsContext(), + createJobsContext(), + ]); + try { + const results = await Promise.all( + pods.map((pod) => pod.get(BackupSchedulerService).runIfDue()), + ); + + expect(results.map((result) => result.written).sort()).toEqual([ + false, + true, + ]); + expect(results).toContainEqual({ written: false, reason: 'not-due' }); + expect(await backupFiles(directory)).toHaveLength(1); + } finally { + await Promise.all(pods.map((pod) => pod.close())); + } + }, + EXPORT_TIMEOUT_MS, + ); + + it('succeeds without writing anything while no path is configured', async () => { + delete process.env.BACKUP_STORAGE_PATH; + const app = await createJobsContext(); + try { + await expect(app.get(BackupSchedulerService).runIfDue()).resolves.toEqual( + { written: false, reason: 'no-path' }, + ); + } finally { + await app.close(); + } + }); +}); diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts index 8710e30..9350192 100644 --- a/ApiCore-Backend/test/automation.e2e-spec.ts +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -6,6 +6,7 @@ import { PrismaService } from '../src/prisma/prisma.service'; import { AutomationTriggerType, Prisma } from '../generated/prisma'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; interface AutomationRuleBody { id: string; @@ -28,6 +29,25 @@ describe('Automation rules (e2e)', () => { let apiaryId: string; let colonyId: string; let varroaRuleId: string; + let swarmRuleId: string; + + /** + * Records an inspection or varroa measurement and runs the handlers of + * its event - the rules evaluate there, not in the request. + */ + async function record(path: string, body: object): Promise { + const response = await request(app.getHttpServer()) + .post(`/api/v1/${path}`) + .set( + 'Authorization', + `Bearer ${path === 'inspections' ? staffToken : adminToken}`, + ) + .send({ colonyId, date: new Date().toISOString(), ...body }) + .expect(201); + const id = (response.body as { id: string }).id; + await drainDomainEvents(app, [id]); + return id; + } beforeAll(async () => { app = await createTestApp(); @@ -70,13 +90,41 @@ describe('Automation rules (e2e)', () => { }); afterAll(async () => { - await prisma.automationRule.updateMany({ - where: { triggerType: 'VARROA_THRESHOLD' }, - data: { active: true }, + // Rule tasks and the rules' own entries are written without a user - + // they are not covered by the userId cleanup below. Deliveries go with + // their event (ON DELETE CASCADE). + const ruleTaskIds = ( + await prisma.task.findMany({ + where: { relatedId: colonyId }, + select: { id: true }, + }) + ).map((task) => task.id); + const byColony = { where: { colonyId }, select: { id: true } }; + const recordIds = [ + ...(await prisma.inspection.findMany(byColony)), + ...(await prisma.varroaMeasurement.findMany(byColony)), + ].map((record) => record.id); + await prisma.auditLog.deleteMany({ + where: { + OR: [ + { entityType: 'Task', entityId: { in: ruleTaskIds } }, + { + entityType: 'AutomationRule', + afterData: { path: ['relatedId'], equals: colonyId }, + }, + ], + }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: [...ruleTaskIds, ...recordIds] } }, + }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceId: { in: [...ruleTaskIds, ...recordIds] } }, }); await prisma.task.deleteMany({ where: { relatedId: colonyId } }); await prisma.notification.deleteMany({ where: { relatedId: colonyId } }); await prisma.varroaMeasurement.deleteMany({ where: { colonyId } }); + await prisma.swarmControlCheck.deleteMany({ where: { colonyId } }); await prisma.inspection.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); @@ -112,19 +160,13 @@ describe('Automation rules (e2e)', () => { ); expect(varroaRule).toBeDefined(); varroaRuleId = varroaRule!.id; + swarmRuleId = rules.find( + (rule) => rule.triggerType === 'SWARM_MOOD_HIGH', + )!.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); + await record('inspections', { queenSeen: false, eggsPresent: false }); const tasks = await prisma.task.findMany({ where: { relatedId: colonyId }, @@ -139,16 +181,7 @@ describe('Automation rules (e2e)', () => { }); 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); + await record('inspections', { queenSeen: true, eggsPresent: false }); const tasks = await prisma.task.findMany({ where: { relatedId: colonyId }, @@ -157,15 +190,7 @@ describe('Automation rules (e2e)', () => { }); 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); + await record('inspections', { feedStatus: 'kritisch' }); const tasks = await prisma.task.findMany({ where: { relatedId: colonyId }, @@ -174,44 +199,95 @@ describe('Automation rules (e2e)', () => { expect(tasks.some((task) => task.title.includes('Fütterung'))).toBe(true); }); - it('triggers a treatment check when the varroa threshold is exceeded', async () => { + it('evaluates no rule on a corrected inspection', async () => { + const before = await prisma.task.count({ where: { relatedId: colonyId } }); + const id = await record('inspections', { queenSeen: true }); + // Close the open tasks so the one-open-task guard cannot be what keeps + // a new task from appearing - only the missing evaluation may. + await prisma.task.updateMany({ + where: { relatedId: colonyId }, + data: { status: 'DONE' }, + }); + 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, - // One day on the board: 20 mites per day, above the default 10. - durationMinutes: 24 * 60, - }) - .expect(201); + .patch(`/api/v1/inspections/${id}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ queenSeen: false, eggsPresent: false, feedStatus: 'kritisch' }) + .expect(200); + await drainDomainEvents(app, [id]); + + expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( + before, + ); + }); + + it('triggers a treatment check when the varroa threshold is exceeded', async () => { + const before = await prisma.task.count({ where: { relatedId: colonyId } }); + await record('varroa-measurements', { + method: 'NATURAL_MITE_FALL', + miteCount: 20, + // One day on the board: 20 mites per day, above the default 10. + durationMinutes: 24 * 60, + }); const tasks = await prisma.task.findMany({ where: { relatedId: colonyId }, }); - expect(tasks).toHaveLength(3); + expect(tasks).toHaveLength(before + 1); expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true); }); it('does not compare a sample-based count against the per-day threshold', async () => { + await prisma.task.updateMany({ + where: { relatedId: colonyId }, + data: { status: 'DONE' }, + }); const before = await prisma.task.count({ where: { relatedId: colonyId } }); - await request(app.getHttpServer()) - .post('/api/v1/varroa-measurements') - .set('Authorization', `Bearer ${adminToken}`) - .send({ - colonyId, - date: new Date().toISOString(), - method: 'POWDERED_SUGAR', - miteCount: 50, - }) - .expect(201); + await record('varroa-measurements', { + method: 'POWDERED_SUGAR', + miteCount: 50, + }); expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( before, ); }); + it('re-evaluates a corrected varroa measurement against the threshold', async () => { + await prisma.task.updateMany({ + where: { relatedId: colonyId }, + data: { status: 'DONE' }, + }); + const before = await prisma.task.count({ where: { relatedId: colonyId } }); + // Five mites per day: below the threshold. + const id = await record('varroa-measurements', { + method: 'NATURAL_MITE_FALL', + miteCount: 5, + durationMinutes: 24 * 60, + }); + expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( + before, + ); + + // The count was miscounted: 25 mites per day. + await request(app.getHttpServer()) + .patch(`/api/v1/varroa-measurements/${id}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ miteCount: 25 }) + .expect(200); + await drainDomainEvents(app, [id]); + + const tasks = await prisma.task.findMany({ + where: { + relatedId: colonyId, + sourceAutomationTrigger: 'VARROA_THRESHOLD', + }, + }); + expect(tasks.filter((task) => task.status === 'OPEN')).toHaveLength(1); + expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( + before + 1, + ); + }); + it('lets an admin clear a rule config, which falls back to its defaults', async () => { const before = await prisma.automationRule.findUniqueOrThrow({ where: { id: varroaRuleId }, @@ -243,34 +319,58 @@ describe('Automation rules (e2e)', () => { } }); + /** + * Deactivates SWARM_MOOD_HIGH, not a rule other e2e files rely on: the + * rule rows are shared, and switching off VARROA_THRESHOLD here would make + * a parallel file's varroa test see no task. Only swarm-control.e2e + * evaluates this rule, with checks that never trigger it (LOW or none). + */ 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); - // Close the earlier tasks so the one-open-task-per-rule guard cannot be - // what keeps a new task from appearing - only the deactivation may. + const highSwarmMood = () => + request(app.getHttpServer()) + .post('/api/v1/swarm-control-checks') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + swarmMood: 'HIGH', + }) + .expect(201); + const swarmTasks = () => + prisma.task.count({ + where: { + relatedId: colonyId, + sourceAutomationTrigger: 'SWARM_MOOD_HIGH', + }, + }); + + // While active, high swarm mood leaves a task - the rule really fires. + // (Swarm checks stay on the post-commit path: evaluated before the + // response, no event to drain.) + await highSwarmMood(); + expect(await swarmTasks()).toBe(1); + // Close it so the one-open-task guard cannot be what keeps a new task + // from appearing - only the deactivation may. await prisma.task.updateMany({ where: { relatedId: colonyId }, data: { status: 'DONE' }, }); - 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, - durationMinutes: 24 * 60, - }) - .expect(201); + try { + await request(app.getHttpServer()) + .patch(`/api/v1/automation-rules/${swarmRuleId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ active: false }) + .expect(200); - const tasks = await prisma.task.findMany({ - where: { relatedId: colonyId }, - }); - expect(tasks).toHaveLength(3); + await highSwarmMood(); + + expect(await swarmTasks()).toBe(1); + } finally { + await prisma.automationRule.update({ + where: { id: swarmRuleId }, + data: { active: true }, + }); + } }); }); diff --git a/ApiCore-Backend/test/bottling.e2e-spec.ts b/ApiCore-Backend/test/bottling.e2e-spec.ts index 1052a69..008b288 100644 --- a/ApiCore-Backend/test/bottling.e2e-spec.ts +++ b/ApiCore-Backend/test/bottling.e2e-spec.ts @@ -17,6 +17,12 @@ interface BottlingBatchBody { lotNumber: string; } +interface RecordedEvent { + type: string; + payload: unknown; + actorUserId: string | null; +} + describe('Bottling (e2e)', () => { let app: INestApplication; let prisma: PrismaService; @@ -24,12 +30,49 @@ describe('Bottling (e2e)', () => { const password = 'SuperSecret123!'; let beekeeperId: string; let beekeeperToken: string; + // Removing a bottling needs bottling-batch:delete, which BEEKEEPER lacks. + const adminEmail = `bottling-e2e-admin-${Date.now()}@example.com`; + let adminId: string; + let adminToken: string; let apiaryId: string; let colonyId: string; let harvestId: string; let extractionId: string; let honeyBatchId: string; let bottlingBatchId: string; + const seenEventIds = new Set(); + + /** + * The events recorded on the bottling since the last call, by type - one + * request's events share a correlation id. + */ + async function newEventsOf(id: string): Promise { + const events = ( + await prisma.domainEvent.findMany({ + where: { aggregateType: 'BottlingBatch', aggregateId: id }, + select: { + id: true, + type: true, + payload: true, + actorUserId: true, + correlationId: true, + }, + }) + ).filter((event) => !seenEventIds.has(event.id)); + events.forEach((event) => seenEventIds.add(event.id)); + expect(new Set(events.map((event) => event.correlationId)).size).toBe(1); + return events + .map(({ type, payload, actorUserId }) => ({ type, payload, actorUserId })) + .sort((a, b) => a.type.localeCompare(b.type)); + } + + async function honeyBatchAmount(): Promise { + const response = await request(app.getHttpServer()) + .get(`/api/v1/honey-batches/${honeyBatchId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + return (response.body as HoneyBatchBody).amount; + } beforeAll(async () => { app = await createTestApp(); @@ -46,6 +89,16 @@ describe('Bottling (e2e)', () => { }); beekeeperId = beekeeper.id; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + const admin = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash, + name: 'Admin', + role: { connect: { key: 'ADMIN' } }, + }, + }); + adminId = admin.id; + adminToken = (await login(app, adminEmail, password)).accessToken; const apiary = await prisma.apiary.create({ data: { name: `Stand ${Date.now()}` }, @@ -79,6 +132,12 @@ describe('Bottling (e2e)', () => { afterAll(async () => { if (bottlingBatchId) { + await prisma.domainEvent.deleteMany({ + where: { + aggregateType: 'BottlingBatch', + aggregateId: bottlingBatchId, + }, + }); await prisma.bottlingBatch.deleteMany({ where: { id: bottlingBatchId } }); } await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } }); @@ -88,9 +147,12 @@ describe('Bottling (e2e)', () => { await prisma.harvest.deleteMany({ where: { id: harvestId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); - await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); - await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); - await prisma.user.deleteMany({ where: { id: beekeeperId } }); + const userIds = [beekeeperId, adminId].filter(Boolean); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await app.close(); }); @@ -134,4 +196,75 @@ describe('Bottling (e2e)', () => { expect(honeyBatch.amount).toBe(0); expect(honeyBatch.status).toBe('BOTTLED'); }); + + it('bottling create records BatchBottled and StockChanged together', async () => { + expect(await newEventsOf(bottlingBatchId)).toEqual([ + { + type: 'BatchBottled', + payload: { honeyBatchId }, + actorUserId: beekeeperId, + }, + { + type: 'StockChanged', + payload: { + bottlingBatchIds: [bottlingBatchId], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: beekeeperId, + }, + ]); + }); + + it('a correction records BottlingCorrected and StockChanged together', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/bottling-batches/${bottlingBatchId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ jarCount: 40 }) + .expect(200); + + // 10 jars of 500 g fewer: 5 kg go back to the honey batch. + expect(await honeyBatchAmount()).toBe(5); + expect(await newEventsOf(bottlingBatchId)).toEqual([ + { + type: 'BottlingCorrected', + payload: { honeyBatchId, removed: false }, + actorUserId: beekeeperId, + }, + { + type: 'StockChanged', + payload: { + bottlingBatchIds: [bottlingBatchId], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: beekeeperId, + }, + ]); + }); + + it('a removal records BottlingCorrected { removed: true } and StockChanged together', async () => { + await request(app.getHttpServer()) + .delete(`/api/v1/bottling-batches/${bottlingBatchId}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(await honeyBatchAmount()).toBe(25); + expect(await newEventsOf(bottlingBatchId)).toEqual([ + { + type: 'BottlingCorrected', + payload: { honeyBatchId, removed: true }, + actorUserId: adminId, + }, + { + type: 'StockChanged', + payload: { + bottlingBatchIds: [bottlingBatchId], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: adminId, + }, + ]); + }); }); diff --git a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts index 6c22cee..1ca0c5f 100644 --- a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts +++ b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts @@ -11,6 +11,7 @@ import { WAKE_CHANNELS } from '../src/common/worker-wakeup'; import { PgListener } from '../src/worker/pg-listener'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; /** * The M365 outbox against the real database, with Microsoft Graph replaced @@ -119,6 +120,9 @@ describe('Calendar sync outbox (e2e)', () => { await prisma.calendarSyncOutboxEntry.deleteMany({ where: { sourceType: 'TASK', sourceId: { in: taskIds } }, }); + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Task', aggregateId: { in: taskIds } }, + }); await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); const admin = await prisma.user.findUnique({ where: { email: adminEmail }, @@ -237,6 +241,35 @@ describe('Calendar sync outbox (e2e)', () => { expect(entry.lastError).toBeNull(); }); + it('removes the Graph event of a task that was completed, and settles the entry SKIPPED', async () => { + const task = await queueTask('Completed with event'); + await calendarSync.processOutbox(); + const synced = await entryFor(task.id); + expect(synced.status).toBe('SYNCED'); + const graphEventId = synced.graphEventId; + expect(graphEventId).toMatch(/^event-/); + const deleteEvent = jest.spyOn(graph, 'deleteEvent'); + + // The real path: the request records TaskCompleted, the dispatcher runs + // the calendar-sync.task handler, which queues the entry again. + await request(app.getHttpServer()) + .post(`/api/v1/tasks/${task.id}/complete`) + .set('Authorization', `Bearer ${token}`) + .expect(201); + expect((await entryFor(task.id)).status).toBe('SYNCED'); + await drainDomainEvents(app, [task.id]); + expect((await entryFor(task.id)).status).toBe('PENDING'); + + await calendarSync.processOutbox(); + + expect(deleteEvent).toHaveBeenCalledWith('group-e2e', graphEventId); + expect(await entryFor(task.id)).toMatchObject({ + status: 'SKIPPED', + graphEventId: null, + claimedAt: null, + }); + }); + describe('API', () => { const auth = () => ({ Authorization: `Bearer ${token}` }); diff --git a/ApiCore-Backend/test/colonies.e2e-spec.ts b/ApiCore-Backend/test/colonies.e2e-spec.ts index dd9e4f5..5854e33 100644 --- a/ApiCore-Backend/test/colonies.e2e-spec.ts +++ b/ApiCore-Backend/test/colonies.e2e-spec.ts @@ -5,6 +5,7 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; interface ColonyBody { id: string; @@ -35,6 +36,8 @@ describe('Colonies (e2e)', () => { let colonyId: string; let childColonyId: string; let colonyToMergeId: string; + let healthColonyId: string; + let raceColonyId: string; beforeAll(async () => { app = await createTestApp(); @@ -74,13 +77,43 @@ describe('Colonies (e2e)', () => { }); afterAll(async () => { + const colonyIds = [ + colonyId, + childColonyId, + colonyToMergeId, + healthColonyId, + raceColonyId, + ].filter(Boolean); + // Rule tasks are written without a user - their audit entry and + // TaskCreated are not covered by the userId cleanup below. + const ruleTaskIds = ( + await prisma.task.findMany({ + where: { relatedId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((task) => task.id); + await prisma.auditLog.deleteMany({ + where: { entityType: 'Task', entityId: { in: ruleTaskIds } }, + }); + await prisma.domainEvent.deleteMany({ + where: { + OR: [ + { aggregateType: 'Colony', aggregateId: { in: colonyIds } }, + { aggregateType: 'Task', aggregateId: { in: ruleTaskIds } }, + ], + }, + }); + await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } }); + await prisma.notification.deleteMany({ + where: { relatedId: { in: colonyIds } }, + }); await prisma.colonyLineage.deleteMany({ where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] }, }); await prisma.colonyApiaryHistory.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { - id: { in: [colonyId, childColonyId, colonyToMergeId].filter(Boolean) }, + id: { in: colonyIds }, }, }); await prisma.apiary.deleteMany({ @@ -180,6 +213,11 @@ describe('Colonies (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); expect((merged.body as ColonyBody).status).toBe('MERGED'); + + const ended = await prisma.domainEvent.findMany({ + where: { type: 'ColonyEnded', aggregateId: colonyToMergeId }, + }); + expect(ended.map((event) => event.payload)).toEqual([{ status: 'MERGED' }]); }); it('dissolves the colony without deleting it', async () => { @@ -189,6 +227,16 @@ describe('Colonies (e2e)', () => { .expect(201); expect((response.body as ColonyBody).status).toBe('DISSOLVED'); + const events = await prisma.domainEvent.findMany({ + where: { aggregateId: colonyId }, + orderBy: { occurredAt: 'asc' }, + }); + expect(events.map((event) => event.type)).toEqual([ + 'ColonyStatusChanged', + 'ColonyEnded', + ]); + expect(events[1].payload).toEqual({ status: 'DISSOLVED' }); + await request(app.getHttpServer()) .get(`/api/v1/colonies/${colonyId}`) .set('Authorization', `Bearer ${staffToken}`) @@ -230,4 +278,83 @@ describe('Colonies (e2e)', () => { }); expect(colony.status).toBe('WEAK'); }); + + it('creates the COLONY_HEALTH_CRITICAL task only after the event is processed, and only on the transition', async () => { + const created = await request(app.getHttpServer()) + .post('/api/v1/colonies') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + code: `V-${Date.now()}-H`, + apiaryId: apiaryAId, + colonyType: 'PRODUCTION_COLONY', + }) + .expect(201); + healthColonyId = (created.body as ColonyBody).id; + + await request(app.getHttpServer()) + .patch(`/api/v1/colonies/${healthColonyId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ healthStatus: 'CRITICAL' }) + .expect(200); + + const event = await prisma.domainEvent.findFirstOrThrow({ + where: { type: 'ColonyStatusChanged', aggregateId: healthColonyId }, + }); + expect(event.payload).toEqual({ + from: { status: 'ACTIVE', healthStatus: null }, + to: { status: 'ACTIVE', healthStatus: 'CRITICAL' }, + }); + expect( + await prisma.task.count({ where: { relatedId: healthColonyId } }), + ).toBe(0); + + await drainDomainEvents(app, [healthColonyId]); + expect( + await prisma.task.count({ where: { relatedId: healthColonyId } }), + ).toBe(1); + + // Already critical: a further edit is no transition and opens no second task. + await request(app.getHttpServer()) + .patch(`/api/v1/colonies/${healthColonyId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ notes: 'weiter kritisch' }) + .expect(200); + await drainDomainEvents(app, [healthColonyId]); + expect( + await prisma.task.count({ where: { relatedId: healthColonyId } }), + ).toBe(1); + }); + + it('two concurrent PATCHes into CRITICAL produce one transition event and one task', async () => { + const created = await request(app.getHttpServer()) + .post('/api/v1/colonies') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + code: `V-${Date.now()}-R`, + apiaryId: apiaryAId, + colonyType: 'PRODUCTION_COLONY', + }) + .expect(201); + raceColonyId = (created.body as ColonyBody).id; + + await Promise.all( + [1, 2].map(() => + request(app.getHttpServer()) + .patch(`/api/v1/colonies/${raceColonyId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ healthStatus: 'CRITICAL' }) + .expect(200), + ), + ); + + expect( + await prisma.domainEvent.count({ + where: { type: 'ColonyStatusChanged', aggregateId: raceColonyId }, + }), + ).toBe(1); + await drainDomainEvents(app, [raceColonyId]); + expect( + await prisma.task.count({ where: { relatedId: raceColonyId } }), + ).toBe(1); + }); }); diff --git a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts new file mode 100644 index 0000000..4b76292 --- /dev/null +++ b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts @@ -0,0 +1,252 @@ +import { INestApplicationContext } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Prisma } from '../generated/prisma'; +import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job'; +import { JobsModule } from '../src/jobs/jobs.module'; +import { PrismaService } from '../src/prisma/prisma.service'; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); +/** When the CronJob runs on `day` (04:30 Europe/Berlin). */ +const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`); + +const NOTHING_NEW = { + WithdrawalEnded: 0, + TaskDue: 0, + QueenAged: 0, + VarroaMeasurementDue: 0, + InspectionOverdue: 0, +}; + +/** + * The job evaluates the whole database. The e2e files share it and run in + * parallel, so the evaluation runs on days in 2001: no other file has + * records there, and the events of this file stay its own. Called through + * evaluate(day) instead of runCli, which would also write JobRun rows that + * test/jobs.e2e-spec.ts counts. + */ +describe('daily-evaluation (e2e)', () => { + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + let app: INestApplicationContext; + let prisma: PrismaService; + let job: DailyEvaluationJob; + const ids: Record = {}; + + const eventsOf = (aggregateId: string) => + prisma.domainEvent.findMany({ + where: { aggregateId }, + select: { + type: true, + aggregateType: true, + payload: true, + dedupeKey: true, + actorUserId: true, + }, + orderBy: { type: 'asc' }, + }); + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [JobsModule], + }).compile(); + app = await moduleRef.init(); + prisma = app.get(PrismaService); + job = app.get(DailyEvaluationJob); + + const user = await prisma.user.create({ + data: { + email: `daily-evaluation-e2e-${suffix}@example.com`, + passwordHash: 'not-used', + name: 'Daily Evaluation E2E', + role: { connect: { key: 'STAFF' } }, + }, + }); + ids.user = user.id; + const apiary = await prisma.apiary.create({ + data: { name: `Daily Evaluation ${suffix}` }, + }); + ids.apiary = apiary.id; + const queen = async ( + name: string, + data: Pick, + ) => { + const created = await prisma.queen.create({ + data: { code: `DE-E2E-${suffix}-${name}`, ...data }, + }); + ids[`queen:${name}`] = created.id; + }; + await queen('aged', { hatchYear: 1999, status: 'ACTIVE' }); + await queen('young', { hatchYear: 2000, status: 'ACTIVE' }); + await queen('reserve', { hatchYear: 1999, status: 'RESERVE' }); + + // Living, created 2001-04-20 and never measured: varroa due 2001-05-18. + const living = await prisma.colony.create({ + data: { + code: `DE-E2E-${suffix}-living`, + apiaryId: apiary.id, + colonyType: 'PRODUCTION_COLONY', + currentQueenId: ids['queen:aged'], + createdAt: d('2001-04-20'), + }, + }); + ids.living = living.id; + const dead = await prisma.colony.create({ + data: { + code: `DE-E2E-${suffix}-dead`, + apiaryId: apiary.id, + colonyType: 'PRODUCTION_COLONY', + status: 'DECEASED', + createdAt: d('2001-04-20'), + }, + }); + ids.dead = dead.id; + // Inspected 2001-04-28: the next inspection is overdue on 2001-05-19. + for (const colonyId of [living.id, dead.id]) { + await prisma.inspection.create({ + data: { colonyId, userId: user.id, date: d('2001-04-28') }, + }); + } + + const treatment = async ( + name: string, + data: Pick< + Prisma.TreatmentUncheckedCreateInput, + 'date' | 'endDate' | 'withdrawalPeriodDays' + >, + ) => { + const created = await prisma.treatment.create({ + data: { + colonyId: living.id, + userId: user.id, + product: 'Oxalsäure', + ...data, + }, + }); + ids[`treatment:${name}`] = created.id; + }; + // 2001-05-10 + 7 = 2001-05-17, in the window of 2001-05-20. + await treatment('ending', { + date: d('2001-05-08'), + endDate: d('2001-05-10'), + withdrawalPeriodDays: 7, + }); + // 2001-05-01 + 7 = 2001-05-08, before the window. + await treatment('ended-earlier', { + date: d('2001-05-01'), + withdrawalPeriodDays: 7, + }); + await treatment('no-withdrawal', { date: d('2001-05-15') }); + + const task = async ( + name: string, + data: Pick, + ) => { + const created = await prisma.task.create({ + data: { title: `Daily evaluation ${suffix} ${name}`, ...data }, + }); + ids[`task:${name}`] = created.id; + }; + await task('open', { dueDate: d('2001-05-18'), status: 'OPEN' }); + await task('done', { dueDate: d('2001-05-18'), status: 'DONE' }); + }); + + afterAll(async () => { + const aggregateIds = Object.entries(ids) + .filter(([key]) => !['user', 'apiary'].includes(key)) + .map(([, id]) => id); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: aggregateIds } }, + }); + const colonyIds = [ids.living, ids.dead].filter(Boolean); + await prisma.treatment.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.inspection.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.task.deleteMany({ + where: { title: { startsWith: `Daily evaluation ${suffix}` } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.queen.deleteMany({ + where: { code: { startsWith: `DE-E2E-${suffix}` } }, + }); + if (ids.apiary) { + await prisma.apiary.deleteMany({ where: { id: ids.apiary } }); + } + if (ids.user) { + await prisma.user.deleteMany({ where: { id: ids.user } }); + } + await app.close(); + }); + + it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => { + const first = await job.evaluate(at('2001-05-20')); + const second = await job.evaluate(at('2001-05-20')); + // The next day the occurrence is still in the window - and still known. + const nextDay = await job.evaluate(at('2001-05-21')); + + expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1); + expect(second).toEqual(NOTHING_NEW); + expect(nextDay).toEqual(NOTHING_NEW); + expect(await eventsOf(ids['treatment:ending'])).toEqual([ + { + type: 'WithdrawalEnded', + aggregateType: 'Treatment', + payload: { colonyId: ids.living, endedOn: '2001-05-17' }, + dedupeKey: `WithdrawalEnded:${ids['treatment:ending']}:2001-05-17`, + actorUserId: null, + }, + ]); + expect(await eventsOf(ids['treatment:ended-earlier'])).toEqual([]); + expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]); + }); + + it('records due tasks and overdue checks only for open tasks and living colonies', async () => { + await job.evaluate(at('2001-05-20')); + + expect(await eventsOf(ids['task:open'])).toEqual([ + { + type: 'TaskDue', + aggregateType: 'Task', + payload: { dueOn: '2001-05-18' }, + dedupeKey: `TaskDue:${ids['task:open']}:2001-05-18`, + actorUserId: null, + }, + ]); + expect(await eventsOf(ids['task:done'])).toEqual([]); + expect(await eventsOf(ids.living)).toEqual([ + { + type: 'InspectionOverdue', + aggregateType: 'Colony', + payload: { dueOn: '2001-05-19', lastInspectedOn: '2001-04-28' }, + dedupeKey: `InspectionOverdue:${ids.living}:2001-05-19`, + actorUserId: null, + }, + { + type: 'VarroaMeasurementDue', + aggregateType: 'Colony', + payload: { dueOn: '2001-05-18', lastMeasuredOn: null }, + dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`, + actorUserId: null, + }, + ]); + expect(await eventsOf(ids.dead)).toEqual([]); + }); + + it('reports active queens from their second year in the first days of January', async () => { + await job.evaluate(at('2001-01-05')); + expect(await job.evaluate(at('2001-01-08'))).toEqual(NOTHING_NEW); + + expect(await eventsOf(ids['queen:aged'])).toEqual([ + { + type: 'QueenAged', + aggregateType: 'Queen', + payload: { hatchYear: 1999, colonyId: ids.living }, + dedupeKey: `QueenAged:${ids['queen:aged']}:2001-01-01`, + actorUserId: null, + }, + ]); + expect(await eventsOf(ids['queen:young'])).toEqual([]); + expect(await eventsOf(ids['queen:reserve'])).toEqual([]); + }); +}); diff --git a/ApiCore-Backend/test/data-integrity.e2e-spec.ts b/ApiCore-Backend/test/data-integrity.e2e-spec.ts index 307a08d..d33f69e 100644 --- a/ApiCore-Backend/test/data-integrity.e2e-spec.ts +++ b/ApiCore-Backend/test/data-integrity.e2e-spec.ts @@ -5,6 +5,7 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; /** * Regression tests for data-integrity rules that cut across modules: @@ -84,6 +85,21 @@ describe('Data integrity (e2e)', () => { }); afterAll(async () => { + // The events this file's requests recorded (all by its own user) and + // the follow-up events their handlers recorded under the same + // correlation id - other e2e files share the database. + if (userId) { + const correlationIds = ( + await prisma.domainEvent.findMany({ + where: { actorUserId: userId }, + select: { correlationId: true }, + distinct: ['correlationId'], + }) + ).map((event) => event.correlationId); + await prisma.domainEvent.deleteMany({ + where: { correlationId: { in: correlationIds } }, + }); + } await app.close(); }); @@ -117,6 +133,7 @@ describe('Data integrity (e2e)', () => { expect( await prisma.inspection.count({ where: { colonyId: colony.id } }), ).toBe(1); + await drainDomainEvents(app, [(first.body as { id: string }).id]); // The automation fired once, not once per delivery attempt. expect( await prisma.task.count({ @@ -397,6 +414,76 @@ describe('Data integrity (e2e)', () => { expect((second.body as { completedAt: string }).completedAt).toBe( (first.body as { completedAt: string }).completedAt, ); + // Announced once: a second TaskCompleted would send the webhook twice. + await expect( + prisma.domainEvent.count({ + where: { aggregateId: id, type: 'TaskCompleted' }, + }), + ).resolves.toBe(1); + }); + + it('announces a completion once when two requests complete a task at the same time', async () => { + const created = await request(server()) + .post('/api/v1/tasks') + .set(auth()) + .send({ title: `Gleichzeitig erledigt ${suffix}` }) + .expect(201); + const id = (created.body as { id: string }).id; + + const [first, second] = await Promise.all([ + request(server()) + .post(`/api/v1/tasks/${id}/complete`) + .set(auth()) + .expect(201), + request(server()) + .post(`/api/v1/tasks/${id}/complete`) + .set(auth()) + .expect(201), + ]); + + expect((second.body as { completedAt: string }).completedAt).toBe( + (first.body as { completedAt: string }).completedAt, + ); + await expect( + prisma.domainEvent.count({ + where: { aggregateId: id, type: 'TaskCompleted' }, + }), + ).resolves.toBe(1); + await expect( + prisma.auditLog.count({ + where: { entityType: 'Task', entityId: id, action: 'UPDATE' }, + }), + ).resolves.toBe(1); + }); + + it('announces a completion once when an edit into DONE and "Erledigt" arrive at the same time', async () => { + const created = await request(server()) + .post('/api/v1/tasks') + .set(auth()) + .send({ title: `Bearbeitet und erledigt ${suffix}` }) + .expect(201); + const id = (created.body as { id: string }).id; + + const [edited, completed] = await Promise.all([ + request(server()) + .patch(`/api/v1/tasks/${id}`) + .set(auth()) + .send({ status: 'DONE' }) + .expect(200), + request(server()) + .post(`/api/v1/tasks/${id}/complete`) + .set(auth()) + .expect(201), + ]); + + expect((edited.body as { completedAt: string }).completedAt).toBe( + (completed.body as { completedAt: string }).completedAt, + ); + await expect( + prisma.domainEvent.count({ + where: { aggregateId: id, type: 'TaskCompleted' }, + }), + ).resolves.toBe(1); }); it('clears completedAt when a done task is reopened', async () => { @@ -442,6 +529,20 @@ describe('Data integrity (e2e)', () => { where: { id: template.id }, }); expect(refreshed.nextDueDate.getTime()).toBeGreaterThan(Date.now()); + // One TaskCreated carrying that due date, so the calendar entry and + // the webhook follow the generated task like any other. + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'Task', aggregateId: generated[0].id }, + }); + expect(events).toEqual([ + expect.objectContaining({ + type: 'TaskCreated', + actorUserId: null, + payload: expect.objectContaining({ + dueDate: generated[0].dueDate?.toISOString().slice(0, 10), + }) as unknown, + }), + ]); }); }); diff --git a/ApiCore-Backend/test/feedings.e2e-spec.ts b/ApiCore-Backend/test/feedings.e2e-spec.ts index 57b4796..ee2c2f3 100644 --- a/ApiCore-Backend/test/feedings.e2e-spec.ts +++ b/ApiCore-Backend/test/feedings.e2e-spec.ts @@ -16,6 +16,17 @@ interface FeedingSummaryEntry { totalAmount: number; } +interface BulkFeedingBody { + bulkId: string; + records: (FeedingBody & { + feedType: string; + amount: number; + unit: string; + batchNumber: string | null; + userId: string; + })[]; +} + describe('Feedings (e2e)', () => { let app: INestApplication; let prisma: PrismaService; @@ -71,6 +82,15 @@ describe('Feedings (e2e)', () => { }); afterAll(async () => { + const feedingIds = ( + await prisma.feeding.findMany({ + where: { colonyId }, + select: { id: true }, + }) + ).map((feeding) => feeding.id); + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } }, + }); await prisma.feeding.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); @@ -116,6 +136,48 @@ describe('Feedings (e2e)', () => { expect(feedingId).toEqual(expect.any(String)); }); + it('wrote the audit entry and FeedingRecorded together with the feeding', async () => { + expect( + await prisma.auditLog.count({ + where: { + entityType: 'Feeding', + entityId: feedingId, + action: 'CREATE', + userId: staffId, + }, + }), + ).toBe(1); + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'Feeding', aggregateId: feedingId }, + }); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + type: 'FeedingRecorded', + actorUserId: staffId, + payload: { colonyId }, + }), + ); + }); + + it('records FeedingCorrected on an edit', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/feedings/${feedingId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ notes: 'korrigiert' }) + .expect(200); + + const corrections = await prisma.domainEvent.findMany({ + where: { + aggregateType: 'Feeding', + aggregateId: feedingId, + type: 'FeedingCorrected', + }, + }); + expect(corrections).toHaveLength(1); + expect(corrections[0].payload).toEqual({ colonyId }); + }); + it('lists feedings for the colony', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/colonies/${colonyId}/feedings`) @@ -134,4 +196,128 @@ describe('Feedings (e2e)', () => { const litersEntry = summary.find((entry) => entry.unit === 'l'); expect(litersEntry?.totalAmount).toBe(2.5); }); + + describe('POST /feedings/bulk', () => { + const suffix = Date.now(); + const shared = { + date: '2026-09-01', + feedType: 'FEED_SYRUP', + product: 'Apiinvert', + amount: 10, + unit: 'kg', + batchNumber: 'L-2026-09', + }; + let bulkColonyIds: string[]; + let endedColonyId: string; + + const postBulk = (token: string, body: object) => + request(app.getHttpServer()) + .post('/api/v1/feedings/bulk') + .set('Authorization', `Bearer ${token}`) + .send(body); + + const storedFeedings = () => + prisma.feeding.count({ + where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } }, + }); + + beforeAll(async () => { + const colonyAt = async ( + code: string, + status: 'ACTIVE' | 'SOLD' = 'ACTIVE', + ) => + ( + await prisma.colony.create({ + data: { + code: `${code}-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + status, + }, + }) + ).id; + bulkColonyIds = [await colonyAt('FB-1'), await colonyAt('FB-2')]; + endedColonyId = await colonyAt('FB-X', 'SOLD'); + }); + + afterAll(async () => { + const colonyIds = [...bulkColonyIds, endedColonyId]; + const feedingIds = ( + await prisma.feeding.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((feeding) => feeding.id); + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } }, + }); + await prisma.feeding.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + }); + + it('bulk creates one feeding per colony with the shared fields', async () => { + const response = await postBulk(staffToken, { + ...shared, + colonyIds: bulkColonyIds, + }).expect(201); + + const { bulkId, records } = response.body as BulkFeedingBody; + expect(records.map((record) => record.colonyId)).toEqual(bulkColonyIds); + for (const record of records) { + expect(record).toEqual( + expect.objectContaining({ + feedType: 'FEED_SYRUP', + amount: 10, + unit: 'kg', + batchNumber: 'L-2026-09', + userId: staffId, + }), + ); + } + expect( + await prisma.domainEvent.count({ + where: { + type: 'FeedingRecorded', + payload: { path: ['bulkId'], equals: bulkId }, + }, + }), + ).toBe(2); + expect( + await prisma.auditLog.count({ + where: { + entityType: 'Feeding', + action: 'CREATE', + entityId: { in: records.map((record) => record.id) }, + }, + }), + ).toBe(2); + }); + + it('bulk rejects the whole batch when one colony has ended and names it', async () => { + const before = await storedFeedings(); + + const response = await postBulk(staffToken, { + ...shared, + colonyIds: [...bulkColonyIds, endedColonyId], + }).expect(400); + + expect( + (response.body as { invalidColonyIds: string[] }).invalidColonyIds, + ).toEqual([endedColonyId]); + expect(await storedFeedings()).toBe(before); + }); + + it('bulk rejects a duplicate colony', () => + postBulk(staffToken, { + ...shared, + colonyIds: [bulkColonyIds[0], bulkColonyIds[0]], + }).expect(400)); + + it('bulk rejects a role without feedings:create', () => + postBulk(readonlyToken, { ...shared, colonyIds: bulkColonyIds }).expect( + 403, + )); + }); }); diff --git a/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts b/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts index ef151f8..7c2d398 100644 --- a/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts +++ b/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts @@ -5,6 +5,7 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; interface RuleBody { id: string; @@ -22,6 +23,7 @@ describe('Follow-up controls (e2e)', () => { const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; let token: string; let apiaryId: string; + const colonyIds: string[] = []; const server = () => app.getHttpServer(); const auth = () => ({ Authorization: `Bearer ${token}` }); @@ -39,17 +41,21 @@ describe('Follow-up controls (e2e)', () => { } async function newColony(code: string) { - return prisma.colony.create({ + const colony = await prisma.colony.create({ data: { code: `${code}-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); + colonyIds.push(colony.id); + return colony; } - function treat(colonyId: string, body: Record = {}) { - return request(server()) + // The follow-up check comes from the TreatmentRecorded handler, not from + // the request: the helper drains the events of this treatment. + async function treat(colonyId: string, body: Record = {}) { + const response = await request(server()) .post('/api/v1/treatments') .set(auth()) .send({ @@ -59,6 +65,7 @@ describe('Follow-up controls (e2e)', () => { ...body, }) .expect(201); + await drainDomainEvents(app, [(response.body as { id: string }).id]); } beforeAll(async () => { @@ -88,6 +95,36 @@ describe('Follow-up controls (e2e)', () => { }, data: { active: false, config: {} }, }); + // Follow-up tasks are written without a user: remove their audit entry + // and TaskCreated (deliveries go with the event, ON DELETE CASCADE). + const followUpTaskIds = ( + await prisma.task.findMany({ + where: { relatedId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((task) => task.id); + await prisma.auditLog.deleteMany({ + where: { entityType: 'Task', entityId: { in: followUpTaskIds } }, + }); + const measureIds = [ + ...(await prisma.treatment.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + })), + ...(await prisma.feeding.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + })), + ].map((measure) => measure.id); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: measureIds } }, + }); + await prisma.auditLog.deleteMany({ + where: { entityId: { in: measureIds } }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Task', aggregateId: { in: followUpTaskIds } }, + }); await app.close(); }); @@ -150,7 +187,7 @@ describe('Follow-up controls (e2e)', () => { .expect(200); const colony = await newColony('FEED'); - await request(server()) + const response = await request(server()) .post('/api/v1/feedings') .set(auth()) .send({ @@ -161,6 +198,11 @@ describe('Follow-up controls (e2e)', () => { unit: 'l', }) .expect(201); + // Not before the FeedingRecorded handler ran. + expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe( + 0, + ); + await drainDomainEvents(app, [(response.body as { id: string }).id]); const task = await prisma.task.findFirstOrThrow({ where: { diff --git a/ApiCore-Backend/test/harvests.e2e-spec.ts b/ApiCore-Backend/test/harvests.e2e-spec.ts index 3bf3b65..8ee43f8 100644 --- a/ApiCore-Backend/test/harvests.e2e-spec.ts +++ b/ApiCore-Backend/test/harvests.e2e-spec.ts @@ -5,6 +5,7 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; interface HarvestBody { id: string; @@ -26,6 +27,7 @@ describe('Harvests (e2e)', () => { let colonyOneId: string; let colonyTwoId: string; let harvestId: string; + let subscriptionId: string; beforeAll(async () => { app = await createTestApp(); @@ -76,7 +78,16 @@ describe('Harvests (e2e)', () => { }); afterAll(async () => { + if (subscriptionId) { + // Its deliveries go with it, ON DELETE CASCADE. + await prisma.webhookSubscription.deleteMany({ + where: { id: subscriptionId }, + }); + } if (harvestId) { + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Harvest', aggregateId: harvestId }, + }); await prisma.harvestColony.deleteMany({ where: { harvestId } }); await prisma.harvest.deleteMany({ where: { id: harvestId } }); } @@ -124,6 +135,60 @@ describe('Harvests (e2e)', () => { expect(harvest.colonies).toHaveLength(2); }); + it('wrote the audit entry and HarvestRecorded with the colony ids together with the harvest', async () => { + expect( + await prisma.auditLog.count({ + where: { + entityType: 'Harvest', + entityId: harvestId, + action: 'CREATE', + userId: staffId, + }, + }), + ).toBe(1); + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'Harvest', aggregateId: harvestId }, + }); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('HarvestRecorded'); + expect(events[0].actorUserId).toBe(staffId); + const payload = events[0].payload as { + apiaryId: string; + colonyIds: string[]; + }; + expect(payload.apiaryId).toBe(apiaryId); + expect([...payload.colonyIds].sort()).toEqual( + [colonyOneId, colonyTwoId].sort(), + ); + }); + + it('queues harvest.created from the event handler, once', async () => { + subscriptionId = ( + await prisma.webhookSubscription.create({ + data: { + url: 'https://hooks.example.com/harvests-e2e', + events: ['harvest.created'], + secret: `harvests-e2e-${Date.now()}`, + createdByUserId: staffId, + }, + }) + ).id; + // The request itself queued nothing. + expect( + await prisma.webhookDelivery.count({ where: { subscriptionId } }), + ).toBe(0); + + await drainDomainEvents(app, [harvestId]); + await drainDomainEvents(app, [harvestId]); + + const deliveries = await prisma.webhookDelivery.findMany({ + where: { subscriptionId }, + }); + expect(deliveries).toHaveLength(1); + expect(deliveries[0].event).toBe('harvest.created'); + expect(deliveries[0].body).toContain(harvestId); + }); + it('returns the harvest with its colonies on lookup', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/harvests/${harvestId}`) @@ -135,4 +200,25 @@ describe('Harvests (e2e)', () => { expect.arrayContaining([colonyOneId, colonyTwoId]), ); }); + + it('records HarvestCorrected with the new colony set on an edit', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/harvests/${harvestId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ colonyIds: [colonyOneId] }) + .expect(200); + + const corrections = await prisma.domainEvent.findMany({ + where: { + aggregateType: 'Harvest', + aggregateId: harvestId, + type: 'HarvestCorrected', + }, + }); + expect(corrections).toHaveLength(1); + expect(corrections[0].payload).toEqual({ + apiaryId, + colonyIds: [colonyOneId], + }); + }); }); diff --git a/ApiCore-Backend/test/inspections.e2e-spec.ts b/ApiCore-Backend/test/inspections.e2e-spec.ts index 1717593..d9d18d6 100644 --- a/ApiCore-Backend/test/inspections.e2e-spec.ts +++ b/ApiCore-Backend/test/inspections.e2e-spec.ts @@ -5,6 +5,7 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; interface InspectionBody { id: string; @@ -74,8 +75,27 @@ describe('Inspections (e2e)', () => { afterAll(async () => { if (taskId) { + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Task', aggregateId: taskId }, + }); await prisma.task.delete({ where: { id: taskId } }); } + const inspectionIds = ( + await prisma.inspection.findMany({ + where: { colonyId }, + select: { id: true }, + }) + ).map((inspection) => inspection.id); + // Deliveries go with their event (ON DELETE CASCADE). + await prisma.domainEvent.deleteMany({ + where: { + aggregateType: 'Inspection', + aggregateId: { in: inspectionIds }, + }, + }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } }, + }); await prisma.inspection.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); @@ -122,6 +142,61 @@ describe('Inspections (e2e)', () => { }, }); expect(auditEntry).not.toBeNull(); + + // Committed with the inspection; rules, calendar and webhook follow + // from it once the handlers run - nothing of that in the request. + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'Inspection', aggregateId: inspectionId }, + }); + expect(events).toEqual([ + expect.objectContaining({ + type: 'InspectionRecorded', + actorUserId: staffId, + payload: { colonyId }, + status: 'PENDING', + }), + ]); + }); + + it('records InspectionCorrected for an edit, whose only follow-up is the calendar', async () => { + await drainDomainEvents(app, [inspectionId]); + + await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${inspectionId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ notes: 'Nachtrag: Weiselzelle gesehen' }) + .expect(200); + + await expect( + prisma.auditLog.count({ + where: { + entityType: 'Inspection', + entityId: inspectionId, + action: 'UPDATE', + userId: staffId, + }, + }), + ).resolves.toBe(1); + const correction = await prisma.domainEvent.findFirstOrThrow({ + where: { aggregateId: inspectionId, type: 'InspectionCorrected' }, + }); + expect(correction).toMatchObject({ + aggregateType: 'Inspection', + actorUserId: staffId, + payload: { colonyId }, + }); + + await drainDomainEvents(app, [inspectionId]); + // A correction evaluates no automation rule and sends no webhook. + const deliveries = await prisma.domainEventDelivery.findMany({ + where: { eventId: correction.id }, + }); + expect(deliveries).toEqual([ + expect.objectContaining({ + handler: 'calendar-sync.inspection', + status: 'DONE', + }), + ]); }); it('lists inspections for the colony', async () => { @@ -142,6 +217,28 @@ describe('Inspections (e2e)', () => { const task = response.body as TaskBody; taskId = task.id; expect(task.sourceInspectionId).toBe(inspectionId); + + // The same trail as a task from the task page. + await expect( + prisma.auditLog.count({ + where: { + entityType: 'Task', + entityId: taskId, + action: 'CREATE', + userId: staffId, + }, + }), + ).resolves.toBe(1); + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'Task', aggregateId: taskId }, + }); + expect(events).toEqual([ + expect.objectContaining({ + type: 'TaskCreated', + actorUserId: staffId, + payload: { relatedType: 'COLONY', relatedId: colonyId, dueDate: null }, + }), + ]); }); it('stores, clears and validates broodFrames', async () => { diff --git a/ApiCore-Backend/test/jest-e2e.json b/ApiCore-Backend/test/jest-e2e.json index 6460e3e..517dff8 100644 --- a/ApiCore-Backend/test/jest-e2e.json +++ b/ApiCore-Backend/test/jest-e2e.json @@ -9,6 +9,6 @@ "^.+\\.(t|j)s$": "ts-jest" }, "transformIgnorePatterns": [ - "node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron|croner|uuid)/)" + "node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron|croner|uuid)/)" ] } diff --git a/ApiCore-Backend/test/jobs.e2e-spec.ts b/ApiCore-Backend/test/jobs.e2e-spec.ts new file mode 100644 index 0000000..26e0bf9 --- /dev/null +++ b/ApiCore-Backend/test/jobs.e2e-spec.ts @@ -0,0 +1,415 @@ +import { INestApplicationContext } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job'; +import { JOB_ABORTED_ERROR } from '../src/system/job-run.service'; +import { JobsModule } from '../src/jobs/jobs.module'; +import { runCli } from '../src/jobs/run-cli'; +import { PrismaService } from '../src/prisma/prisma.service'; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +const ago = (ms: number): Date => new Date(Date.now() - ms); + +/** + * The jobs run like `node dist/cli.js ` does: runCli with the real + * JobsModule context (or, for the failure case, the same module with one + * job replaced). Rows are tagged with this file's suffix and removed in + * afterAll - the test database is shared by parallel e2e files. + */ +describe('Jobs (e2e)', () => { + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const testStart = new Date(Date.now() - 1000); + let prisma: PrismaService; + const jobRunIds: string[] = []; + const taskIds: string[] = []; + let templateId: string | undefined; + let subscriptionId: string | undefined; + let adminId: string | undefined; + + const runsSinceStart = (job: string) => + prisma.jobRun.findMany({ + where: { job, startedAt: { gte: testStart } }, + orderBy: { startedAt: 'asc' }, + }); + + beforeAll(async () => { + prisma = new PrismaService(); + await prisma.$connect(); + }); + + afterAll(async () => { + const runs = await prisma.jobRun.findMany({ + where: { startedAt: { gte: testStart } }, + select: { id: true }, + }); + const runIds = [...jobRunIds, ...runs.map((run) => run.id)]; + await prisma.notification.deleteMany({ + where: { relatedType: 'JobRun', relatedId: { in: runIds } }, + }); + await prisma.jobRun.deleteMany({ where: { id: { in: runIds } } }); + if (templateId) { + const generated = await prisma.task.findMany({ + where: { sourceTemplateId: templateId }, + select: { id: true }, + }); + taskIds.push(...generated.map((task) => task.id)); + await prisma.auditLog.deleteMany({ + where: { entityType: 'Task', entityId: { in: taskIds } }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Task', aggregateId: { in: taskIds } }, + }); + await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); + await prisma.taskTemplate.deleteMany({ where: { id: templateId } }); + } + await prisma.domainEvent.deleteMany({ + where: { aggregateId: `jobs-e2e-${suffix}` }, + }); + if (subscriptionId) { + await prisma.webhookSubscription.deleteMany({ + where: { id: subscriptionId }, + }); + } + await prisma.throttlerHit.deleteMany({ + where: { key: { startsWith: `jobs-e2e-${suffix}` } }, + }); + await prisma.idempotencyRecord.deleteMany({ + where: { key: { startsWith: `jobs-e2e-${suffix}` } }, + }); + await prisma.consumedToken.deleteMany({ + where: { jti: { startsWith: `jobs-e2e-${suffix}` } }, + }); + if (adminId) { + await prisma.notification.deleteMany({ where: { userId: adminId } }); + await prisma.user.deleteMany({ where: { id: adminId } }); + } + await prisma.$disconnect(); + }); + + describe('cleanup', () => { + const tag = (name: string): string => `jobs-e2e-${suffix}-${name}`; + const ids: Record = {}; + + beforeAll(async () => { + const event = async ( + name: string, + status: 'DONE' | 'FAILED' | 'PENDING', + age: number, + ) => { + const created = await prisma.domainEvent.create({ + data: { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: `jobs-e2e-${suffix}`, + payload: {}, + correlationId: tag(name), + status, + occurredAt: ago(age), + }, + }); + ids[`event:${name}`] = created.id; + }; + await event('done-old', 'DONE', 31 * DAY_MS); + await event('done-recent', 'DONE', 29 * DAY_MS); + await event('failed-old', 'FAILED', 91 * DAY_MS); + await event('failed-recent', 'FAILED', 89 * DAY_MS); + await event('pending-old', 'PENDING', 91 * DAY_MS); + + const subscription = await prisma.webhookSubscription.create({ + data: { + url: 'https://receiver.example.com/hook', + events: ['task.created'], + secret: 'not-used', + active: false, + }, + }); + subscriptionId = subscription.id; + const delivery = async ( + name: string, + status: 'DELIVERED' | 'FAILED' | 'PENDING', + age: number, + ) => { + const created = await prisma.webhookDelivery.create({ + data: { + subscriptionId: subscription.id, + event: 'task.created', + body: '{}', + status, + createdAt: ago(age), + }, + }); + ids[`delivery:${name}`] = created.id; + }; + await delivery('delivered-old', 'DELIVERED', 31 * DAY_MS); + await delivery('delivered-recent', 'DELIVERED', 29 * DAY_MS); + await delivery('failed-old', 'FAILED', 91 * DAY_MS); + await delivery('failed-recent', 'FAILED', 89 * DAY_MS); + await delivery('pending-old', 'PENDING', 91 * DAY_MS); + + // "rekey" notifies holders of system:manage; cleanup sends that for + // the stale run, the afterAll removes it by relatedId. + const run = async ( + name: string, + status: 'RUNNING' | 'SUCCEEDED' | 'FAILED', + age: number, + ) => { + const created = await prisma.jobRun.create({ + data: { job: 'rekey', status, startedAt: ago(age) }, + }); + ids[`run:${name}`] = created.id; + jobRunIds.push(created.id); + }; + await run('succeeded-old', 'SUCCEEDED', 91 * DAY_MS); + await run('failed-recent', 'FAILED', 89 * DAY_MS); + await run('running-stale', 'RUNNING', 7 * HOUR_MS); + await run('running-fresh', 'RUNNING', 5 * HOUR_MS); + + await prisma.throttlerHit.createMany({ + data: [ + { + key: tag('expired'), + throttlerName: 'default', + hits: 3, + expiresAt: ago(60_000), + }, + { + key: tag('blocked'), + throttlerName: 'default', + hits: 11, + expiresAt: ago(60_000), + blockedUntil: ago(-HOUR_MS), + }, + { + key: tag('block-ended'), + throttlerName: 'default', + hits: 11, + expiresAt: ago(2 * 60_000), + blockedUntil: ago(60_000), + }, + { + key: tag('open-window'), + throttlerName: 'default', + hits: 1, + expiresAt: ago(-60_000), + }, + ], + }); + + await prisma.idempotencyRecord.createMany({ + data: [ + { + userId: tag('user'), + key: tag('old'), + scope: 'POST /api/v1/tasks', + createdAt: ago(91 * DAY_MS), + }, + { + userId: tag('user'), + key: tag('recent'), + scope: 'POST /api/v1/tasks', + createdAt: ago(89 * DAY_MS), + }, + ], + }); + await prisma.consumedToken.createMany({ + data: [ + { jti: tag('expired'), expiresAt: ago(2 * HOUR_MS) }, + // Expired, but within the 1 h margin for api pods' clocks. + { jti: tag('in-grace'), expiresAt: ago(30 * 60_000) }, + { jti: tag('valid'), expiresAt: ago(-HOUR_MS) }, + ], + }); + }); + + it('node dist/cli.js cleanup equivalent: runCli(["cleanup"]) writes a SUCCEEDED JobRun with counts', async () => { + expect(await runCli(['cleanup'])).toBe(0); + + const [run, ...others] = await runsSinceStart('cleanup'); + expect(others).toHaveLength(0); + expect(run.status).toBe('SUCCEEDED'); + expect(run.finishedAt).toEqual(expect.any(Date)); + expect(run.error).toBeNull(); + const result = run.result as Record; + // Other e2e files share the database: at least this file's rows. + expect(result.idempotencyRecords).toBeGreaterThanOrEqual(1); + expect(result.consumedTokens).toBeGreaterThanOrEqual(1); + expect(result.throttlerHits).toBeGreaterThanOrEqual(2); + expect(result['domainEvents.done']).toBeGreaterThanOrEqual(1); + expect(result['domainEvents.failed']).toBeGreaterThanOrEqual(1); + expect(result['webhookDeliveries.delivered']).toBeGreaterThanOrEqual(1); + expect(result['webhookDeliveries.failed']).toBeGreaterThanOrEqual(1); + expect(result['jobRuns.aborted']).toBeGreaterThanOrEqual(1); + expect(result['jobRuns.deleted']).toBeGreaterThanOrEqual(1); + }); + + it('removed exactly the expired rows', async () => { + const remainingEvents = await prisma.domainEvent.findMany({ + where: { aggregateId: `jobs-e2e-${suffix}` }, + select: { correlationId: true }, + }); + expect(remainingEvents.map((e) => e.correlationId).sort()).toEqual( + [tag('done-recent'), tag('failed-recent'), tag('pending-old')].sort(), + ); + + const remainingDeliveries = await prisma.webhookDelivery.findMany({ + where: { subscriptionId }, + select: { id: true }, + }); + expect(remainingDeliveries.map((d) => d.id).sort()).toEqual( + [ + ids['delivery:delivered-recent'], + ids['delivery:failed-recent'], + ids['delivery:pending-old'], + ].sort(), + ); + + const remainingHits = await prisma.throttlerHit.findMany({ + where: { key: { startsWith: `jobs-e2e-${suffix}` } }, + select: { key: true }, + }); + expect(remainingHits.map((h) => h.key).sort()).toEqual( + [tag('blocked'), tag('open-window')].sort(), + ); + + const remainingRecords = await prisma.idempotencyRecord.findMany({ + where: { userId: tag('user') }, + select: { key: true }, + }); + expect(remainingRecords.map((r) => r.key)).toEqual([tag('recent')]); + const remainingTokens = await prisma.consumedToken.findMany({ + where: { jti: { startsWith: `jobs-e2e-${suffix}` } }, + select: { jti: true }, + }); + expect(remainingTokens.map((t) => t.jti).sort()).toEqual( + [tag('in-grace'), tag('valid')].sort(), + ); + }); + + it('marks runs older than 6 h that never finished as aborted and deletes runs older than 90 days', async () => { + const runs = await prisma.jobRun.findMany({ + where: { + id: { + in: [ + ids['run:succeeded-old'], + ids['run:failed-recent'], + ids['run:running-stale'], + ids['run:running-fresh'], + ], + }, + }, + }); + const byId = new Map(runs.map((run) => [run.id, run])); + + expect(byId.has(ids['run:succeeded-old'])).toBe(false); + expect(byId.get(ids['run:failed-recent'])?.status).toBe('FAILED'); + expect(byId.get(ids['run:running-stale'])).toMatchObject({ + status: 'FAILED', + error: JOB_ABORTED_ERROR, + finishedAt: expect.any(Date) as Date, + }); + expect(byId.get(ids['run:running-fresh'])).toMatchObject({ + status: 'RUNNING', + finishedAt: null, + }); + }); + }); + + describe('task-templates', () => { + it('task-templates creates the due task once even when run twice', async () => { + const template = await prisma.taskTemplate.create({ + data: { + title: `Jobs e2e ${suffix}`, + recurrenceType: 'INTERVAL_DAYS', + intervalDays: 7, + nextDueDate: ago(DAY_MS), + }, + }); + templateId = template.id; + + expect(await runCli(['task-templates'])).toBe(0); + expect(await runCli(['task-templates'])).toBe(0); + + const generated = await prisma.task.findMany({ + where: { sourceTemplateId: template.id }, + }); + expect(generated).toHaveLength(1); + expect(generated[0].createdByUserId).toBeNull(); + const advanced = await prisma.taskTemplate.findUniqueOrThrow({ + where: { id: template.id }, + }); + expect(advanced.nextDueDate.getTime()).toBeGreaterThan(Date.now()); + + const runs = await runsSinceStart('task-templates'); + expect(runs.map((run) => run.status)).toEqual(['SUCCEEDED', 'SUCCEEDED']); + for (const run of runs) { + expect(run.result).toEqual({ created: expect.any(Number) as number }); + } + }); + }); + + describe('a failing job', () => { + // The same module as dist/cli.js, with the daily evaluation replaced by + // a job that always fails. + const failingContext = async (): Promise => { + const moduleRef = await Test.createTestingModule({ + imports: [JobsModule], + }) + .overrideProvider(DailyEvaluationJob) + .useValue({ + name: 'daily-evaluation', + run: () => Promise.reject(new Error('weather service unreachable')), + }) + .compile(); + return moduleRef.init(); + }; + + beforeAll(async () => { + const admin = await prisma.user.create({ + data: { + email: `jobs-e2e-admin-${suffix}@example.com`, + passwordHash: 'not-used', + name: 'Jobs Admin', + role: { connect: { key: 'ADMIN' } }, + }, + }); + adminId = admin.id; + // The dedupe is per job and UTC day across the whole database: an + // earlier failure notification of today (another run of this file, + // a crashed run) would suppress the one this test expects. + await prisma.$executeRaw` + DELETE FROM "Notification" n + USING "JobRun" r + WHERE r."id" = n."relatedId" + AND n."relatedType" = 'JobRun' + AND r."job" = 'daily-evaluation' + AND n."createdAt" >= date_trunc('day', now())`; + }); + + it('exits 1, stores the error and notifies permission holders once per day', async () => { + expect(await runCli(['daily-evaluation'], failingContext)).toBe(1); + expect(await runCli(['daily-evaluation'], failingContext)).toBe(1); + + const runs = await runsSinceStart('daily-evaluation'); + expect(runs).toHaveLength(2); + for (const run of runs) { + expect(run).toMatchObject({ + status: 'FAILED', + error: 'weather service unreachable', + result: null, + }); + } + const notifications = await prisma.notification.findMany({ + where: { userId: adminId, relatedType: 'JobRun' }, + }); + expect(notifications).toEqual([ + expect.objectContaining({ + priority: 'WARNUNG', + message: 'Job „Tägliche Auswertung“ fehlgeschlagen', + relatedId: runs[0].id, + read: false, + }), + ]); + }); + }); +}); diff --git a/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts b/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts index 070ad48..c7d2867 100644 --- a/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts +++ b/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts @@ -1,4 +1,4 @@ -import { INestApplication } from '@nestjs/common'; +import { INestApplication, Logger } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; @@ -6,14 +6,18 @@ import { PrismaService } from '../src/prisma/prisma.service'; import { NotificationsService } from '../src/notifications/notifications.service'; import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service'; import { AutomationService } from '../src/automation/automation.service'; +import { AutomationTriggerType, Prisma } from '../generated/prisma'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; /** - * Side effects that follow a committed write (automation rules, calendar - * sync, webhooks) must never turn that write into an error response: the - * offline queue would send the request again and store the record twice - - * for treatments that is a duplicate entry in the Bestandsbuch. + * Side effects of a committed write (automation rules, calendar sync, + * webhooks) must never turn that write into an error response: the offline + * queue would send the request again and store the record twice - for + * treatments that is a duplicate entry in the Bestandsbuch. Producers that + * record a domain event run none of them in the request; the handlers do, + * and a failed handler is retried without repeating what already worked. */ describe('Post-commit side effects (e2e)', () => { let app: INestApplication; @@ -22,32 +26,121 @@ describe('Post-commit side effects (e2e)', () => { const email = `post-commit-e2e-${suffix}@example.com`; const password = 'SuperSecret123!'; let token: string; + let userId: string; let apiaryId: string; + const colonyIds: string[] = []; + const stockItemIds: string[] = []; const server = () => app.getHttpServer(); const auth = () => ({ Authorization: `Bearer ${token}` }); + function post(path: string, body: object, idempotencyKey?: string) { + const call = request(server()).post(path).set(auth()); + return ( + idempotencyKey === undefined + ? call + : call.set('Idempotency-Key', idempotencyKey) + ).send(body); + } + async function createColony(code: string) { - return prisma.colony.create({ + const colony = await prisma.colony.create({ data: { code: `${code}-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); + colonyIds.push(colony.id); + return colony; + } + + /** Open or not: a rule task that exists at all. */ + const taskCount = (trigger: AutomationTriggerType, relatedId: string) => + prisma.task.count({ + where: { sourceAutomationTrigger: trigger, relatedId }, + }); + + const openTaskCount = (trigger: AutomationTriggerType, relatedId: string) => + prisma.task.count({ + where: { + sourceAutomationTrigger: trigger, + relatedId, + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + }); + + /** Everything this file wrote, scoped by its own ids. */ + async function cleanUp(): Promise { + const byColony = { + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }; + const recordIds = ( + await Promise.all([ + prisma.inspection.findMany(byColony), + prisma.treatment.findMany(byColony), + prisma.varroaMeasurement.findMany(byColony), + ]) + ) + .flat() + .map((record) => record.id); + const relatedIds = [...colonyIds, ...stockItemIds]; + const taskIds = ( + await prisma.task.findMany({ + where: { relatedId: { in: relatedIds } }, + select: { id: true }, + }) + ).map((task) => task.id); + const aggregateIds = [...recordIds, ...taskIds]; + // Deliveries go with their event (ON DELETE CASCADE). + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: aggregateIds } }, + }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceId: { in: aggregateIds } }, + }); + // Rule tasks and the rules' own entries are written without a user. + await prisma.auditLog.deleteMany({ + where: { + OR: [ + { userId }, + { entityType: 'Task', entityId: { in: taskIds } }, + ...relatedIds.map((relatedId) => ({ + entityType: 'AutomationRule', + afterData: { path: ['relatedId'], equals: relatedId }, + })), + ], + }, + }); + await prisma.notification.deleteMany({ + where: { relatedId: { in: relatedIds } }, + }); + await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); + await prisma.inspection.deleteMany({ where: byColony.where }); + await prisma.treatment.deleteMany({ where: byColony.where }); + await prisma.varroaMeasurement.deleteMany({ where: byColony.where }); + await prisma.stockItem.deleteMany({ where: { id: { in: stockItemIds } } }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.idempotencyRecord.deleteMany({ where: { userId } }); + await prisma.refreshToken.deleteMany({ where: { userId } }); + await prisma.user.deleteMany({ where: { id: userId } }); } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); - await prisma.user.create({ - data: { - email, - passwordHash: await bcrypt.hash(password, 12), - name: 'Post Commit', - role: { connect: { key: 'ADMIN' } }, - }, - }); + userId = ( + await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: 'Post Commit', + role: { connect: { key: 'ADMIN' } }, + }, + }) + ).id; token = (await login(app, email, password)).accessToken; apiaryId = ( await prisma.apiary.create({ data: { name: `Post Commit ${suffix}` } }) @@ -59,12 +152,66 @@ describe('Post-commit side effects (e2e)', () => { }); afterAll(async () => { + await cleanUp(); await app.close(); }); describe('when a side effect fails', () => { + let errorLog: jest.SpyInstance; + + /** The error lines the automation's legacy path logged for one record. */ + const automationFailuresFor = (entityType: string, entityId: string) => + errorLog.mock.calls + .map(([message]) => String(message)) + .filter((message) => + message.startsWith( + `Automation rule evaluation failed for ${entityType} ${entityId}:`, + ), + ); + + /** + * Every transaction still runs for real, but inside it the seeding of + * the automation rules (ensureDefaults → tx.automationRule.createMany) + * fails: the first write of every rule evaluation. A rule evaluated + * inside the request's own transaction would turn it into a 500. + */ + function failRuleSeedingInTransactions(): void { + type Work = (tx: Prisma.TransactionClient) => Promise; + const runTransaction = prisma.$transaction.bind(prisma) as ( + work: Work, + options?: unknown, + ) => Promise; + const failingRules = (rules: object) => + new Proxy(rules, { + get: (target, property): unknown => + property === 'createMany' + ? () => Promise.reject(new Error('automation rules down')) + : Reflect.get(target, property), + }); + const withFailingRuleSeeding = (tx: Prisma.TransactionClient) => + new Proxy(tx, { + get: (target, property): unknown => + property === 'automationRule' + ? failingRules(target.automationRule) + : Reflect.get(target, property), + }); + jest + .spyOn(prisma, '$transaction') + .mockImplementation((work: unknown, options?: unknown) => + typeof work === 'function' + ? runTransaction( + (tx) => (work as Work)(withFailingRuleSeeding(tx)), + options, + ) + : runTransaction(work as Work, options), + ); + } + beforeEach(() => { - // Automation: the task is created, then the warning fails. + errorLog = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + // Automation: the warning cannot be written. jest .spyOn(app.get(NotificationsService), 'record') .mockRejectedValue(new Error('notifications down')); @@ -78,7 +225,7 @@ describe('Post-commit side effects (e2e)', () => { .mockRejectedValue(new Error('webhook subscriptions down')); }); - it('still answers 201 for an inspection and replays it on a retry', async () => { + it('still answers 201 and replays on retry with the same Idempotency-Key (no side effect in the request)', async () => { const colony = await createColony('PC-INSP'); const key = `post-commit-${suffix}-inspection`; const body = { @@ -88,40 +235,36 @@ describe('Post-commit side effects (e2e)', () => { eggsPresent: false, }; - const first = await request(server()) - .post('/api/v1/inspections') - .set(auth()) - .set('Idempotency-Key', key) - .send(body) - .expect(201); - const retry = await request(server()) - .post('/api/v1/inspections') - .set(auth()) - .set('Idempotency-Key', key) - .send(body) - .expect(201); + const first = await post('/api/v1/inspections', body, key).expect(201); + const retry = await post('/api/v1/inspections', body, key).expect(201); - expect((retry.body as { id: string }).id).toBe( - (first.body as { id: string }).id, - ); + const inspectionId = (first.body as { id: string }).id; + expect((retry.body as { id: string }).id).toBe(inspectionId); expect( await prisma.inspection.count({ where: { colonyId: colony.id } }), ).toBe(1); - // The rule still did its main job before the warning failed. - expect( - await prisma.task.count({ - where: { - relatedId: colony.id, - sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', - }, + // The request only recorded its event - the replay not even that. + const events = await prisma.domainEvent.findMany({ + where: { aggregateId: inspectionId }, + }); + expect(events).toEqual([ + expect.objectContaining({ + type: 'InspectionRecorded', + status: 'PENDING', + attempts: 0, }), - ).toBe(1); + ]); + // No rule ran in the request, not even one that failed: the task + // follows when the handler runs. + expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]); + expect(await taskCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0); + expect( + await prisma.notification.count({ where: { relatedId: colony.id } }), + ).toBe(0); }); - it('still answers 201 for a treatment when the rule evaluation fails', async () => { - jest - .spyOn(prisma.automationRule, 'upsert') - .mockRejectedValue(new Error('automation rules down')); + it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => { + failRuleSeedingInTransactions(); const colony = await createColony('PC-TREAT'); const key = `post-commit-${suffix}-treatment`; const body = { @@ -130,18 +273,8 @@ describe('Post-commit side effects (e2e)', () => { product: 'Oxalsäure', }; - const first = await request(server()) - .post('/api/v1/treatments') - .set(auth()) - .set('Idempotency-Key', key) - .send(body) - .expect(201); - const retry = await request(server()) - .post('/api/v1/treatments') - .set(auth()) - .set('Idempotency-Key', key) - .send(body) - .expect(201); + const first = await post('/api/v1/treatments', body, key).expect(201); + const retry = await post('/api/v1/treatments', body, key).expect(201); expect((retry.body as { id: string }).id).toBe( (first.body as { id: string }).id, @@ -152,8 +285,56 @@ describe('Post-commit side effects (e2e)', () => { }); }); + describe('when an automation handler fails', () => { + it('creates exactly one automation task when the handler fails once and is retried', async () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + const colonyId = (await createColony('PC-RETRY')).id; + jest + .spyOn(app.get(NotificationsService), 'record') + .mockRejectedValueOnce(new Error('notifications down')); + + const { body } = (await post('/api/v1/inspections', { + colonyId, + date: '2026-07-01T00:00:00.000Z', + queenSeen: false, + eggsPresent: false, + }).expect(201)) as { body: { id: string } }; + + await drainDomainEvents(app, [body.id]); + // The warning failed, so the whole evaluation rolled back. + expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0); + expect( + await prisma.notification.count({ where: { relatedId: colonyId } }), + ).toBe(0); + + // Due again now instead of after the 30 s backoff. + await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`; + await drainDomainEvents(app, [body.id]); + expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1); + expect( + await prisma.notification.count({ where: { relatedId: colonyId } }), + ).toBe(1); + + const deliveries = await prisma.domainEventDelivery.findMany({ + where: { event: { aggregateId: body.id } }, + }); + expect( + deliveries.find((d) => d.handler === 'automation.inspection-rules'), + ).toMatchObject({ status: 'DONE', attempts: 2 }); + // What already worked did not run again. + expect( + deliveries.find((d) => d.handler === 'webhooks.forward'), + ).toMatchObject({ status: 'DONE', attempts: 1 }); + await expect( + prisma.domainEvent.findFirstOrThrow({ + where: { aggregateId: body.id }, + }), + ).resolves.toMatchObject({ status: 'DONE', attempts: 1 }); + }); + }); + describe('automation tasks', () => { - it('leaves one open task when several triggers for the same colony arrive at once', async () => { + it('leaves one open reorder task when several triggers for the same stock item arrive at once', async () => { const automation = app.get(AutomationService); // The same object crossing the threshold on several pods/requests at // once: every evaluation sees "no open task yet" unless checking and @@ -167,24 +348,17 @@ describe('Post-commit side effects (e2e)', () => { minThreshold: 5, }, }); + stockItemIds.push(item.id); await Promise.all( Array.from({ length: 8 }, () => automation.evaluateStockItem(item)), ); - expect( - await prisma.task.count({ - where: { - relatedId: item.id, - sourceAutomationTrigger: 'STOCK_ITEM_LOW', - status: { in: ['OPEN', 'IN_PROGRESS'] }, - }, - }), - ).toBe(1); + expect(await openTaskCount('STOCK_ITEM_LOW', item.id)).toBe(1); }); - it('leaves one open task when several measurements for the same colony arrive at once', async () => { - const colony = await createColony('PC-RACE-HTTP'); + it('leaves one open task when several triggers for the same colony arrive at once', async () => { + const colony = await createColony('PC-RACE'); const measurement = { colonyId: colony.id, date: '2026-08-15T00:00:00.000Z', @@ -194,25 +368,19 @@ describe('Post-commit side effects (e2e)', () => { durationMinutes: 24 * 60, }; - await Promise.all( + const responses = await Promise.all( Array.from({ length: 6 }, () => - request(server()) - .post('/api/v1/varroa-measurements') - .set(auth()) - .send(measurement) - .expect(201), + post('/api/v1/varroa-measurements', measurement).expect(201), + ), + ); + // One worker each: the handlers evaluate the rule at the same moment. + await Promise.all( + responses.map(({ body }) => + drainDomainEvents(app, [(body as { id: string }).id]), ), ); - expect( - await prisma.task.count({ - where: { - relatedId: colony.id, - sourceAutomationTrigger: 'VARROA_THRESHOLD', - status: { in: ['OPEN', 'IN_PROGRESS'] }, - }, - }), - ).toBe(1); + expect(await openTaskCount('VARROA_THRESHOLD', colony.id)).toBe(1); }); }); }); diff --git a/ApiCore-Backend/test/raw-materials.e2e-spec.ts b/ApiCore-Backend/test/raw-materials.e2e-spec.ts index 9a034c0..ae41aa1 100644 --- a/ApiCore-Backend/test/raw-materials.e2e-spec.ts +++ b/ApiCore-Backend/test/raw-materials.e2e-spec.ts @@ -2,9 +2,11 @@ import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; +import { Prisma } from '../generated/prisma'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; interface StockBody { summary: { @@ -13,7 +15,12 @@ interface StockBody { consumed: number; remaining: number; }; - movements: { type: string; quantity: number; notes: string | null }[]; + movements: { + id: string; + type: string; + quantity: number; + notes: string | null; + }[]; } interface LotBody { @@ -27,6 +34,13 @@ interface BatchBody { inputs: { quantity: number; rawMaterialLot: { id: string } }[]; } +/** A shared rule's config as it was before a test changed it. */ +function restoredConfig(rule: { + config: Prisma.JsonValue; +}): Prisma.InputJsonValue | typeof Prisma.DbNull { + return rule.config === null ? Prisma.DbNull : rule.config; +} + /** * Raw material lots (wax, propolis) and their consumption in production: * creating a production batch books the inputs off the raw lots in the same @@ -37,17 +51,29 @@ describe('Raw materials and production (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const startedAt = new Date(); const password = 'SuperSecret123!'; let token: string; + let userId: string; let waxId: string; let propolisId: string; let productId: string; let batchId: string; + // Tasks the RAW_MATERIAL_LOW handler created for this file. + const ruleTaskIds: string[] = []; const server = () => app.getHttpServer(); const auth = (req: request.Test) => req.set('Authorization', `Bearer ${token}`); + /** The StockChanged events recorded for one movement or batch. */ + function stockChangedOf(aggregateId: string) { + return prisma.domainEvent.findMany({ + where: { type: 'StockChanged', aggregateId }, + select: { aggregateType: true, payload: true, actorUserId: true }, + }); + } + async function stockOf(lotId: string): Promise { const response = await auth( request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`), @@ -58,7 +84,7 @@ describe('Raw materials and production (e2e)', () => { beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); - await prisma.user.create({ + const user = await prisma.user.create({ data: { email: `raw-beekeeper-${suffix}@example.com`, passwordHash: await bcrypt.hash(password, 12), @@ -66,6 +92,7 @@ describe('Raw materials and production (e2e)', () => { role: { connect: { key: 'BEEKEEPER' } }, }, }); + userId = user.id; token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password)) .accessToken; @@ -96,6 +123,53 @@ describe('Raw materials and production (e2e)', () => { }); afterAll(async () => { + // Only this file's rows, by its own ids - other e2e files share the + // database. An undefined id would drop a filter, hence the guards. + const lotIds = [waxId, propolisId].filter(Boolean); + const movementIds = ( + await prisma.batchMovement.findMany({ + where: { rawMaterialLotId: { in: lotIds } }, + select: { id: true }, + }) + ).map((movement) => movement.id); + const batchIds = [batchId].filter(Boolean); + // Rule tasks are written without a user: their TaskCreated, audit + // entries, warning and queued deliveries go by task id. + for (const taskId of ruleTaskIds) { + await prisma.auditLog.deleteMany({ + where: { + entityType: 'AutomationRule', + afterData: { path: ['taskId'], equals: taskId }, + }, + }); + await prisma.webhookDelivery.deleteMany({ + where: { event: 'task.created', body: { contains: taskId } }, + }); + } + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceId: { in: ruleTaskIds } }, + }); + // Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer + // of GENERAL/PROPOLIS warnings, and only this file switches the rule on + // (default: off, no minimum) - so such a warning since this file + // started belongs to the task above. + if (ruleTaskIds.length > 0) { + await prisma.notification.deleteMany({ + where: { + relatedType: 'GENERAL', + relatedId: 'PROPOLIS', + createdAt: { gte: startedAt }, + }, + }); + } + const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds]; + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: aggregateIds } }, + }); + await prisma.auditLog.deleteMany({ + where: { entityId: { in: [...lotIds, ...aggregateIds] } }, + }); + await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } }); await app.close(); }); @@ -130,6 +204,26 @@ describe('Raw materials and production (e2e)', () => { expect( (wax.body as LotBody).usedIn.map((u) => u.productionBatch.id), ).toEqual([batchId]); + + // The article for the shop, the consumed kinds for the minimum rule. + const events = await stockChangedOf(batchId); + expect(events).toEqual([ + { + aggregateType: 'ProductionBatch', + payload: { + bottlingBatchIds: [], + productIds: [productId], + rawMaterialKinds: expect.arrayContaining([ + 'BEESWAX', + 'PROPOLIS', + ]) as unknown, + }, + actorUserId: userId, + }, + ]); + expect( + (events[0].payload as { rawMaterialKinds: string[] }).rawMaterialKinds, + ).toHaveLength(2); }); it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => { @@ -163,6 +257,24 @@ describe('Raw materials and production (e2e)', () => { }); }); + it('a raw material withdrawal records StockChanged with the lot kind and no shop ids', async () => { + const [withdrawal] = await prisma.batchMovement.findMany({ + where: { rawMaterialLotId: waxId, type: 'WITHDRAWAL' }, + select: { id: true }, + }); + expect(await stockChangedOf(withdrawal.id)).toEqual([ + { + aggregateType: 'BatchMovement', + payload: { + bottlingBatchIds: [], + productIds: [], + rawMaterialKinds: ['BEESWAX'], + }, + actorUserId: userId, + }, + ]); + }); + it('gives the raw material back when a mistaken production batch is deleted', async () => { await auth( request(server()).delete(`/api/v1/production-batches/${batchId}`), @@ -174,6 +286,20 @@ describe('Raw materials and production (e2e)', () => { true, ); expect((await stockOf(propolisId)).summary.remaining).toBe(200); + + // The give-back is a stock change of the article and both kinds too. + const events = await stockChangedOf(batchId); + expect(events).toHaveLength(2); + for (const event of events) { + expect(event.payload).toEqual({ + bottlingBatchIds: [], + productIds: [productId], + rawMaterialKinds: expect.arrayContaining([ + 'BEESWAX', + 'PROPOLIS', + ]) as unknown, + }); + } }); it('refuses to correct a raw lot below what was already used or withdrawn', async () => { @@ -236,4 +362,97 @@ describe('Raw materials and production (e2e)', () => { request(server()).delete(`/api/v1/raw-material-lots/${waxId}`), ).expect(409); }); + + it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => { + // The rule is shared by every e2e file: switch it on with a minimum no + // test database reaches, and put it back right after. + const before = await prisma.automationRule.findUnique({ + where: { triggerType: 'RAW_MATERIAL_LOW' }, + }); + const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } }; + // One open task per kind: a leftover from an aborted earlier run would + // keep the handler from creating a new one - close it first. + await prisma.task.updateMany({ + where: { + sourceAutomationTrigger: 'RAW_MATERIAL_LOW', + relatedId: 'PROPOLIS', + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + data: { status: 'DONE', completedAt: new Date() }, + }); + await prisma.automationRule.upsert({ + where: { triggerType: 'RAW_MATERIAL_LOW' }, + create: { + triggerType: 'RAW_MATERIAL_LOW', + active: true, + config, + actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.', + }, + update: { active: true, config }, + }); + try { + const response = await auth( + request(server()).post( + `/api/v1/raw-material-lots/${propolisId}/withdrawals`, + ), + ) + .send({ quantity: 10, reason: 'PROBE' }) + .expect(201); + // Newest first: the withdrawal just booked. + const [movement] = (response.body as StockBody).movements; + const [event] = await prisma.domainEvent.findMany({ + where: { type: 'StockChanged', aggregateId: movement.id }, + select: { correlationId: true }, + }); + const ruleTasksOfThisChange = async () => + ( + await prisma.domainEvent.findMany({ + where: { + type: 'TaskCreated', + correlationId: event.correlationId, + }, + select: { aggregateId: true }, + }) + ).map((created) => created.aggregateId); + + // The request itself creates no task - the handler does. + expect(await ruleTasksOfThisChange()).toEqual([]); + expect( + await prisma.task.count({ + where: { + sourceAutomationTrigger: 'RAW_MATERIAL_LOW', + relatedId: 'PROPOLIS', + createdAt: { gte: startedAt }, + }, + }), + ).toBe(0); + + await drainDomainEvents(app, [movement.id]); + + const taskIds = await ruleTasksOfThisChange(); + ruleTaskIds.push(...taskIds); + expect(taskIds).toHaveLength(1); + expect( + await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }), + ).toMatchObject({ + sourceAutomationTrigger: 'RAW_MATERIAL_LOW', + relatedType: 'GENERAL', + relatedId: 'PROPOLIS', + status: 'OPEN', + }); + } finally { + // Leave the rule as found: restored, or gone again if this test + // created it (ensureDefaults then recreates it with its defaults). + if (before === null) { + await prisma.automationRule.delete({ + where: { triggerType: 'RAW_MATERIAL_LOW' }, + }); + } else { + await prisma.automationRule.update({ + where: { triggerType: 'RAW_MATERIAL_LOW' }, + data: { active: before.active, config: restoredConfig(before) }, + }); + } + } + }); }); diff --git a/ApiCore-Backend/test/rekey.e2e-spec.ts b/ApiCore-Backend/test/rekey.e2e-spec.ts new file mode 100644 index 0000000..03741c1 --- /dev/null +++ b/ApiCore-Backend/test/rekey.e2e-spec.ts @@ -0,0 +1,329 @@ +import { randomBytes } from 'node:crypto'; +import { Prisma } from '../generated/prisma'; +import { + decryptMfaSecret, + encryptMfaSecret, +} from '../src/auth/mfa/mfa-crypto.util'; +import { generateTotpSecret } from '../src/auth/mfa/totp.util'; +import { gcmSeal, legacyJwtKey } from '../src/common/crypto/key-ring'; +import { RekeyJob } from '../src/jobs/rekey.job'; +import { runCli } from '../src/jobs/run-cli'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { + openWebhookSecret, + sealWebhookSecret, +} from '../src/webhooks/webhook-secret'; + +type Form = 'legacy' | 'previous' | 'current' | 'plaintext'; +type MfaForm = Exclude; + +const MFA_FORMS: readonly MfaForm[] = ['legacy', 'previous', 'current']; +const WEBHOOK_FORMS: readonly Form[] = [ + 'legacy', + 'plaintext', + 'previous', + 'current', +]; + +const PREVIOUS_KEYS = { + MFA_ENCRYPTION_KEY_PREVIOUS: `rekey-e2e-previous-mfa-${'p'.repeat(32)}`, + WEBHOOK_ENCRYPTION_KEY_PREVIOUS: `rekey-e2e-previous-webhook-${'q'.repeat(32)}`, +} as const; + +/** How the code before the key ring sealed: AES-256-GCM under sha256(JWT_ACCESS_SECRET). */ +function legacyCiphertext(plaintext: string): string { + const { iv, tag, ciphertext } = gcmSeal( + legacyJwtKey(), + Buffer.from(plaintext, 'utf8'), + ); + return [iv, tag, ciphertext].map((part) => part.toString('base64')).join('.'); +} + +/** Seals with the key in `_PREVIOUS`, as if before the rotation. */ +function sealedWithPrevious(variable: string, seal: () => string): string { + const current = process.env[variable]; + process.env[variable] = process.env[`${variable}_PREVIOUS`]; + try { + return seal(); + } finally { + if (current === undefined) { + delete process.env[variable]; + } else { + process.env[variable] = current; + } + } +} + +function sealMfa(form: MfaForm, plaintext: string): string { + if (form === 'legacy') { + return legacyCiphertext(plaintext); + } + if (form === 'previous') { + return sealedWithPrevious('MFA_ENCRYPTION_KEY', () => + encryptMfaSecret(plaintext), + ); + } + return encryptMfaSecret(plaintext); +} + +function sealWebhook(form: Form, plaintext: string): string { + if (form === 'legacy') { + return `enc:v1:${legacyCiphertext(plaintext)}`; + } + if (form === 'plaintext') { + return plaintext; + } + if (form === 'previous') { + return sealedWithPrevious('WEBHOOK_ENCRYPTION_KEY', () => + sealWebhookSecret(plaintext), + ); + } + return sealWebhookSecret(plaintext); +} + +const keyIdOf = (stored: string): string => stored.split(':')[0]; + +/** + * The job as `node dist/cli.js rekey [--dry-run]` runs it: runCli with the + * real JobsModule against the shared test database. The job visits every + * row there, also those of e2e files running in parallel. So this file + * sets only *_PREVIOUS: the current keys stay the ones every e2e process + * has (the development keys from JWT_ACCESS_SECRET in .env.test, unless the + * shell sets real ones), and whatever this job reseals still opens there. + * Assertions on single rows use this file's rows; counts over the whole + * database are lower bounds. + */ +describe('rekey job (e2e)', () => { + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const testStart = new Date(Date.now() - 1000); + const savedEnv = Object.fromEntries( + Object.keys(PREVIOUS_KEYS).map((name) => [name, process.env[name]]), + ); + let prisma: PrismaService; + const mfa = {} as Record; + const webhook = {} as Record; + const created = { mfa: {}, webhook: {} } as { + mfa: Record; + webhook: Record; + }; + const extraUserIds: string[] = []; + + const createUser = async (name: string, mfaSecret: string) => { + const user = await prisma.user.create({ + data: { + email: `rekey-e2e-${name}-${suffix}@example.com`, + passwordHash: 'not-used', + name: `Rekey ${name}`, + mfaEnabled: true, + mfaSecret, + role: { connect: { key: 'READONLY' } }, + }, + }); + return user.id; + }; + + /** This file's rows as stored now. */ + const storedValues = async () => { + const users = await prisma.user.findMany({ + where: { id: { in: MFA_FORMS.map((form) => mfa[form].id) } }, + select: { id: true, mfaSecret: true }, + }); + const subscriptions = await prisma.webhookSubscription.findMany({ + where: { id: { in: WEBHOOK_FORMS.map((form) => webhook[form].id) } }, + select: { id: true, secret: true }, + }); + const userById = new Map(users.map((row) => [row.id, row.mfaSecret])); + const subscriptionById = new Map( + subscriptions.map((row) => [row.id, row.secret]), + ); + return { + mfa: Object.fromEntries( + MFA_FORMS.map((form) => [form, userById.get(mfa[form].id)]), + ) as Record, + webhook: Object.fromEntries( + WEBHOOK_FORMS.map((form) => [ + form, + subscriptionById.get(webhook[form].id), + ]), + ) as Record, + }; + }; + + const rekeyRuns = () => + prisma.jobRun.findMany({ + where: { job: 'rekey', startedAt: { gte: testStart } }, + orderBy: { startedAt: 'asc' }, + }); + + beforeAll(async () => { + Object.assign(process.env, PREVIOUS_KEYS); + prisma = new PrismaService(); + await prisma.$connect(); + + for (const form of MFA_FORMS) { + const plaintext = generateTotpSecret(); + created.mfa[form] = sealMfa(form, plaintext); + mfa[form] = { + id: await createUser(form, created.mfa[form]), + plaintext, + }; + } + for (const form of WEBHOOK_FORMS) { + const plaintext = randomBytes(32).toString('hex'); + created.webhook[form] = sealWebhook(form, plaintext); + const subscription = await prisma.webhookSubscription.create({ + data: { + url: 'https://receiver.example.com/rekey-e2e', + events: ['task.created'], + secret: created.webhook[form], + active: false, + }, + }); + webhook[form] = { id: subscription.id, plaintext }; + } + }); + + afterAll(async () => { + const runs = await rekeyRuns(); + const runIds = runs.map((run) => run.id); + await prisma.notification.deleteMany({ + where: { relatedType: 'JobRun', relatedId: { in: runIds } }, + }); + await prisma.jobRun.deleteMany({ where: { id: { in: runIds } } }); + const userIds = [ + ...MFA_FORMS.flatMap((form) => (mfa[form] ? [mfa[form].id] : [])), + ...extraUserIds, + ]; + await prisma.notification.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await prisma.webhookSubscription.deleteMany({ + where: { + id: { + in: WEBHOOK_FORMS.flatMap((form) => + webhook[form] ? [webhook[form].id] : [], + ), + }, + }, + }); + await prisma.$disconnect(); + for (const [name, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + }); + + it('a second run rekeys nothing; dry-run writes nothing', async () => { + const currentMfaKeyId = keyIdOf(encryptMfaSecret('key-id-probe')); + const currentWebhookKeyId = keyIdOf(sealWebhookSecret('key-id-probe')); + + // Dry-run: counts, opens every value, writes none. + expect(await runCli(['rekey', '--dry-run'])).toBe(0); + expect(await storedValues()).toEqual(created); + + // Run: everything not sealed with the current key is resealed. + expect(await runCli(['rekey'])).toBe(0); + const afterFirst = await storedValues(); + for (const form of ['legacy', 'previous'] as const) { + expect(keyIdOf(afterFirst.mfa[form])).toBe(currentMfaKeyId); + expect(afterFirst.mfa[form]).not.toBe(created.mfa[form]); + } + for (const form of ['legacy', 'plaintext', 'previous'] as const) { + expect(keyIdOf(afterFirst.webhook[form])).toBe(currentWebhookKeyId); + expect(afterFirst.webhook[form]).not.toBe(created.webhook[form]); + } + expect(afterFirst.mfa.current).toBe(created.mfa.current); + expect(afterFirst.webhook.current).toBe(created.webhook.current); + + // Second run: a reseal would draw a fresh IV, so unchanged bytes prove + // that nothing was written. + expect(await runCli(['rekey'])).toBe(0); + expect(await storedValues()).toEqual(afterFirst); + + const [dryRun, first, second, ...others] = await rekeyRuns(); + expect(others).toHaveLength(0); + for (const run of [dryRun, first, second]) { + expect(run).toMatchObject({ status: 'SUCCEEDED', error: null }); + } + const counts = (run: typeof first) => run.result as Record; + expect(counts(dryRun)).toMatchObject({ dryRun: true, 'mfa.failed': 0 }); + expect(counts(first)).toMatchObject({ dryRun: false, 'mfa.failed': 0 }); + for (const run of [dryRun, first]) { + expect(counts(run)['mfa.rekeyed']).toBeGreaterThanOrEqual(2); + expect(counts(run)['webhook.rekeyed']).toBeGreaterThanOrEqual(2); + expect(counts(run)['webhook.plaintextSealed']).toBeGreaterThanOrEqual(1); + } + // No e2e file writes old-format MFA or sealed webhook secrets; others + // may add plaintext webhook secrets meanwhile, so that count stays open. + expect(counts(second)).toMatchObject({ + dryRun: false, + 'mfa.rekeyed': 0, + 'mfa.plaintextSealed': 0, + 'mfa.changedMeanwhile': 0, + 'mfa.failed': 0, + 'webhook.rekeyed': 0, + 'webhook.failed': 0, + }); + expect(counts(second)['mfa.alreadyCurrent']).toBeGreaterThanOrEqual(3); + expect(counts(second)['webhook.alreadyCurrent']).toBeGreaterThanOrEqual(4); + }); + + it('rekeyed values open to the original plaintext', async () => { + // The previous keys can go now. (JWT_ACCESS_SECRET cannot change here: + // it is also the source of the development keys.) + for (const name of Object.keys(PREVIOUS_KEYS)) { + delete process.env[name]; + } + const stored = await storedValues(); + + for (const form of MFA_FORMS) { + expect(decryptMfaSecret(stored.mfa[form])).toBe(mfa[form].plaintext); + } + for (const form of WEBHOOK_FORMS) { + expect(openWebhookSecret(stored.webhook[form])).toBe( + webhook[form].plaintext, + ); + } + }); + + it('skips a row changed since it was read and keeps the new secret', async () => { + const userId = await createUser( + 'raced', + legacyCiphertext(generateTotpSecret()), + ); + extraUserIds.push(userId); + const reenrolled = encryptMfaSecret(generateTotpSecret()); + // Between the job's read and its write, the user sets MFA up anew - + // through the real database, so the SQL compare-and-swap is what has + // to keep the new secret. + const racing = { + user: { + findMany: (args: Prisma.UserFindManyArgs) => prisma.user.findMany(args), + updateMany: async (args: Prisma.UserUpdateManyArgs) => { + if (args.where?.id === userId) { + await prisma.user.update({ + where: { id: userId }, + data: { mfaSecret: reenrolled }, + }); + } + return prisma.user.updateMany(args); + }, + }, + webhookSubscription: prisma.webhookSubscription, + } as unknown as PrismaService; + + const result = await new RekeyJob(racing).run([]); + + expect(result['mfa.changedMeanwhile']).toBeGreaterThanOrEqual(1); + expect(result['mfa.failed']).toBe(0); + const after = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { mfaSecret: true }, + }); + expect(after.mfaSecret).toBe(reenrolled); + }); +}); diff --git a/ApiCore-Backend/test/stock.e2e-spec.ts b/ApiCore-Backend/test/stock.e2e-spec.ts index d560588..d63f54f 100644 --- a/ApiCore-Backend/test/stock.e2e-spec.ts +++ b/ApiCore-Backend/test/stock.e2e-spec.ts @@ -14,7 +14,7 @@ interface StockBody { corrected: number; remaining: number; }; - movements: { type: string; quantity: number }[]; + movements: { id: string; type: string; quantity: number }[]; } interface ShopResult { @@ -35,6 +35,7 @@ describe('Stock ledger (e2e)', () => { let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; + let beekeeperId: string; let beekeeperToken: string; let adminToken: string; let writeKey: string; @@ -48,6 +49,23 @@ describe('Stock ledger (e2e)', () => { const asBeekeeper = (req: request.Test) => req.set('Authorization', `Bearer ${beekeeperToken}`); + /** The StockChanged events recorded for one movement, shop request or batch. */ + function stockChangedOf(aggregateId: string) { + return prisma.domainEvent.findMany({ + where: { type: 'StockChanged', aggregateId }, + select: { aggregateType: true, payload: true, actorUserId: true }, + }); + } + + async function shopRequestId(externalId: string): Promise { + return ( + await prisma.shopStockRequest.findUniqueOrThrow({ + where: { externalId }, + select: { id: true }, + }) + ).id; + } + function book(token: string, requests: Record[]) { return request(server()) .post('/api/v1/shop-sync/movements') @@ -68,6 +86,7 @@ describe('Stock ledger (e2e)', () => { role: { connect: { key: 'BEEKEEPER' } }, }, }); + beekeeperId = beekeeper.id; await prisma.user.create({ data: { email: `stock-admin-${suffix}@example.com`, @@ -155,6 +174,34 @@ describe('Stock ledger (e2e)', () => { }); afterAll(async () => { + // Events and audit entries of this file only, by its own ids - other + // e2e files share the database. + // Only ids that exist: an undefined id would drop the filter. + const batchIds = [bottlingId, newBatchId, oldBatchId].filter(Boolean); + const movementIds = ( + await prisma.batchMovement.findMany({ + where: { + OR: [ + { bottlingBatchId: { in: batchIds } }, + { productionBatchId: { in: batchIds } }, + ], + }, + select: { id: true }, + }) + ).map((movement) => movement.id); + const requestIds = ( + await prisma.shopStockRequest.findMany({ + where: { externalId: { endsWith: suffix } }, + select: { id: true }, + }) + ).map((shopRequest) => shopRequest.id); + const aggregateIds = [...batchIds, ...movementIds, ...requestIds]; + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: aggregateIds } }, + }); + await prisma.auditLog.deleteMany({ + where: { entityId: { in: aggregateIds } }, + }); await app.close(); }); @@ -181,6 +228,48 @@ describe('Stock ledger (e2e)', () => { .expect(400); }); + it('withdraw books the movement, the audit entry and StockChanged in one transaction', async () => { + // The withdrawal of 2 above - the refused one of 20 left no trace. + const movements = await prisma.batchMovement.findMany({ + where: { bottlingBatchId: bottlingId }, + select: { id: true, type: true, quantity: true }, + }); + expect(movements).toEqual([ + expect.objectContaining({ type: 'WITHDRAWAL', quantity: -2 }), + ]); + const [movement] = movements; + expect( + await prisma.auditLog.count({ + where: { entityType: 'BatchMovement', entityId: movement.id }, + }), + ).toBe(1); + expect(await stockChangedOf(movement.id)).toEqual([ + { + aggregateType: 'BatchMovement', + payload: { + bottlingBatchIds: [bottlingId], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: beekeeperId, + }, + ]); + }); + + it('a production batch records StockChanged for its article', async () => { + expect(await stockChangedOf(newBatchId)).toEqual([ + { + aggregateType: 'ProductionBatch', + payload: { + bottlingBatchIds: [], + productIds: [candleId], + rawMaterialKinds: [], + }, + actorUserId: beekeeperId, + }, + ]); + }); + it('books a shop sale once, even when it is sent twice', async () => { const sale = { externalId: `sale-honey-${suffix}`, @@ -198,6 +287,19 @@ describe('Stock ledger (e2e)', () => { const [duplicate] = (second.body as { results: ShopResult[] }).results; expect(duplicate.status).toBe('duplicate'); expect(duplicate.remainingCount).toBe(5); + + // One event for the booking - the repeat booked nothing. + expect(await stockChangedOf(await shopRequestId(sale.externalId))).toEqual([ + { + aggregateType: 'ShopStockRequest', + payload: { + bottlingBatchIds: [bottlingId], + productIds: [], + rawMaterialKinds: [], + }, + actorUserId: null, + }, + ]); }); it('allocates an article sale to the batch with the earliest best-before date first', async () => { @@ -253,6 +355,34 @@ describe('Stock ledger (e2e)', () => { expect(tooMuch.status).toBe('rejected'); }); + it('a shop request records one StockChanged per accepted booking', async () => { + const sale = await stockChangedOf( + await shopRequestId(`sale-candle-${suffix}`), + ); + const returned = await stockChangedOf( + await shopRequestId(`return-candle-${suffix}`), + ); + for (const events of [sale, returned]) { + expect(events).toEqual([ + { + aggregateType: 'ShopStockRequest', + payload: { + bottlingBatchIds: [], + productIds: [candleId], + rawMaterialKinds: [], + }, + actorUserId: null, + }, + ]); + } + // The rejected return was rolled back together with its event. + expect( + await prisma.shopStockRequest.count({ + where: { externalId: `return-too-much-${suffix}` }, + }), + ).toBe(0); + }); + it('rejects a bad request but still books the rest of the batch', async () => { const response = await book(writeKey, [ { @@ -315,6 +445,20 @@ describe('Stock ledger (e2e)', () => { ); expect(attempts.filter((r) => r.status === 201)).toHaveLength(5); expect(attempts.filter((r) => r.status === 400)).toHaveLength(3); + // One StockChanged per booked withdrawal, none for a refused one. + const withdrawals = await prisma.batchMovement.findMany({ + where: { productionBatchId: newBatchId, type: 'WITHDRAWAL' }, + select: { id: true }, + }); + expect(withdrawals).toHaveLength(5); + expect( + await prisma.domainEvent.count({ + where: { + type: 'StockChanged', + aggregateId: { in: withdrawals.map((movement) => movement.id) }, + }, + }), + ).toBe(5); const stock = await asBeekeeper( request(server()).get(`/api/v1/production-batches/${newBatchId}/stock`), diff --git a/ApiCore-Backend/test/system.e2e-spec.ts b/ApiCore-Backend/test/system.e2e-spec.ts new file mode 100644 index 0000000..4f2ca20 --- /dev/null +++ b/ApiCore-Backend/test/system.e2e-spec.ts @@ -0,0 +1,237 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { DomainEventDispatcher } from '../src/domain-events/domain-event-dispatcher'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface FailedEventBody { + id: string; + failedHandlers: unknown[]; + [key: string]: unknown; +} + +interface FailedDeliveryBody { + id: string; + targetHost: string; + [key: string]: unknown; +} + +interface OverviewBody { + jobs: unknown[]; + domainEvents: { failed: number; oldestPendingAgeSeconds: number | null }; + webhookDeliveries: { failed: number }; +} + +describe('System API (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + let adminToken: string; + let staffToken: string; + const server = () => app.getHttpServer(); + const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + const passwordHash = await bcrypt.hash(password, 12); + for (const [key, role] of [ + ['admin', 'ADMIN'], + ['staff', 'STAFF'], + ] as const) { + await prisma.user.create({ + data: { + email: `system-${key}-${suffix}@example.com`, + passwordHash, + name: key, + role: { connect: { key: role } }, + }, + }); + } + adminToken = ( + await login(app, `system-admin-${suffix}@example.com`, password) + ).accessToken; + staffToken = ( + await login(app, `system-staff-${suffix}@example.com`, password) + ).accessToken; + }); + + afterAll(async () => { + await app.close(); + }); + + it('staff without system:read gets 403; API keys never reach /system', async () => { + await request(server()) + .get('/api/v1/system/overview') + .set(auth(staffToken)) + .expect(403); + await request(server()) + .post('/api/v1/system/domain-events/x/retry') + .set(auth(staffToken)) + .expect(403); + + const created = await request(server()) + .post('/api/v1/api-keys') + .set(auth(adminToken)) + .send({ name: `System ${suffix}`, scopes: ['shop-sync:read'] }) + .expect(201); + const { token } = created.body as { token: string }; + await request(server()) + .get('/api/v1/system/overview') + .set('Authorization', `Bearer ${token}`) + .expect(403); + await request(server()) + .get('/api/v1/system/job-runs') + .set('Authorization', `Bearer ${token}`) + .expect(403); + }); + + it('shows the overview and validates the list limit', async () => { + const response = await request(server()) + .get('/api/v1/system/overview') + .set(auth(adminToken)) + .expect(200); + const body = response.body as OverviewBody; + expect(body.jobs).toHaveLength(5); + expect(typeof body.domainEvents.failed).toBe('number'); + + await request(server()) + .get('/api/v1/system/job-runs?limit=0') + .set(auth(adminToken)) + .expect(400); + await request(server()) + .get('/api/v1/system/job-runs?limit=101') + .set(auth(adminToken)) + .expect(400); + await request(server()) + .get('/api/v1/system/job-runs?job=nope') + .set(auth(adminToken)) + .expect(400); + await request(server()) + .get('/api/v1/system/job-runs?job=cleanup&limit=5') + .set(auth(adminToken)) + .expect(200); + }); + + it('admin retries a FAILED event and the next drain completes it', async () => { + const correlationId = `system-${suffix}`; + const event = await prisma.domainEvent.create({ + data: { + type: 'test.system-retry', + aggregateType: 'SystemTest', + aggregateId: `agg-${suffix}`, + payload: { secret: 'must-not-leak' }, + correlationId, + status: 'FAILED', + attempts: 8, + lastError: 'boom', + availableAt: new Date(Date.now() + 86_400_000), + }, + }); + + const failed = await request(server()) + .get('/api/v1/system/domain-events/failed?limit=100') + .set(auth(adminToken)) + .expect(200); + const listed = (failed.body as FailedEventBody[]).find( + (e) => e.id === event.id, + ); + expect(listed).toBeDefined(); + expect(JSON.stringify(failed.body)).not.toContain('must-not-leak'); + expect(listed).not.toHaveProperty('payload'); + + await request(server()) + .post(`/api/v1/system/domain-events/${event.id}/retry`) + .set(auth(adminToken)) + .expect(204); + + const reset = await prisma.domainEvent.findUniqueOrThrow({ + where: { id: event.id }, + }); + expect(reset).toMatchObject({ + status: 'PENDING', + attempts: 0, + lastError: null, + }); + expect(reset.availableAt.getTime()).toBeLessThanOrEqual(Date.now() + 5_000); + await expect( + prisma.auditLog.count({ + where: { entityType: 'DomainEvent', entityId: event.id }, + }), + ).resolves.toBe(1); + + await app + .get(DomainEventDispatcher) + .drain({ correlationIds: [correlationId] }); + const done = await prisma.domainEvent.findUniqueOrThrow({ + where: { id: event.id }, + }); + expect(done.status).toBe('DONE'); + + // DONE is not FAILED any more: a second retry is a conflict. + await request(server()) + .post(`/api/v1/system/domain-events/${event.id}/retry`) + .set(auth(adminToken)) + .expect(409); + await request(server()) + .post('/api/v1/system/domain-events/does-not-exist/retry') + .set(auth(adminToken)) + .expect(404); + }); + + it('lists a failed webhook delivery with host only and retries it', async () => { + const subscription = await prisma.webhookSubscription.create({ + data: { + url: 'https://hooks.example.com/very/secret/path?token=abc', + events: ['harvest.created'], + secret: 'enc-secret', + }, + }); + const delivery = await prisma.webhookDelivery.create({ + data: { + subscriptionId: subscription.id, + event: 'harvest.created', + body: '{"private":"record"}', + status: 'FAILED', + attempts: 8, + lastStatusCode: 500, + lastError: 'HTTP 500', + }, + }); + + const response = await request(server()) + .get('/api/v1/system/webhook-deliveries/failed?limit=100') + .set(auth(adminToken)) + .expect(200); + const listed = (response.body as FailedDeliveryBody[]).find( + (d) => d.id === delivery.id, + ); + expect(listed?.targetHost).toBe('hooks.example.com'); + const raw = JSON.stringify(listed); + expect(raw).not.toMatch(/secret|token|private/); + + await request(server()) + .post(`/api/v1/system/webhook-deliveries/${delivery.id}/retry`) + .set(auth(adminToken)) + .expect(204); + const reset = await prisma.webhookDelivery.findUniqueOrThrow({ + where: { id: delivery.id }, + }); + expect(reset).toMatchObject({ + status: 'PENDING', + attempts: 0, + lastError: null, + lastStatusCode: null, + }); + await request(server()) + .post(`/api/v1/system/webhook-deliveries/${delivery.id}/retry`) + .set(auth(adminToken)) + .expect(409); + + await prisma.webhookSubscription.delete({ where: { id: subscription.id } }); + }); +}); diff --git a/ApiCore-Backend/test/task-templates.e2e-spec.ts b/ApiCore-Backend/test/task-templates.e2e-spec.ts index 91b1229..7879c4e 100644 --- a/ApiCore-Backend/test/task-templates.e2e-spec.ts +++ b/ApiCore-Backend/test/task-templates.e2e-spec.ts @@ -43,6 +43,18 @@ describe('TaskTemplates (e2e)', () => { afterAll(async () => { if (templateId) { + const generated = await prisma.task.findMany({ + where: { sourceTemplateId: templateId }, + select: { id: true }, + }); + const generatedIds = generated.map((task) => task.id); + // Written without a user, so not covered by the userId cleanup below. + await prisma.auditLog.deleteMany({ + where: { entityType: 'Task', entityId: { in: generatedIds } }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Task', aggregateId: { in: generatedIds } }, + }); await prisma.task.deleteMany({ where: { sourceTemplateId: templateId } }); await prisma.taskTemplate.delete({ where: { id: templateId } }); } @@ -88,15 +100,51 @@ describe('TaskTemplates (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); - const generatedTask = await prisma.task.findFirst({ + const generated = await prisma.task.findMany({ where: { sourceTemplateId: templateId }, }); - expect(generatedTask).not.toBeNull(); + expect(generated).toHaveLength(1); + const [generatedTask] = generated; + expect(generatedTask.createdByUserId).toBeNull(); const template = await prisma.taskTemplate.findUniqueOrThrow({ where: { id: templateId }, }); expect(template.nextDueDate.getTime()).toBeGreaterThan(yesterday.getTime()); + + // Same trail as any other task: an audit entry (by no user - the + // template made it) and TaskCreated for calendar and webhooks. + const audit = await prisma.auditLog.findMany({ + where: { entityType: 'Task', entityId: generatedTask.id }, + }); + expect(audit).toEqual([ + expect.objectContaining({ action: 'CREATE', userId: null }), + ]); + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'Task', aggregateId: generatedTask.id }, + }); + expect(events).toEqual([ + expect.objectContaining({ + type: 'TaskCreated', + actorUserId: null, + payload: { + relatedType: 'GENERAL', + relatedId: null, + dueDate: generatedTask.dueDate?.toISOString().slice(0, 10), + }, + }), + ]); + }); + + it('creates nothing more for an occurrence already generated', async () => { + await request(app.getHttpServer()) + .get('/api/v1/tasks') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + + await expect( + prisma.task.count({ where: { sourceTemplateId: templateId } }), + ).resolves.toBe(1); }); it('rejects a recurrence emptied of a field it needs', () => { diff --git a/ApiCore-Backend/test/tasks.e2e-spec.ts b/ApiCore-Backend/test/tasks.e2e-spec.ts index 36598df..3ab7fe8 100644 --- a/ApiCore-Backend/test/tasks.e2e-spec.ts +++ b/ApiCore-Backend/test/tasks.e2e-spec.ts @@ -5,6 +5,7 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; interface TaskBody { id: string; @@ -24,6 +25,13 @@ describe('Tasks (e2e)', () => { let staffToken: string; let readonlyToken: string; let taskId: string; + const taskIds: string[] = []; + + const eventsOf = (id: string, type: string) => + prisma.domainEvent.findMany({ + where: { aggregateType: 'Task', aggregateId: id, type }, + include: { deliveries: true }, + }); beforeAll(async () => { app = await createTestApp(); @@ -53,9 +61,11 @@ describe('Tasks (e2e)', () => { }); afterAll(async () => { - if (taskId) { - await prisma.task.delete({ where: { id: taskId } }); - } + // Deliveries go with their event (ON DELETE CASCADE). + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Task', aggregateId: { in: taskIds } }, + }); + await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: [staffId, readonlyId] } }, }); @@ -84,9 +94,63 @@ describe('Tasks (e2e)', () => { .expect(201); const task = response.body as TaskBody; taskId = task.id; + taskIds.push(taskId); expect(task.status).toBe('OPEN'); }); + it('POST /tasks leaves a TaskCreated event whose calendar and webhook deliveries are DONE after draining', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/tasks') + .set('Authorization', `Bearer ${staffToken}`) + .send({ title: 'Mit Termin', dueDate: '2027-05-01T00:00:00.000Z' }) + .expect(201); + const id = (response.body as TaskBody).id; + taskIds.push(id); + + // Recorded in the request's transaction, next to the audit entry. + const [recorded] = await eventsOf(id, 'TaskCreated'); + expect(recorded).toMatchObject({ + status: 'PENDING', + actorUserId: staffId, + payload: { + relatedType: 'GENERAL', + relatedId: null, + dueDate: '2027-05-01', + }, + }); + await expect( + prisma.auditLog.count({ + where: { + entityType: 'Task', + entityId: id, + action: 'CREATE', + userId: staffId, + }, + }), + ).resolves.toBe(1); + + await drainDomainEvents(app, [id]); + + const [drained, ...more] = await eventsOf(id, 'TaskCreated'); + expect(more).toHaveLength(0); + expect(drained.status).toBe('DONE'); + expect(drained.deliveries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handler: 'calendar-sync.task', + status: 'DONE', + }), + expect.objectContaining({ + handler: 'webhooks.forward', + status: 'DONE', + }), + ]), + ); + expect(drained.deliveries.every((entry) => entry.status === 'DONE')).toBe( + true, + ); + }); + it('lists tasks filtered by status', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/tasks?status=OPEN') @@ -121,6 +185,14 @@ describe('Tasks (e2e)', () => { expect(auditEntry?.afterData).toEqual( expect.objectContaining({ dueDate: null }), ); + const updates = await eventsOf(taskId, 'TaskUpdated'); + expect(updates.map((event) => event.payload)).toEqual( + expect.arrayContaining([ + { status: 'OPEN', dueDate: '2026-08-01' }, + { status: 'OPEN', dueDate: null }, + ]), + ); + expect(updates).toHaveLength(2); }); it('rejects null for the required title', () => { @@ -131,7 +203,7 @@ describe('Tasks (e2e)', () => { .expect(400); }); - it('completes a task', async () => { + it('completes a task and records TaskCompleted once, also when completed again', async () => { const response = await request(app.getHttpServer()) .post(`/api/v1/tasks/${taskId}/complete`) .set('Authorization', `Bearer ${staffToken}`) @@ -139,5 +211,18 @@ describe('Tasks (e2e)', () => { const task = response.body as TaskBody; expect(task.status).toBe('DONE'); expect(task.completedAt).not.toBeNull(); + + const repeated = await request(app.getHttpServer()) + .post(`/api/v1/tasks/${taskId}/complete`) + .set('Authorization', `Bearer ${staffToken}`) + .expect(201); + expect((repeated.body as TaskBody).completedAt).toBe(task.completedAt); + + const completions = await eventsOf(taskId, 'TaskCompleted'); + expect(completions).toHaveLength(1); + expect(completions[0]).toMatchObject({ + actorUserId: staffId, + payload: { relatedType: 'GENERAL', relatedId: null }, + }); }); }); diff --git a/ApiCore-Backend/test/throttler.e2e-spec.ts b/ApiCore-Backend/test/throttler.e2e-spec.ts index 2e7a423..6e17016 100644 --- a/ApiCore-Backend/test/throttler.e2e-spec.ts +++ b/ApiCore-Backend/test/throttler.e2e-spec.ts @@ -108,6 +108,55 @@ describe('Database-backed throttler (e2e)', () => { expect(record.timeToExpire).toBeLessThanOrEqual(60); }); + it('never reports more seconds than the ttl or the block duration (sub-ms rounding)', async () => { + const ns = randomUUID(); + await newApp(ns); + const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); + + // The column keeps milliseconds while now() has microseconds; rounding + // up by < 1 ms used to turn 60.0004 s into 61. About half of all + // timestamps round up, so 60 fresh keys catch it reliably. + for (let i = 0; i < 60; i += 1) { + const record = await storage.increment(`ms-${i}`, 60000, 0, 60000, 'd'); + expect(record.timeToExpire).toBeGreaterThanOrEqual(59); + expect(record.timeToExpire).toBeLessThanOrEqual(60); + expect(record.isBlocked).toBe(true); + expect(record.timeToBlockExpire).toBeGreaterThanOrEqual(59); + expect(record.timeToBlockExpire).toBeLessThanOrEqual(60); + } + }); + + it('keeps the same bounds when a window restarts or a block starts on an existing row', async () => { + const ns = randomUUID(); + await newApp(ns); + const storage = apps[apps.length - 1].get(PrismaThrottlerStorage); + const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + + for (let i = 0; i < 60; i += 1) { + // Restart branch: the 1 ms window is over, the row starts again at 1. + await storage.increment(`re-${i}`, 1, 10, 60000, 'd'); + await sleep(5); + const restarted = await storage.increment( + `re-${i}`, + 60000, + 10, + 60000, + 'd', + ); + expect(restarted.totalHits).toBe(1); + expect(restarted.timeToExpire).toBeGreaterThanOrEqual(59); + expect(restarted.timeToExpire).toBeLessThanOrEqual(60); + + // Block branch: hits + 1 > limit on an open window sets blockedUntil. + await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd'); + const blocked = await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd'); + expect(blocked.isBlocked).toBe(true); + expect(blocked.timeToBlockExpire).toBeGreaterThanOrEqual(59); + expect(blocked.timeToBlockExpire).toBeLessThanOrEqual(60); + } + }); + it('keeps hits while blocked and releases the block after it expired', async () => { const ns = randomUUID(); await newApp(ns); diff --git a/ApiCore-Backend/test/treatments.e2e-spec.ts b/ApiCore-Backend/test/treatments.e2e-spec.ts index eaf7e12..3b63e83 100644 --- a/ApiCore-Backend/test/treatments.e2e-spec.ts +++ b/ApiCore-Backend/test/treatments.e2e-spec.ts @@ -19,6 +19,11 @@ interface WithdrawalStatusBody { product: string; } +interface BulkTreatmentBody { + bulkId: string; + records: (TreatmentBody & { apiaryId: string; batchNumber: string })[]; +} + describe('Treatments (e2e)', () => { let app: INestApplication; let prisma: PrismaService; @@ -74,6 +79,15 @@ describe('Treatments (e2e)', () => { }); afterAll(async () => { + const treatmentIds = ( + await prisma.treatment.findMany({ + where: { colonyId }, + select: { id: true }, + }) + ).map((treatment) => treatment.id); + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } }, + }); await prisma.treatment.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); @@ -116,6 +130,30 @@ describe('Treatments (e2e)', () => { expect(treatmentId).toEqual(expect.any(String)); }); + it('wrote the audit entry and TreatmentRecorded together with the treatment', async () => { + expect( + await prisma.auditLog.count({ + where: { + entityType: 'Treatment', + entityId: treatmentId, + action: 'CREATE', + userId: beekeeperId, + }, + }), + ).toBe(1); + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'Treatment', aggregateId: treatmentId }, + }); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + type: 'TreatmentRecorded', + actorUserId: beekeeperId, + payload: { colonyId }, + }), + ); + }); + it('lists treatments for the colony', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/colonies/${colonyId}/treatments`) @@ -126,6 +164,14 @@ describe('Treatments (e2e)', () => { }); it('clears start and end date on null and audits the edit', async () => { + const correctionWhere = { + aggregateType: 'Treatment', + aggregateId: treatmentId, + type: 'TreatmentCorrected', + } as const; + const correctionsBefore = await prisma.domainEvent.count({ + where: correctionWhere, + }); await request(app.getHttpServer()) .patch(`/api/v1/treatments/${treatmentId}`) .set('Authorization', `Bearer ${beekeeperToken}`) @@ -152,6 +198,17 @@ describe('Treatments (e2e)', () => { expect(auditEntry?.afterData).toEqual( expect.objectContaining({ startDate: null, endDate: null }), ); + // Two edits in this test, one event each. + const corrections = await prisma.domainEvent.findMany({ + where: correctionWhere, + }); + expect(corrections).toHaveLength(correctionsBefore + 2); + expect( + corrections.every( + (event) => + (event.payload as { colonyId: string }).colonyId === colonyId, + ), + ).toBe(true); }); it.each(['date', 'product'])('rejects null for the required %s', (field) => @@ -350,4 +407,236 @@ describe('Treatments (e2e)', () => { it('rejects a role without treatments:create', () => lastByProduct(staffToken, productName).expect(403)); }); + + describe('POST /treatments/bulk', () => { + const suffix = Date.now(); + const shared = { + date: '2026-08-15', + product: 'Oxalsäure', + batchNumber: 'CH-1', + withdrawalPeriodDays: 0, + }; + let secondApiaryId: string; + // Two colonies at the first apiary, one at the second. + let bulkColonyIds: string[]; + let endedColonyId: string; + + const postBulk = (token: string, body: object, idempotencyKey?: string) => { + const call = request(app.getHttpServer()) + .post('/api/v1/treatments/bulk') + .set('Authorization', `Bearer ${token}`); + return ( + idempotencyKey === undefined + ? call + : call.set('Idempotency-Key', idempotencyKey) + ).send(body); + }; + + const storedTreatments = () => + prisma.treatment.count({ + where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } }, + }); + + beforeAll(async () => { + const secondApiary = await prisma.apiary.create({ + data: { name: `Stand B ${suffix}` }, + }); + secondApiaryId = secondApiary.id; + const colonyAt = async ( + code: string, + atApiaryId: string, + status: 'ACTIVE' | 'WEAK' | 'DISSOLVED' = 'ACTIVE', + ) => + ( + await prisma.colony.create({ + data: { + code: `${code}-${suffix}`, + apiaryId: atApiaryId, + colonyType: 'PRODUCTION_COLONY', + status, + }, + }) + ).id; + bulkColonyIds = [ + await colonyAt('TB-1', apiaryId), + await colonyAt('TB-2', apiaryId, 'WEAK'), + await colonyAt('TB-3', secondApiaryId), + ]; + endedColonyId = await colonyAt('TB-X', apiaryId, 'DISSOLVED'); + }); + + afterAll(async () => { + const colonyIds = [...bulkColonyIds, endedColonyId]; + const treatmentIds = ( + await prisma.treatment.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((treatment) => treatment.id); + await prisma.domainEvent.deleteMany({ + where: { + aggregateType: 'Treatment', + aggregateId: { in: treatmentIds }, + }, + }); + await prisma.treatment.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.deleteMany({ where: { id: secondApiaryId } }); + await prisma.idempotencyRecord.deleteMany({ + where: { userId: beekeeperId }, + }); + }); + + it("bulk creates one treatment per colony with the shared fields and each colony's apiary", async () => { + const response = await postBulk(beekeeperToken, { + ...shared, + colonyIds: bulkColonyIds, + }).expect(201); + + const body = response.body as BulkTreatmentBody; + expect(body.bulkId).toEqual(expect.any(String)); + expect(body.records).toHaveLength(3); + expect(body.records.map((record) => record.colonyId)).toEqual( + bulkColonyIds, + ); + expect( + body.records.every((record) => record.batchNumber === 'CH-1'), + ).toBe(true); + expect(body.records.map((record) => record.apiaryId)).toEqual([ + apiaryId, + apiaryId, + secondApiaryId, + ]); + }); + + it('bulk writes one TreatmentRecorded event and one audit entry per record', async () => { + const response = await postBulk(beekeeperToken, { + ...shared, + colonyIds: bulkColonyIds, + }).expect(201); + const { bulkId, records } = response.body as BulkTreatmentBody; + const ofThisBulk = { + type: 'TreatmentRecorded', + payload: { path: ['bulkId'], equals: bulkId }, + }; + + expect(await prisma.domainEvent.count({ where: ofThisBulk })).toBe(3); + const events = await prisma.domainEvent.findMany({ where: ofThisBulk }); + expect( + events + .map((event) => ({ + aggregateId: event.aggregateId, + actorUserId: event.actorUserId, + payload: event.payload, + })) + .sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)), + ).toEqual( + records + .map((record) => ({ + aggregateId: record.id, + actorUserId: beekeeperId, + payload: { colonyId: record.colonyId, bulkId }, + })) + .sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)), + ); + expect( + await prisma.auditLog.count({ + where: { + entityType: 'Treatment', + action: 'CREATE', + userId: beekeeperId, + entityId: { in: records.map((record) => record.id) }, + }, + }), + ).toBe(3); + }); + + it('bulk rejects the whole batch when one colony has ended and names it', async () => { + const before = await storedTreatments(); + + const response = await postBulk(beekeeperToken, { + ...shared, + colonyIds: [...bulkColonyIds, endedColonyId], + }).expect(400); + + expect( + (response.body as { invalidColonyIds: string[] }).invalidColonyIds, + ).toEqual([endedColonyId]); + expect(await storedTreatments()).toBe(before); + }); + + it.each([ + [ + 'an unknown id', + () => [bulkColonyIds[0], `unknown-${suffix}`], + 'Einige Völker sind nicht (mehr) erfassbar', + ], + ['no id', () => [], 'colonyIds braucht mindestens ein Volk'], + [ + '101 ids', + () => [ + ...bulkColonyIds, + ...Array.from({ length: 98 }, (_, index) => `cl-${suffix}-${index}`), + ], + 'colonyIds darf höchstens 100 Völker enthalten', + ], + [ + 'a duplicate id', + () => [bulkColonyIds[0], bulkColonyIds[0]], + 'colonyIds enthält ein Volk doppelt', + ], + [ + 'an empty id', + () => [bulkColonyIds[0], ''], + 'colonyIds enthält eine leere Volk-ID', + ], + ])( + 'bulk rejects unknown ids, 0 or 101 ids and duplicates: %s', + async (_label, colonyIds, message) => { + const before = await storedTreatments(); + + const response = await postBulk(beekeeperToken, { + ...shared, + colonyIds: colonyIds(), + }).expect(400); + + expect(JSON.stringify(response.body)).toContain(message); + expect(await storedTreatments()).toBe(before); + }, + ); + + it('bulk names an unknown id like an ended colony', async () => { + const unknownId = `unknown-${suffix}`; + + const response = await postBulk(beekeeperToken, { + ...shared, + colonyIds: [bulkColonyIds[0], unknownId], + }).expect(400); + + expect( + (response.body as { invalidColonyIds: string[] }).invalidColonyIds, + ).toEqual([unknownId]); + }); + + it('bulk replays with the same Idempotency-Key', async () => { + const key = `treatments-bulk-${suffix}`; + const body = { ...shared, colonyIds: bulkColonyIds }; + const before = await storedTreatments(); + + const first = await postBulk(beekeeperToken, body, key).expect(201); + const replay = await postBulk(beekeeperToken, body, key).expect(201); + + expect((replay.body as BulkTreatmentBody).bulkId).toBe( + (first.body as BulkTreatmentBody).bulkId, + ); + expect(await storedTreatments()).toBe(before + bulkColonyIds.length); + }); + + it('bulk rejects a role without treatments:create', () => + postBulk(staffToken, { ...shared, colonyIds: bulkColonyIds }).expect( + 403, + )); + }); }); diff --git a/ApiCore-Backend/test/utils/webhooks.ts b/ApiCore-Backend/test/utils/webhooks.ts new file mode 100644 index 0000000..3bba6bf --- /dev/null +++ b/ApiCore-Backend/test/utils/webhooks.ts @@ -0,0 +1,15 @@ +import { INestApplicationContext } from '@nestjs/common'; +import { WebhookDeliveryWorker } from '../../src/webhooks/webhook-delivery.worker'; + +/** + * Sends the due webhook deliveries of the given subscriptions - and nothing + * else, so e2e files running in parallel never send each other's + * deliveries. Without a worker, e2e tests call this after the request + * whose webhooks they want to assert. Returns how many were claimed. + */ +export function deliverWebhooks( + app: INestApplicationContext, + subscriptionIds: readonly string[], +): Promise { + return app.get(WebhookDeliveryWorker).drain({ subscriptionIds }); +} diff --git a/ApiCore-Backend/test/varroa.e2e-spec.ts b/ApiCore-Backend/test/varroa.e2e-spec.ts index 4fbe599..af0bc8b 100644 --- a/ApiCore-Backend/test/varroa.e2e-spec.ts +++ b/ApiCore-Backend/test/varroa.e2e-spec.ts @@ -12,6 +12,15 @@ interface VarroaMeasurementBody { calculatedValue: number | null; } +interface BulkVarroaBody { + bulkId: string; + records: (VarroaMeasurementBody & { + miteCount: number | null; + durationMinutes: number | null; + assessment: string | null; + })[]; +} + describe('Varroa measurements (e2e)', () => { let app: INestApplication; let prisma: PrismaService; @@ -92,6 +101,19 @@ describe('Varroa measurements (e2e)', () => { }); afterAll(async () => { + const measurementIds = ( + await prisma.varroaMeasurement.findMany({ + where: { colonyId }, + select: { id: true }, + }) + ).map((measurement) => measurement.id); + // Deliveries go with their event (ON DELETE CASCADE). + await prisma.domainEvent.deleteMany({ + where: { + aggregateType: 'VarroaMeasurement', + aggregateId: { in: measurementIds }, + }, + }); await prisma.varroaMeasurement.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); @@ -136,6 +158,30 @@ describe('Varroa measurements (e2e)', () => { measurementId = created.id; expect(measurementId).toEqual(expect.any(String)); expect(created.calculatedValue).toBe(4); + + // Committed with the measurement and its audit entry; the threshold + // rule and the varroa.measured webhook follow from the event. + await expect( + prisma.auditLog.count({ + where: { + entityType: 'VarroaMeasurement', + entityId: measurementId, + action: 'CREATE', + userId: beekeeperId, + }, + }), + ).resolves.toBe(1); + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'VarroaMeasurement', aggregateId: measurementId }, + }); + expect(events).toEqual([ + expect.objectContaining({ + type: 'VarroaMeasured', + actorUserId: beekeeperId, + payload: { colonyId }, + status: 'PENDING', + }), + ]); }); it('recomputes mites/day when the count is corrected', async () => { @@ -153,6 +199,28 @@ describe('Varroa measurements (e2e)', () => { }) .expect(200); expect((response.body as VarroaMeasurementBody).calculatedValue).toBe(10); + + // A corrected count can cross the threshold: the rule re-evaluates it + // from VarroaCorrected. + await expect( + prisma.auditLog.count({ + where: { + entityType: 'VarroaMeasurement', + entityId: measurementId, + action: 'UPDATE', + userId: beekeeperId, + }, + }), + ).resolves.toBe(1); + await expect( + prisma.domainEvent.findFirst({ + where: { aggregateId: measurementId, type: 'VarroaCorrected' }, + }), + ).resolves.toMatchObject({ + aggregateType: 'VarroaMeasurement', + actorUserId: beekeeperId, + payload: { colonyId }, + }); }); it('lists measurements for the colony', async () => { @@ -187,4 +255,195 @@ describe('Varroa measurements (e2e)', () => { it('rejects a role without any varroa permission', () => thresholdBody(noVarroaToken).expect(403)); }); + + describe('POST /varroa-measurements/bulk', () => { + const suffix = Date.now(); + const shared = { + date: '2026-09-10', + method: 'NATURAL_MITE_FALL', + durationMinutes: 4320, + notes: 'Windel nach Ameisensäure', + }; + let bulkColonyIds: string[]; + let endedColonyId: string; + + const postBulk = (token: string, body: object) => + request(app.getHttpServer()) + .post('/api/v1/varroa-measurements/bulk') + .set('Authorization', `Bearer ${token}`) + .send(body); + + const storedMeasurements = () => + prisma.varroaMeasurement.count({ + where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } }, + }); + + beforeAll(async () => { + const colonyAt = async ( + code: string, + status: 'ACTIVE' | 'MERGED' = 'ACTIVE', + ) => + ( + await prisma.colony.create({ + data: { + code: `${code}-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + status, + }, + }) + ).id; + bulkColonyIds = [await colonyAt('VB-1'), await colonyAt('VB-2')]; + endedColonyId = await colonyAt('VB-X', 'MERGED'); + }); + + afterAll(async () => { + const colonyIds = [...bulkColonyIds, endedColonyId]; + const measurementIds = ( + await prisma.varroaMeasurement.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((measurement) => measurement.id); + await prisma.domainEvent.deleteMany({ + where: { + aggregateType: 'VarroaMeasurement', + aggregateId: { in: measurementIds }, + }, + }); + await prisma.varroaMeasurement.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + }); + + it('bulk derives mites per day per entry', async () => { + const response = await postBulk(beekeeperToken, { + ...shared, + entries: [ + { colonyId: bulkColonyIds[0], miteCount: 30, assessment: 'hoch' }, + { colonyId: bulkColonyIds[1], miteCount: 6 }, + ], + }).expect(201); + + const { bulkId, records } = response.body as BulkVarroaBody; + expect( + records.map((record) => ({ + colonyId: record.colonyId, + miteCount: record.miteCount, + durationMinutes: record.durationMinutes, + calculatedValue: record.calculatedValue, + assessment: record.assessment, + })), + ).toEqual([ + { + colonyId: bulkColonyIds[0], + miteCount: 30, + durationMinutes: 4320, + calculatedValue: 10, + assessment: 'hoch', + }, + { + colonyId: bulkColonyIds[1], + miteCount: 6, + durationMinutes: 4320, + calculatedValue: 2, + assessment: null, + }, + ]); + expect( + await prisma.domainEvent.count({ + where: { + type: 'VarroaMeasured', + payload: { path: ['bulkId'], equals: bulkId }, + }, + }), + ).toBe(2); + expect( + await prisma.auditLog.count({ + where: { + entityType: 'VarroaMeasurement', + action: 'CREATE', + userId: beekeeperId, + entityId: { in: records.map((record) => record.id) }, + }, + }), + ).toBe(2); + }); + + it('bulk rejects duplicate colonyId in entries', async () => { + const before = await storedMeasurements(); + + const response = await postBulk(beekeeperToken, { + ...shared, + entries: [ + { colonyId: bulkColonyIds[0], miteCount: 3 }, + { colonyId: bulkColonyIds[0], miteCount: 4 }, + ], + }).expect(400); + + expect(JSON.stringify(response.body)).toContain( + 'entries enthält ein Volk doppelt', + ); + expect(await storedMeasurements()).toBe(before); + }); + + it('bulk rejects the whole batch when one colony has ended and names it', async () => { + const before = await storedMeasurements(); + + const response = await postBulk(beekeeperToken, { + ...shared, + entries: [ + { colonyId: bulkColonyIds[0], miteCount: 3 }, + { colonyId: endedColonyId, miteCount: 4 }, + ], + }).expect(400); + + expect( + (response.body as { invalidColonyIds: string[] }).invalidColonyIds, + ).toEqual([endedColonyId]); + expect(await storedMeasurements()).toBe(before); + }); + + it.each([ + ['no entry', {}, () => []], + [ + 'a shared mite count instead of one per entry', + { miteCount: 5 }, + () => [{ colonyId: bulkColonyIds[0] }], + ], + [ + 'an invalid entry', + {}, + () => [{ colonyId: bulkColonyIds[0], miteCount: -1 }], + ], + ])('bulk rejects %s', (_label, extra: object, entries: () => object[]) => + postBulk(beekeeperToken, { + ...shared, + ...extra, + entries: entries(), + }).expect(400), + ); + + it('bulk rejects an empty colonyId in entries as invalid input, not as an unknown colony', async () => { + const response = await postBulk(beekeeperToken, { + ...shared, + entries: [ + { colonyId: bulkColonyIds[0], miteCount: 3 }, + { colonyId: '', miteCount: 4 }, + ], + }).expect(400); + + expect(JSON.stringify(response.body)).toContain( + 'entries.1.colonyId enthält eine leere Volk-ID', + ); + expect(response.body).not.toHaveProperty('invalidColonyIds'); + }); + + it('bulk rejects a role without varroa:create', () => + postBulk(staffToken, { + ...shared, + entries: [{ colonyId: bulkColonyIds[0], miteCount: 1 }], + }).expect(403)); + }); }); diff --git a/ApiCore-Backend/test/webhooks.e2e-spec.ts b/ApiCore-Backend/test/webhooks.e2e-spec.ts index f8f6b2c..c30b2ee 100644 --- a/ApiCore-Backend/test/webhooks.e2e-spec.ts +++ b/ApiCore-Backend/test/webhooks.e2e-spec.ts @@ -5,8 +5,25 @@ import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; +import { postWebhook, WebhookResponse } from '../src/webhooks/webhook-delivery'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; +import { deliverWebhooks } from './utils/webhooks'; + +// The real postWebhook by default; tests that need the loopback receiver +// below swap in postToLoopback (the real one refuses loopback by design). +jest.mock('../src/webhooks/webhook-delivery', () => { + const actual = jest.requireActual< + typeof import('../src/webhooks/webhook-delivery') + >('../src/webhooks/webhook-delivery'); + return { ...actual, postWebhook: jest.fn(actual.postWebhook) }; +}); + +const postWebhookMock = postWebhook as jest.MockedFunction; +const realPostWebhook = jest.requireActual< + typeof import('../src/webhooks/webhook-delivery') +>('../src/webhooks/webhook-delivery').postWebhook; interface WebhookSubscriptionBody { id: string; @@ -16,27 +33,142 @@ interface WebhookSubscriptionBody { active: boolean; } +interface WebhookDeliveryBody { + id: string; + event: string; + status: string; + attempts: number; + lastStatusCode: number | null; + lastError: string | null; + createdAt: string; + deliveredAt: string | null; +} + interface ReceivedRequest { body: string; headers: http.IncomingHttpHeaders; } +/** + * postWebhook without the connect-time address guard, for the loopback + * receiver of this file: same method, headers and body, real HTTP. + */ +function postToLoopback( + rawUrl: string, + headers: Record, + body: string, +): Promise { + return new Promise((resolve, reject) => { + const outgoing = http.request( + rawUrl, + { + method: 'POST', + headers: { + ...headers, + 'Content-Length': Buffer.byteLength(body).toString(), + }, + }, + (response) => { + response.resume(); + resolve({ status: response.statusCode ?? 0 }); + }, + ); + outgoing.on('error', reject); + outgoing.end(body); + }); +} + +function signatureOf(secret: string, received: ReceivedRequest): string { + const timestamp = String(received.headers['x-apicore-timestamp']); + const deliveryId = String(received.headers['x-apicore-delivery']); + const digest = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.${deliveryId}.${received.body}`) + .digest('hex'); + return `sha256=${digest}`; +} + describe('Webhooks (e2e)', () => { let app: INestApplication; let prisma: PrismaService; - const adminEmail = `webhooks-e2e-admin-${Date.now()}@example.com`; - const staffEmail = `webhooks-e2e-staff-${Date.now()}@example.com`; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const adminEmail = `webhooks-e2e-admin-${suffix}@example.com`; + const staffEmail = `webhooks-e2e-staff-${suffix}@example.com`; const password = 'SuperSecret123!'; + // Stored in plain like a pre-encryption row; openWebhookSecret reads it. + const loopbackSecret = crypto.randomBytes(32).toString('hex'); let adminId: string; let staffId: string; let adminAccessToken: string; let staffAccessToken: string; + let apiaryId: string; let subscriptionId: string; - let deliverySubscriptionId: string; + let blockedSubscriptionId: string; + let colonySubscriptionId: string; + let taskSubscriptionId: string; + const colonyIds: string[] = []; + const taskIds: string[] = []; let receiverServer: http.Server; let receiverUrl: string; let receivedRequests: ReceivedRequest[] = []; + /** Delivery ids the receiver answers with 500 once. */ + const failOnce = new Set(); + + const received = (deliveryId: string): ReceivedRequest[] => + receivedRequests.filter( + (entry) => entry.headers['x-apicore-delivery'] === deliveryId, + ); + + /** Creates a colony through the API; returns its id. */ + async function createColony(): Promise { + const response = await request(app.getHttpServer()) + .post('/api/v1/colonies') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + code: `WH-${suffix}-${colonyIds.length}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }) + .expect(201); + const id = (response.body as { id: string }).id; + colonyIds.push(id); + return id; + } + + /** Creates a task through the API; returns its id. */ + async function createTask(title: string): Promise { + const response = await request(app.getHttpServer()) + .post('/api/v1/tasks') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ title: `${title} ${suffix}` }) + .expect(201); + const id = (response.body as { id: string }).id; + taskIds.push(id); + return id; + } + + /** The delivery of `subscription` whose body is about `recordId`. */ + function deliveryAbout(subscription: string, recordId: string) { + return prisma.webhookDelivery.findFirstOrThrow({ + where: { subscriptionId: subscription, body: { contains: recordId } }, + }); + } + + /** All deliveries of `subscription` for `event` about `recordId`. */ + function deliveriesAbout( + subscription: string, + event: string, + recordId: string, + ) { + return prisma.webhookDelivery.findMany({ + where: { + subscriptionId: subscription, + event, + body: { contains: recordId }, + }, + }); + } beforeAll(async () => { app = await createTestApp(); @@ -63,18 +195,23 @@ describe('Webhooks (e2e)', () => { staffId = staff.id; adminAccessToken = (await login(app, adminEmail, password)).accessToken; staffAccessToken = (await login(app, staffEmail, password)).accessToken; + apiaryId = ( + await prisma.apiary.create({ data: { name: `Webhook-Stand ${suffix}` } }) + ).id; receivedRequests = []; receiverServer = http.createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { + const deliveryId = String(req.headers['x-apicore-delivery']); receivedRequests.push({ body: Buffer.concat(chunks).toString('utf-8'), headers: req.headers, }); - res.writeHead(200); - res.end('ok'); + const fail = failOnce.delete(deliveryId); + res.writeHead(fail ? 500 : 200); + res.end(fail ? 'receiver error body' : 'ok'); }); }); await new Promise((resolve) => @@ -87,13 +224,33 @@ describe('Webhooks (e2e)', () => { receiverUrl = `http://127.0.0.1:${address.port}/hook`; }); + afterEach(() => { + postWebhookMock.mockImplementation(realPostWebhook); + }); + afterAll(async () => { await new Promise((resolve, reject) => receiverServer.close((error) => (error ? reject(error) : resolve())), ); + // Deliveries go with their subscription (ON DELETE CASCADE). await prisma.webhookSubscription.deleteMany({ - where: { id: { in: [subscriptionId, deliverySubscriptionId] } }, + where: { + id: { + in: [ + subscriptionId, + blockedSubscriptionId, + colonySubscriptionId, + taskSubscriptionId, + ].filter(Boolean), + }, + }, }); + // Event deliveries go with their event (ON DELETE CASCADE). + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Task', aggregateId: { in: taskIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.task.deleteMany({ where: { createdByUserId: { in: [adminId, staffId] } }, }); @@ -142,6 +299,22 @@ describe('Webhooks (e2e)', () => { expect(body.events).toEqual(['task.created']); }); + it('accepts the new event types', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/webhooks/${subscriptionId}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + events: [ + 'task.created', + 'varroa.measured', + 'treatment.created', + 'feeding.created', + 'stock.changed', + ], + }) + .expect(200); + }); + it('excludes the secret from the list response', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/webhooks') @@ -154,29 +327,289 @@ describe('Webhooks (e2e)', () => { it('never delivers to a private address, even from a row that bypassed the create guard', async () => { // Stands in for DNS rebinding or a tampered row: the stored URL points - // at a loopback receiver. The connection-time check must refuse it, and - // the request that fired the event must not wait for the receiver. - // Signing is covered by webhooks.service.spec.ts. + // at a loopback receiver. The connection-time check must refuse it. const subscription = await prisma.webhookSubscription.create({ data: { url: receiverUrl, events: ['task.created'], - secret: crypto.randomBytes(32).toString('hex'), + secret: loopbackSecret, createdByUserId: adminId, }, }); - deliverySubscriptionId = subscription.id; + blockedSubscriptionId = subscription.id; - await request(app.getHttpServer()) - .post('/api/v1/tasks') - .set('Authorization', `Bearer ${adminAccessToken}`) - .send({ title: `Webhook-Test ${Date.now()}` }) - .expect(201); - await new Promise((resolve) => setTimeout(resolve, 300)); + const taskId = await createTask('Webhook-Test'); + // The request recorded TaskCreated; its webhook handler queues the + // delivery. Nothing is sent yet. + await drainDomainEvents(app, [taskId]); + const queued = await deliveryAbout(blockedSubscriptionId, taskId); + expect(queued).toMatchObject({ status: 'PENDING', attempts: 0 }); + + await deliverWebhooks(app, [blockedSubscriptionId]); expect(receivedRequests).toHaveLength(0); + const after = await deliveryAbout(blockedSubscriptionId, taskId); + expect(after).toMatchObject({ + status: 'PENDING', + attempts: 1, + lastStatusCode: null, + }); + expect(after.lastError).toContain('blocked address'); }); + it('delivers task.created and task.completed once each, through the event and the queue', async () => { + postWebhookMock.mockImplementation(postToLoopback); + taskSubscriptionId = ( + await prisma.webhookSubscription.create({ + data: { + url: receiverUrl, + events: ['task.created', 'task.completed'], + secret: loopbackSecret, + createdByUserId: adminId, + }, + }) + ).id; + + const taskId = await createTask('Webhook-Aufgabe'); + // Nothing is queued before the event is dispatched. + await expect( + deliveriesAbout(taskSubscriptionId, 'task.created', taskId), + ).resolves.toHaveLength(0); + await drainDomainEvents(app, [taskId]); + await deliverWebhooks(app, [taskSubscriptionId]); + + const [created, ...moreCreated] = await deliveriesAbout( + taskSubscriptionId, + 'task.created', + taskId, + ); + expect(moreCreated).toHaveLength(0); + expect(created).toMatchObject({ status: 'DELIVERED', lastStatusCode: 200 }); + const [sentCreated, ...resentCreated] = received(created.id); + expect(resentCreated).toHaveLength(0); + expect(sentCreated.headers['x-apicore-event']).toBe('task.created'); + expect(JSON.parse(sentCreated.body)).toMatchObject({ + event: 'task.created', + data: { id: taskId, status: 'OPEN' }, + }); + + // Completed twice: the second request announces nothing. + for (let i = 0; i < 2; i++) { + await request(app.getHttpServer()) + .post(`/api/v1/tasks/${taskId}/complete`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(201); + } + await drainDomainEvents(app, [taskId]); + await deliverWebhooks(app, [taskSubscriptionId]); + + const [completed, ...moreCompleted] = await deliveriesAbout( + taskSubscriptionId, + 'task.completed', + taskId, + ); + expect(moreCompleted).toHaveLength(0); + expect(completed).toMatchObject({ + status: 'DELIVERED', + lastStatusCode: 200, + }); + const [sentCompleted, ...resentCompleted] = received(completed.id); + expect(resentCompleted).toHaveLength(0); + expect(sentCompleted.headers['x-apicore-signature']).toBe( + signatureOf(loopbackSecret, sentCompleted), + ); + expect(JSON.parse(sentCompleted.body)).toMatchObject({ + event: 'task.completed', + data: { id: taskId, status: 'DONE' }, + }); + // Still the one task.created: completing sends no second one. + await expect( + deliveriesAbout(taskSubscriptionId, 'task.created', taskId), + ).resolves.toHaveLength(1); + }); + + it('queues task.completed once for a PATCH into DONE, also when it is repeated', async () => { + const taskId = await createTask('Per Bearbeiten erledigt'); + + for (let i = 0; i < 2; i++) { + await request(app.getHttpServer()) + .patch(`/api/v1/tasks/${taskId}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ status: 'DONE' }) + .expect(200); + } + await expect( + prisma.domainEvent.count({ + where: { aggregateId: taskId, type: 'TaskCompleted' }, + }), + ).resolves.toBe(1); + await expect( + prisma.domainEvent.count({ + where: { aggregateId: taskId, type: 'TaskUpdated' }, + }), + ).resolves.toBe(2); + + await drainDomainEvents(app, [taskId]); + + const completed = await deliveriesAbout( + taskSubscriptionId, + 'task.completed', + taskId, + ); + expect(completed).toHaveLength(1); + expect(JSON.parse(completed[0].body)).toMatchObject({ + event: 'task.completed', + data: { id: taskId, status: 'DONE' }, + }); + }); + + it('delivers colony.created through the queue with a stable delivery id', async () => { + postWebhookMock.mockImplementation(postToLoopback); + colonySubscriptionId = ( + await prisma.webhookSubscription.create({ + data: { + url: receiverUrl, + events: ['colony.created'], + secret: loopbackSecret, + createdByUserId: adminId, + }, + }) + ).id; + + const colonyId = await createColony(); + await deliverWebhooks(app, [colonySubscriptionId]); + + const delivery = await deliveryAbout(colonySubscriptionId, colonyId); + const [sent, ...more] = received(delivery.id); + expect(more).toHaveLength(0); + expect(sent.headers['x-apicore-delivery']).toBe(delivery.id); + expect(sent.headers['x-apicore-event']).toBe('colony.created'); + expect(sent.headers['x-apicore-signature']).toBe( + signatureOf(loopbackSecret, sent), + ); + // The body stored at enqueue time, byte for byte. + expect(sent.body).toBe(delivery.body); + expect(JSON.parse(sent.body)).toMatchObject({ + event: 'colony.created', + data: { id: colonyId, apiaryId }, + }); + expect(delivery).toMatchObject({ + status: 'DELIVERED', + attempts: 0, + lastStatusCode: 200, + claimedAt: null, + }); + expect(delivery.deliveredAt).toBeInstanceOf(Date); + }); + + it('retries a 500 with the same delivery id and then marks it DELIVERED', async () => { + postWebhookMock.mockImplementation(postToLoopback); + const colonyId = await createColony(); + const queued = await deliveryAbout(colonySubscriptionId, colonyId); + failOnce.add(queued.id); + + await deliverWebhooks(app, [colonySubscriptionId]); + + const failed = await deliveryAbout(colonySubscriptionId, colonyId); + expect(failed).toMatchObject({ + status: 'PENDING', + attempts: 1, + lastStatusCode: 500, + lastError: 'Receiver answered HTTP 500', + }); + // Backoff by the database clock: the first retry waits 30 s. + const [{ waitMs }] = await prisma.$queryRaw<{ waitMs: number }[]>` + SELECT (EXTRACT(EPOCH FROM ("availableAt" - now())) * 1000)::float8 AS "waitMs" + FROM "WebhookDelivery" WHERE "id" = ${queued.id}`; + expect(waitMs).toBeGreaterThan(25_000); + expect(waitMs).toBeLessThanOrEqual(30_000); + // Not due yet: another run leaves it alone. + await deliverWebhooks(app, [colonySubscriptionId]); + expect(received(queued.id)).toHaveLength(1); + + await prisma.$executeRaw`UPDATE "WebhookDelivery" SET "availableAt" = now() WHERE "id" = ${queued.id}`; + await deliverWebhooks(app, [colonySubscriptionId]); + + const attempts = received(queued.id); + expect(attempts).toHaveLength(2); + expect(attempts[1].headers['x-apicore-delivery']).toBe(queued.id); + expect(attempts[1].body).toBe(attempts[0].body); + attempts.forEach((attempt) => + expect(attempt.headers['x-apicore-signature']).toBe( + signatureOf(loopbackSecret, attempt), + ), + ); + const delivered = await deliveryAbout(colonySubscriptionId, colonyId); + expect(delivered).toMatchObject({ + status: 'DELIVERED', + attempts: 1, + lastStatusCode: 200, + }); + }); + + it('lists deliveries for admins and refuses staff', async () => { + // Every e2e file creates colonies: while the subscription is active, + // their colony.created deliveries pile up on it and could push this + // file's rows off the page. Deactivated, nothing new is queued. + await prisma.webhookSubscription.update({ + where: { id: colonySubscriptionId }, + data: { active: false }, + }); + const response = await request(app.getHttpServer()) + .get(`/api/v1/webhooks/${colonySubscriptionId}/deliveries?limit=100`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(200); + const deliveries = response.body as WebhookDeliveryBody[]; + const ours = await prisma.webhookDelivery.findMany({ + where: { + subscriptionId: colonySubscriptionId, + OR: colonyIds.map((id) => ({ body: { contains: id } })), + }, + }); + expect(ours.length).toBeGreaterThanOrEqual(2); + for (const delivery of ours) { + expect(deliveries.map((entry) => entry.id)).toContain(delivery.id); + } + for (const entry of deliveries) { + expect(Object.keys(entry).sort()).toEqual( + [ + 'attempts', + 'createdAt', + 'deliveredAt', + 'event', + 'id', + 'lastError', + 'lastStatusCode', + 'status', + ].sort(), + ); + } + // Newest first. + const createdAt = deliveries.map((entry) => Date.parse(entry.createdAt)); + expect(createdAt).toEqual([...createdAt].sort((a, b) => b - a)); + + await request(app.getHttpServer()) + .get(`/api/v1/webhooks/${colonySubscriptionId}/deliveries`) + .set('Authorization', `Bearer ${staffAccessToken}`) + .expect(403); + }); + + it.each(['0', '101', '1.5', 'abc'])( + 'rejects limit=%s for the delivery list', + (limit) => + request(app.getHttpServer()) + .get(`/api/v1/webhooks/${colonySubscriptionId}/deliveries`) + .query({ limit }) + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(400), + ); + + it('answers 404 for the deliveries of an unknown subscription', () => + request(app.getHttpServer()) + .get('/api/v1/webhooks/does-not-exist/deliveries') + .set('Authorization', `Bearer ${adminAccessToken}`) + .expect(404)); + it.each(['url', 'events', 'active'])( 'rejects null for the required %s', (field) => diff --git a/ApiCore-Backend/test/winter-checks.e2e-spec.ts b/ApiCore-Backend/test/winter-checks.e2e-spec.ts index 9ded02f..b8995b8 100644 --- a/ApiCore-Backend/test/winter-checks.e2e-spec.ts +++ b/ApiCore-Backend/test/winter-checks.e2e-spec.ts @@ -94,6 +94,9 @@ describe('Winter checks (e2e)', () => { afterAll(async () => { const colonyIds = [survivorId, lostId, pendingId]; + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } }, + }); await prisma.winterCheck.deleteMany({ where: { colonyId: { in: colonyIds } }, }); @@ -192,6 +195,18 @@ describe('Winter checks (e2e)', () => { expect(colony.deactivatedAt?.toISOString()).toBe( '2002-03-20T00:00:00.000Z', ); + const events = await prisma.domainEvent.findMany({ + where: { aggregateId: lostId }, + orderBy: { occurredAt: 'asc' }, + }); + expect(events.map((event) => event.type)).toEqual([ + 'ColonyStatusChanged', + 'ColonyEnded', + ]); + expect(events[1].payload).toEqual({ status: 'DECEASED' }); + expect( + await prisma.domainEvent.count({ where: { aggregateId: survivorId } }), + ).toBe(0); const survivor = await prisma.colony.findUniqueOrThrow({ where: { id: survivorId }, }); diff --git a/ApiCore-Backend/test/worker-runtime.e2e-spec.ts b/ApiCore-Backend/test/worker-runtime.e2e-spec.ts index 22e7f32..e206e01 100644 --- a/ApiCore-Backend/test/worker-runtime.e2e-spec.ts +++ b/ApiCore-Backend/test/worker-runtime.e2e-spec.ts @@ -15,6 +15,7 @@ import type { DomainEventEnvelope } from '../src/domain-events/domain-event-type import { DomainEventsService } from '../src/domain-events/domain-events.service'; import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator'; import { PrismaService } from '../src/prisma/prisma.service'; +import { WebhookDeliveryWorker } from '../src/webhooks/webhook-delivery.worker'; import { WorkerModule } from '../src/worker/worker.module'; import { DOMAIN_EVENT_POLL_MS } from '../src/worker/worker-runtime.service'; @@ -127,6 +128,7 @@ describe('Worker runtime (e2e)', () => { let worker: TestingModule; let prisma: PrismaService; let processOutbox: jest.SpyInstance; + let deliverWebhooks: jest.SpyInstance; let healthPort: number; async function recordTaskDueInTx(): Promise { @@ -164,6 +166,13 @@ describe('Worker runtime (e2e)', () => { processOutbox = jest .spyOn(worker.get(CalendarSyncService), 'processOutbox') .mockResolvedValue(undefined); + // Likewise the webhook loop (webhooks.e2e-spec.ts covers delivery): an + // unfiltered drain here would send the deliveries of parallel e2e files. + const webhookDeliveries = worker.get(WebhookDeliveryWorker); + deliverWebhooks = jest + .spyOn(webhookDeliveries, 'drain') + .mockResolvedValue(0); + jest.spyOn(webhookDeliveries, 'releaseStaleClaims').mockResolvedValue(0); await worker.init(); // The first ticks run right after bootstrap; wait for the dispatcher's. const dispatcher = worker.get(DomainEventDispatcher); @@ -210,6 +219,19 @@ describe('Worker runtime (e2e)', () => { ); }); + it('wakes the webhook delivery loop on a webhook_delivery notification', async () => { + expect(deliverWebhooks).toHaveBeenCalled(); + const before = deliverWebhooks.mock.calls.length; + + await notifyWorker(prisma, WAKE_CHANNELS.webhookDeliveries); + + await expectWithin( + WAKE_UP_WITHIN_MS, + () => deliverWebhooks.mock.calls.length > before, + true, + ); + }); + it('answers GET /health with 200 {"status":"ok"}', async () => { await expect(getJson(healthPort, '/health')).resolves.toEqual({ status: 200, diff --git a/ApiCore-Frontend/public/sw-routes.js b/ApiCore-Frontend/public/sw-routes.js new file mode 100644 index 0000000..00dacd3 --- /dev/null +++ b/ApiCore-Frontend/public/sw-routes.js @@ -0,0 +1,250 @@ +/* + * Routing rules of the service worker (public/sw.js), kept free of the Cache + * and Fetch APIs so they can be unit-tested (src/lib/offline/sw-routes.spec.ts). + * A plain script on purpose: the worker loads it with importScripts and gets + * `self.ApiCoreSwRoutes`, Vitest loads it with require. No syntax newer than + * the oldest phone browser that runs the worker (no lookbehind, no `?.`). + * + * Changing this file changes its content version: update SW_ROUTES_VERSION + * in sw.js (a test says which value), or phones keep the old rules. + */ +(function (root) { + "use strict"; + + /** Pages whose HTML is kept for a cold start without network. */ + var SHELL_ROUTES = [ + "/", + "/login", + "/dashboard", + "/offline", + "/offline/apiary", + "/offline/colony", + "/inspections/new", + "/varroa/new", + "/treatments/new", + "/feedings/new", + "/swarm-control/new", + ]; + + var OFFLINE_PAGE = "/offline"; + /** Read only this device's storage: answered from the cache at once. */ + var OFFLINE_PAGES = ["/offline", "/offline/apiary", "/offline/colony"]; + + var SHELL_TIMEOUT_MS = 4000; + var DEEP_LINK_TIMEOUT_MS = 10000; + var OTHER_PAGE_TIMEOUT_MS = 15000; + + /** Build assets of old builds stay this long after they were last fetched. */ + var BUILD_ASSET_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + + // Deep links (QR codes, NFC tags) open the offline view of the same record. + // Static pages under the same path are not ids. + var DETAIL_ROUTES = [ + { pattern: /^\/colonies\/([^/]+)$/, offlinePage: "/offline/colony", notIds: ["new"] }, + { pattern: /^\/apiaries\/([^/]+)$/, offlinePage: "/offline/apiary", notIds: ["new", "map"] }, + ]; + + // Every backend call goes through /api/v1/. The backend matches paths + // case-insensitively, so these rules do too. + var API_PATH = /\/api\/v1\//; + // Never kept, each for its own reason: + var NEVER_CACHED_API_PATHS = [ + // tokens and session state + /\/api\/v1\/auth(\/|$)/i, + // apiaries themselves: exact coordinates and the address (theft risk) + /\/api\/v1\/apiaries\/?$/i, + /\/api\/v1\/apiaries\/[^/]+\/?$/i, + // exports and backups: full records incl. coordinates and addresses, + // names (Bestandsbuch), megabytes + /\/api\/v1\/export\//i, + /\/api\/v1\/backup(\/|$)/i, + // what the offline packs answer: without network they must fail fast, + // so the pack - with its age shown - answers instead of an old copy + /\/api\/v1\/colonies\/?$/i, + /\/api\/v1\/colonies\/[^/]+\/inspections\/?$/i, + /\/api\/v1\/apiaries\/[^/]+\/(colony-overview|offline-pack)\/?$/i, + ]; + + // A root-relative build asset, in an attribute or in the inline RSC data + // (where quotes are escaped). The leading character keeps absolute URLs of + // other hosts out. + var NEXT_STATIC_ASSET = /(^|["'(\s,=])(\/_next\/static\/[^"'\s<>()\\]+)/g; + var NEXT_STATIC_PREFIX = "/_next/static/"; + + function normalizePath(pathname) { + return pathname.length > 1 && pathname.charAt(pathname.length - 1) === "/" + ? pathname.slice(0, -1) + : pathname; + } + + function isShellRoute(pathname) { + return SHELL_ROUTES.indexOf(normalizePath(pathname)) !== -1; + } + + function decodeSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return null; + } + } + + /** "from" parameter: where the user wanted to go, for the way back. */ + function fromParam(url) { + return "from=" + encodeURIComponent(url.pathname + url.search); + } + + function offlineHomeFor(url) { + return OFFLINE_PAGE + "?" + fromParam(url); + } + + function detailRouteOf(pathname) { + for (var index = 0; index < DETAIL_ROUTES.length; index += 1) { + var route = DETAIL_ROUTES[index]; + var match = route.pattern.exec(pathname); + if (!match) { + continue; + } + var id = decodeSegment(match[1]); + if (id === null || route.notIds.indexOf(id) !== -1) { + return null; + } + return { offlinePage: route.offlinePage, id: id }; + } + return null; + } + + /** + * What a page navigation gets when neither the network nor the cache has + * that exact URL: a shell page from the cache (its query stays in the + * address bar, the page reads it), the offline view of a record, or the + * offline start page. Redirects carry `from` for the way back. + */ + function offlineFallbackFor(url) { + var pathname = normalizePath(url.pathname); + if (isShellRoute(pathname)) { + return { kind: "cached-path", path: pathname }; + } + var detail = detailRouteOf(pathname); + if (detail) { + return { + kind: "redirect", + to: detail.offlinePage + "?id=" + encodeURIComponent(detail.id) + "&" + fromParam(url), + }; + } + return { kind: "redirect", to: offlineHomeFor(url) }; + } + + /** + * How long a page navigation waits for the network. On a timeout the + * cached copy answers; without one, `redirectOnTimeout` decides between the + * offline fallback and waiting on (a shell page has no better fallback). + */ + function navigationPlanFor(url) { + var pathname = normalizePath(url.pathname); + if (pathname === "/api" || pathname.indexOf("/api/") === 0) { + return { kind: "browser" }; + } + if (OFFLINE_PAGES.indexOf(pathname) !== -1) { + return { kind: "cache-first" }; + } + if (isShellRoute(pathname)) { + return { kind: "network-first", timeoutMs: SHELL_TIMEOUT_MS, redirectOnTimeout: false }; + } + if (detailRouteOf(pathname)) { + return { kind: "network-first", timeoutMs: DEEP_LINK_TIMEOUT_MS, redirectOnTimeout: true }; + } + return { kind: "network-first", timeoutMs: OTHER_PAGE_TIMEOUT_MS, redirectOnTimeout: true }; + } + + function isCacheableApiPath(pathname) { + if (!API_PATH.test(pathname)) { + return false; + } + for (var index = 0; index < NEVER_CACHED_API_PATHS.length; index += 1) { + if (NEVER_CACHED_API_PATHS[index].test(pathname)) { + return false; + } + } + return true; + } + + /** Only answers of the backend named by the runtime config are kept. */ + function shouldCacheApi(url, apiOrigin) { + return Boolean(apiOrigin) && url.origin === apiOrigin && isCacheableApiPath(url.pathname); + } + + /** The backend's origin from /api/runtime-config ({ apiUrl }), or null. */ + function apiOriginFrom(config) { + if (!config || typeof config.apiUrl !== "string") { + return null; + } + try { + var origin = new URL(config.apiUrl).origin; + return origin === "null" ? null : origin; + } catch { + return null; + } + } + + /** Next.js route data (soft navigations): never kept, the page HTML is. */ + function isRoutePayload(url) { + return url.searchParams.has("_rsc"); + } + + /** The build assets (scripts, styles, fonts) a page's HTML refers to. */ + function extractAssetUrls(html) { + var found = []; + var match; + NEXT_STATIC_ASSET.lastIndex = 0; + while ((match = NEXT_STATIC_ASSET.exec(html)) !== null) { + var asset = match[2].replace(/&/g, "&"); + if (found.indexOf(asset) === -1) { + found.push(asset); + } + } + return found; + } + + /** + * Cached build assets no shell page refers to any more and not fetched + * within a week (`date`: the answer's Date header, null when missing) - + * leftovers of old builds. Lazily loaded chunks of the current build are + * kept while they are in use. + */ + function buildAssetsToPrune(cached, current, now) { + return cached + .filter(function (entry) { + var url = new URL(entry.url); + if (url.pathname.indexOf(NEXT_STATIC_PREFIX) !== 0) { + return false; + } + if (current.indexOf(url.pathname + url.search) !== -1) { + return false; + } + return entry.date === null || now - entry.date > BUILD_ASSET_MAX_AGE_MS; + }) + .map(function (entry) { + return entry.url; + }); + } + + var routes = { + SHELL_ROUTES: SHELL_ROUTES, + offlineFallbackFor: offlineFallbackFor, + offlineHomeFor: offlineHomeFor, + navigationPlanFor: navigationPlanFor, + isShellRoute: isShellRoute, + extractAssetUrls: extractAssetUrls, + isCacheableApiPath: isCacheableApiPath, + shouldCacheApi: shouldCacheApi, + apiOriginFrom: apiOriginFrom, + isRoutePayload: isRoutePayload, + buildAssetsToPrune: buildAssetsToPrune, + }; + + root.ApiCoreSwRoutes = routes; + if (typeof module !== "undefined" && module.exports) { + module.exports = routes; + } +})(typeof self !== "undefined" ? self : globalThis); diff --git a/ApiCore-Frontend/public/sw.js b/ApiCore-Frontend/public/sw.js index cd1afb7..8ddbf83 100644 --- a/ApiCore-Frontend/public/sw.js +++ b/ApiCore-Frontend/public/sw.js @@ -1,74 +1,515 @@ -const CACHE_NAME = "apicore-shell-v3"; +/* + * ApiCore service worker. A beekeeper must be able to open the app at the + * apiary without any reception (cold start), open a QR/NFC deep link there, + * and still see data loaded earlier. So it keeps: + * - the app shell: the HTML of SHELL_ROUTES and their /_next/static assets, + * warmed on install and on the WARM_SHELL message ("Für offline laden"), + * - API answers of the configured backend (network first, the cache only + * when the network fails) - except what the offline packs answer. + * The routing rules live in sw-routes.js (unit-tested). + * + * What is never kept: auth answers (tokens, session state), apiaries + * themselves (coordinates, address), Next.js route payloads, and request + * headers - entries are stored under their bare URL, so the Authorization + * header with the access token never lands in Cache Storage. The cached + * HTML holds no user data: the (app) layout renders nothing until the client + * knows the user. + */ -self.addEventListener("install", () => { +// Content version of sw-routes.js (sha256 of its LF text, first 12 hex +// characters; sw-routes.spec.ts checks it). A changed value changes this +// file, so every browser - Safari too - installs the worker with new rules. +const SW_ROUTES_VERSION = "3b534be6a6bf"; +importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`); + +const { + SHELL_ROUTES, + offlineFallbackFor, + offlineHomeFor, + navigationPlanFor, + extractAssetUrls, + isCacheableApiPath, + apiOriginFrom, + isRoutePayload, + buildAssetsToPrune, +} = self.ApiCoreSwRoutes; + +const SHELL_CACHE = "apicore-shell-v4"; +// v2: v1 could hold answers this version never keeps (exports, downloads). +const DATA_CACHE = "apicore-data-v2"; +const KNOWN_CACHES = [SHELL_CACHE, DATA_CACHE]; + +// Same budget as every request of the app (lib/http/fetch-with-timeout.ts). +const ASSET_TIMEOUT_MS = 4000; +const RUNTIME_CONFIG_PATH = "/api/runtime-config"; +const STATIC_PREFIX = "/_next/static/"; + +// Stored under the bare URL (no headers), so Vary cannot hide them later. +const BY_URL = { ignoreVary: true }; +const BY_PATH = { ignoreVary: true, ignoreSearch: true }; + +const TIMED_OUT = new Error("timeout"); + +// Last resort when not even the offline page is cached (the app was never +// opened with network since this worker was installed). Never a browser +// error page: that looks like the app is broken. +const OFFLINE_NOTICE = ` + +ApiCore – keine Verbindung + +

Keine Verbindung

+

ApiCore ist auf diesem Gerät noch nicht für offline bereit.

+

Öffne die App einmal mit Netz. Danach startet sie auch ohne Netz.

+

Erneut versuchen

+`; + +// The backend whose answers may be kept, from the runtime config. Read from +// the cache at every worker start and after WIPE_CACHES, updated whenever +// the config passes by. +let apiOrigin = null; +let apiOriginLoaded = readCachedApiOrigin(); +// Raised by every WIPE_CACHES. A request that began under an older value +// belongs to the user who has just signed out: its answer is never stored. +let wipeGeneration = 0; + +self.addEventListener("install", (event) => { self.skipWaiting(); + // A failed warm-up must not stop the worker: navigations refill the shell. + event.waitUntil(warmShell().catch(() => undefined)); }); self.addEventListener("activate", (event) => { event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))), - ) + removeUnknownCaches() + .then(pruneForbiddenEntries) + .catch(() => undefined) .then(() => self.clients.claim()), ); }); +self.addEventListener("message", (event) => { + const data = event.data; + const type = typeof data === "string" ? data : data && data.type; + if (type === "WARM_SHELL") { + event.waitUntil( + warmShell() + .catch(() => undefined) + .then(() => reply(event, "WARM_SHELL_DONE")), + ); + } else if (type === "WIPE_CACHES") { + event.waitUntil(wipeCaches().then(() => reply(event, "WIPE_CACHES_DONE"))); + } +}); + self.addEventListener("fetch", (event) => { const { request } = event; - if (request.method !== "GET") { return; } - const url = new URL(request.url); - - // /api/runtime-config tells the app which backend origin to use - it - // must always be fetched live. A stale cached value would point the app - // at the wrong (or no longer running) backend, which is worse than no - // cache at all. - if (url.pathname === "/api/runtime-config") { + if (url.protocol !== "https:" && url.protocol !== "http:") { return; } + const isSameOrigin = url.origin === self.location.origin; - // Same-origin static assets/pages, or the real backend API. The backend - // runs on its own origin (API_URL, injected at runtime via - // /api/runtime-config) rather than this app's own origin, so it can't be - // matched by origin the way static assets are - matched by path instead, - // since every backend call goes through /api/v1/. Without this, colonies, - // apiaries, tasks etc. were never cached at all: a beekeeper opening the - // app with no signal at an apiary saw nothing, not even data they had - // already loaded minutes earlier on the same device. - const isCacheable = - url.origin === self.location.origin || url.pathname.includes("/api/v1/"); - if (!isCacheable) { - return; - } - - // Page navigations must never be answered with a synthetic Response.error() - - // Chrome/Edge render that as a native "This page couldn't load" interstitial - // instead of the app. Let the browser handle navigations directly; only - // static assets/API data go through the offline-cache fallback below. if (request.mode === "navigate") { + const plan = navigationPlanFor(url); + if (isSameOrigin && plan.kind !== "browser") { + event.respondWith(respondToNavigation(event, url, plan)); + } return; } - - event.respondWith( - fetch(request) - .then((response) => { - // Never cache an error response (e.g. a 401 hit right as an access - // token expired, before refresh could kick in) - it would otherwise - // get served back later as if it were still the current state. - if (!response.ok) { - return response; - } - const responseClone = response.clone(); - return caches - .open(CACHE_NAME) - .then((cache) => cache.put(request, responseClone)) - .then(() => response); - }) - .catch(() => caches.match(request).then((cached) => cached || Response.error())), - ); + // Which backend to talk to: needed for a cold start, so kept, but always + // asked live first - a stale value could point at a backend that is gone. + if (isSameOrigin && url.pathname === RUNTIME_CONFIG_PATH) { + event.respondWith(respondWithRuntimeConfig(event)); + return; + } + if (isCacheableApiPath(url.pathname)) { + respondToApiCall(event, url); + return; + } + // Auth and other uncached backend calls (also on a same-origin backend), + // other hosts and route payloads: untouched. + if (!isSameOrigin || url.pathname.startsWith("/api/")) { + return; + } + if (isRoutePayload(url) || request.headers.get("RSC") === "1") { + return; + } + // Build assets, icons, the manifest. On a weak signal the cached copy + // answers after the timeout, so the app still starts. + event.respondWith(networkFirst(event, SHELL_CACHE, ASSET_TIMEOUT_MS)); }); + +function respondToApiCall(event, url) { + // Taken when the request arrives, not when its fetch starts: waiting for the + // backend origin below may outlast a WIPE_CACHES. + const generation = wipeGeneration; + const keepApiAnswer = () => + networkFirst(event, DATA_CACHE, null, isKeepableApiAnswer, generation); + if (apiOrigin !== null) { + if (url.origin === apiOrigin) { + event.respondWith(keepApiAnswer()); + } + return; + } + // Worker just started (or caches wiped): the backend is known once the + // cached config is read - without one, nothing is kept. + event.respondWith( + apiOriginLoaded.then((origin) => + origin === url.origin && generation === wipeGeneration + ? keepApiAnswer() + : fetch(event.request), + ), + ); +} + +function reply(event, type) { + const port = event.ports && event.ports[0]; + if (port) { + port.postMessage({ type }); + } +} + +/** + * Deletes every cache and forgets the backend until a config tells it again. + * The first lines run before anything is awaited: from this moment on no + * request, running or new, may write the signed-out user's data back. + */ +async function wipeCaches() { + wipeGeneration += 1; + apiOrigin = null; + apiOriginLoaded = Promise.resolve(null); + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + apiOriginLoaded = readCachedApiOrigin(); + await apiOriginLoaded; +} + +function removeUnknownCaches() { + return caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => !KNOWN_CACHES.includes(key)).map((key) => caches.delete(key))), + ); +} + +/** + * Entries an earlier worker version kept that this one never would: apiary + * rows (coordinates), pack-covered answers, route payloads. + */ +async function pruneForbiddenEntries() { + const data = await caches.open(DATA_CACHE); + for (const request of await data.keys()) { + if (!isCacheableApiPath(new URL(request.url).pathname)) { + await data.delete(request); + } + } + const shell = await caches.open(SHELL_CACHE); + for (const request of await shell.keys()) { + if (isRoutePayload(new URL(request.url))) { + await shell.delete(request); + } + } +} + +async function readCachedApiOrigin() { + try { + const cache = await caches.open(SHELL_CACHE); + const response = await cache.match(RUNTIME_CONFIG_PATH, BY_URL); + const origin = response ? apiOriginFrom(await response.json()) : null; + if (apiOrigin === null) { + apiOrigin = origin; + } + return apiOrigin; + } catch { + return null; + } +} + +function learnApiOrigin(response) { + response + .clone() + .json() + .then((config) => { + const origin = apiOriginFrom(config); + if (origin) { + apiOrigin = origin; + } + }) + .catch(() => undefined); +} + +function respondWithRuntimeConfig(event) { + return networkFirst(event, SHELL_CACHE, null).then((response) => { + if (response.ok) { + learnApiOrigin(response); + } + return response; + }); +} + +/** Rejects with TIMED_OUT after `ms`; the promise itself keeps running. `null` = no limit. */ +function within(promise, ms) { + if (ms === null) { + return promise; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(TIMED_OUT), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +/** + * Stores a copy of a good answer under `key` once it arrives. Registered on + * the network promise before anyone else reads the answer, so the copy is + * taken before the page consumes the body; waitUntil keeps the worker alive + * until it is written, even when the cache answered first. + */ +function storeWhenAnswered(event, network, cacheName, key, isWorthKeeping, generation) { + const stored = network.then((response) => { + if (!isWorthKeeping(response) || generation !== wipeGeneration) { + return undefined; + } + const copy = response.clone(); + return caches + .open(cacheName) + .then((cache) => (generation === wipeGeneration ? cache.put(key, copy) : undefined)); + }); + event.waitUntil(stored.catch(() => undefined)); +} + +function isGoodAnswer(response) { + return response.ok; +} + +/** + * Only JSON records are kept from the backend - never downloads, photos, + * PDFs or CSVs (documents, avatars, exports). Content-Type is CORS-safelisted, + * so it is readable on the backend's cross-origin answers. + */ +function isKeepableApiAnswer(response) { + const contentType = response.headers.get("content-type") || ""; + return response.ok && contentType.toLowerCase().includes("application/json"); +} + +function isGoodPage(response) { + return response.ok && !response.redirected; +} + +async function cachedByUrl(cacheName, key) { + const cache = await caches.open(cacheName); + return cache.match(key, BY_URL); +} + +/** + * The network's answer (and a copy for later); without network the cached + * copy. After `timeoutMs` a cached copy answers too - without one, the + * network still has its chance. + */ +function networkFirst( + event, + cacheName, + timeoutMs, + isWorthKeeping = isGoodAnswer, + generation = wipeGeneration, +) { + const key = event.request.url; + const network = fetch(event.request); + storeWhenAnswered(event, network, cacheName, key, isWorthKeeping, generation); + return within(network, timeoutMs).catch(async () => { + const cached = await cachedByUrl(cacheName, key); + return cached || network; + }); +} + +/** The same URL from the shell cache, else the shell page of its path. */ +async function cachedPage(url, fallback) { + const cache = await caches.open(SHELL_CACHE); + const samePage = await cache.match(url.href, BY_URL); + if (samePage || fallback.kind !== "cached-path") { + return samePage; + } + return cache.match(fallback.path, BY_PATH); +} + +/** + * Page navigations, planned per route (sw-routes.js): the offline pages + * from the cache at once; shell pages 4 s, deep links 10 s, other pages 15 s + * network first. Without network: the cached page, else the offline view of + * a deep-linked record or the offline page - both with the way back. + */ +async function respondToNavigation(event, url, plan) { + const fallback = offlineFallbackFor(url); + if (plan.kind === "cache-first") { + const cached = await cachedPage(url, fallback); + if (cached) { + refreshPageInBackground(event, fallback); + return cached; + } + } + const network = fetch(event.request); + if (fallback.kind === "cached-path") { + storePageWhenAnswered(event, network, fallback.path); + } + const timeoutMs = plan.kind === "network-first" ? plan.timeoutMs : null; + try { + return await within(network, timeoutMs); + } catch (error) { + const cached = await cachedPage(url, fallback); + if (cached) { + return cached; + } + if (error === TIMED_OUT && plan.kind === "network-first" && !plan.redirectOnTimeout) { + return network.catch(() => offlineRedirect(url, fallback)); + } + return offlineRedirect(url, fallback); + } +} + +/** + * Keeps a cache-first page current for the next visit - through the warm-up + * path, so a new build's page is only kept together with its assets (an + * offline view must never be a page whose scripts are missing). + */ +function refreshPageInBackground(event, fallback) { + if (fallback.kind !== "cached-path") { + return; + } + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => keepPage(cache, fallback.path, new Map())) + .catch(() => undefined), + ); +} + +/** + * A shell page from a live navigation is kept the same way: its assets + * first, the HTML only once they are all cached. + */ +function storePageWhenAnswered(event, network, path) { + // The copy is taken before the page consumes the body. + const copied = network.then((response) => (isGoodPage(response) ? response.clone() : null)); + event.waitUntil( + copied + .then(async (copy) => { + if (copy) { + const cache = await caches.open(SHELL_CACHE); + await keepPageAnswer(cache, path, copy, new Map()); + } + }) + .catch(() => undefined), + ); +} + +/** Redirects only to a page the cache can serve, so there is no loop. */ +async function offlineRedirect(url, fallback) { + const cache = await caches.open(SHELL_CACHE); + const candidates = fallback.kind === "redirect" ? [fallback.to] : []; + candidates.push(offlineHomeFor(url)); + for (const candidate of candidates) { + const target = new URL(candidate, self.location.origin); + if (target.pathname !== url.pathname && (await cache.match(target.pathname, BY_PATH))) { + return Response.redirect(target.href, 302); + } + } + return new Response(OFFLINE_NOTICE, { + status: 503, + headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, + }); +} + +/** Fetches and keeps the shell pages, their build assets and the runtime config. */ +async function warmShell() { + const cache = await caches.open(SHELL_CACHE); + // One download per asset, however many pages share it. + const assetJobs = new Map(); + const pages = await Promise.allSettled( + SHELL_ROUTES.map((route) => keepPage(cache, route, assetJobs)), + ); + const assets = new Set(); + let allPagesKept = true; + for (const page of pages) { + if (page.status === "fulfilled" && page.value !== null) { + page.value.forEach((asset) => assets.add(asset)); + } else { + allPagesKept = false; + } + } + await keepRuntimeConfig(cache).catch(() => undefined); + // Only with the full picture of the current build may old assets go. + if (allPagesKept) { + await pruneOldBuildAssets(cache, [...assets]); + } +} + +/** Fetches and keeps a shell page with its assets; its asset list, or null when not kept. */ +async function keepPage(cache, route, assetJobs) { + const response = await fetch(route, { cache: "no-cache" }); + return isGoodPage(response) ? keepPageAnswer(cache, route, response, assetJobs) : null; +} + +/** + * Keeps the page's build assets first and the HTML only once all of them + * are cached - otherwise the old page (with its own assets) stays. Rejects + * when an asset cannot be fetched. + */ +async function keepPageAnswer(cache, route, response, assetJobs) { + const html = await response.clone().text(); + const assets = extractAssetUrls(html); + await Promise.all(assets.map((asset) => keepAssetOnce(cache, asset, assetJobs))); + await cache.put(route, response); + return assets; +} + +function keepAssetOnce(cache, asset, assetJobs) { + if (!assetJobs.has(asset)) { + assetJobs.set(asset, keepAsset(cache, asset)); + } + return assetJobs.get(asset); +} + +/** Build assets are content-hashed: one already kept never changes. */ +async function keepAsset(cache, asset) { + if (await cache.match(asset, BY_URL)) { + return; + } + const response = await fetch(asset, { cache: "no-cache" }); + if (!isGoodAnswer(response)) { + throw new Error("Asset not available: " + asset); + } + await cache.put(asset, response); +} + +async function keepRuntimeConfig(cache) { + const response = await fetch(RUNTIME_CONFIG_PATH, { cache: "no-cache" }); + if (isGoodAnswer(response)) { + learnApiOrigin(response); + await cache.put(RUNTIME_CONFIG_PATH, response); + } +} + +async function pruneOldBuildAssets(cache, currentAssets) { + const entries = []; + for (const request of await cache.keys()) { + if (!new URL(request.url).pathname.startsWith(STATIC_PREFIX)) { + continue; + } + const response = await cache.match(request, BY_URL); + const date = response ? Date.parse(response.headers.get("date") || "") : Number.NaN; + entries.push({ url: request.url, date: Number.isNaN(date) ? null : date }); + } + const prune = buildAssetsToPrune(entries, currentAssets, Date.now()); + await Promise.all(prune.map((url) => cache.delete(url, BY_URL))); +} diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx index 2e802e9..262bfa6 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx @@ -1,3 +1,4 @@ +import "fake-indexeddb/auto"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -99,6 +100,23 @@ describe("ApiaryColoniesCard", () => { expect(screen.getByRole("button", { name: "Runde starten" })).not.toHaveAttribute("href"); }); + it("offers „Für offline laden“ in the colonies card", async () => { + vi.mocked(fetchColonyOverview).mockResolvedValue(overview([row({ id: "c-1", code: "A1" })])); + renderCard(); + + const heading = await screen.findByRole("heading", { name: "Völker (1)" }); + const card = heading.closest("[data-slot=card]") as HTMLElement; + expect(within(card).getByRole("button", { name: "Für offline laden" })).toBeInTheDocument(); + }); + + it("asks for the overview with the signed-in user, so it can answer from the pack offline", async () => { + vi.mocked(fetchColonyOverview).mockResolvedValue(overview([])); + renderCard(); + + await screen.findByText(/Noch keine Völker/); + expect(fetchColonyOverview).toHaveBeenCalledWith("a-1", "user-1"); + }); + it("shows a retryable error instead of an empty list when loading fails", async () => { vi.mocked(fetchColonyOverview).mockRejectedValue(new Error("boom")); renderCard(); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx index 32b5cbf..541da56 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { ChevronRight, Play, Sprout } from "lucide-react"; import { useColonyOverview } from "@/lib/apiaries/use-colony-overview"; +import type { ColonyOverview } from "@/lib/apiaries/field-types"; import { useCurrentUserId } from "@/lib/auth/auth-context"; import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels"; import { colonyStatusLight } from "@/lib/colonies/status-light"; @@ -10,6 +11,7 @@ import { todayLocalIsoDate } from "@/lib/dates"; import { startRound } from "@/lib/rounds/round-state"; import { StatusLightBadge } from "@/components/colonies/status-light-badge"; import { EmptyState } from "@/components/empty-state"; +import { LoadPackButton } from "@/components/offline/load-pack-button"; import { ErrorState, LoadingState } from "@/components/query-state"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -52,10 +54,49 @@ export function StartRoundButton({ apiary }: { apiary: ApiaryRef }) { ); } +interface ColonyOverviewLinksProps { + overview: ColonyOverview; + /** Where a colony row leads - the colony page, or its offline view. */ + hrefFor: (colonyId: string) => string; +} + +/** One row per living colony: code, traffic light (icon and word), status and reasons. */ +export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksProps) { + const today = todayLocalIsoDate(); + return overview.rows.map((row) => { + const { light, reasons } = colonyStatusLight(row, { + today, + inspectionsVisible: overview.inspectionsVisible, + }); + return ( + + + + {row.code} + + {COLONY_STATUS_LABELS[row.status]} + + {reasons.length > 0 ? ( + {reasons.join(" · ")} + ) : null} + + + + ); + }); +} + +function colonyPageHref(colonyId: string): string { + return `/colonies/${colonyId}`; +} + export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) { const query = useColonyOverview(apiary.id); const overview = query.data; - const today = todayLocalIsoDate(); return ( @@ -76,31 +117,10 @@ export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) { description="Sobald ein Volk hier steht, erscheint es mit seiner Ampel." /> ) : null} - {overview?.rows.map((row) => { - const { light, reasons } = colonyStatusLight(row, { - today, - inspectionsVisible: overview.inspectionsVisible, - }); - return ( - - - - {row.code} - - {COLONY_STATUS_LABELS[row.status]} - - {reasons.length > 0 ? ( - {reasons.join(" · ")} - ) : null} - - - - ); - })} + {overview ? : null} +
+ +
); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.spec.tsx new file mode 100644 index 0000000..a2b2782 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.spec.tsx @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { reportRequestOutcome } from "@/lib/offline/connectivity"; +import { EditTriggerButton } from "./edit-dialog-parts"; + +let sessionMode: "online" | "offline" = "online"; +vi.mock("@/lib/auth/auth-context", () => ({ useSessionMode: () => sessionMode })); + +describe("EditTriggerButton", () => { + afterEach(() => { + sessionMode = "online"; + reportRequestOutcome("reached-server"); + }); + + it("is a normal „Bearbeiten“ button while online", () => { + render(); + + expect(screen.getByRole("button", { name: "Bearbeiten" })).toBeEnabled(); + }); + + it("is disabled offline and says why", () => { + act(() => reportRequestOutcome("network-error")); + render(); + + expect(screen.getByRole("button", { name: "Bearbeiten braucht Netz" })).toBeDisabled(); + }); + + it("is disabled in an offline session even when the network looks fine", () => { + sessionMode = "offline"; + render(); + + expect(screen.getByRole("button", { name: "Bearbeiten braucht Netz" })).toBeDisabled(); + }); + + it("stays disabled when the caller disables it, and keeps the caller's other props", () => { + render(); + + expect(screen.getByTestId("edit")).toBeDisabled(); + expect(screen.getByTestId("edit")).toHaveTextContent("Bearbeiten"); + }); + + it("wraps its text on a narrow screen instead of overflowing", () => { + act(() => reportRequestOutcome("network-error")); + render(); + + expect(screen.getByRole("button")).toHaveClass("h-auto", "whitespace-normal"); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.tsx index 56d5e6b..a0db854 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/edit-dialog-parts.tsx @@ -3,16 +3,33 @@ import { ComponentProps } from "react"; import { Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useSessionMode } from "@/lib/auth/auth-context"; +import { useConnectivity } from "@/lib/offline/connectivity"; +import { cn } from "@/lib/utils"; /** * The "Bearbeiten" trigger of every record in a list: at least 40 px high on - * a phone, so it can be hit with a gloved thumb. + * a phone, so it can be hit with a gloved thumb. Saved records can only be + * changed with a connection (edits are not queued), so offline - or in an + * offline session without a token - it is disabled and says why. */ -export function EditTriggerButton(props: ComponentProps) { +export function EditTriggerButton({ + disabled, + className, + ...props +}: ComponentProps) { + const connectivity = useConnectivity(); + const sessionMode = useSessionMode(); + const needsNetwork = connectivity === "offline" || sessionMode === "offline"; return ( - ); } diff --git a/ApiCore-Frontend/src/app/(app)/feedings/new/feeding-form-values.ts b/ApiCore-Frontend/src/app/(app)/feedings/new/feeding-form-values.ts index d5c6c4d..d4ff14d 100644 --- a/ApiCore-Frontend/src/app/(app)/feedings/new/feeding-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/feedings/new/feeding-form-values.ts @@ -1,5 +1,7 @@ import type { createFeeding } from "@/lib/feedings/api"; import type { FeedType } from "@/lib/feedings/types"; +import type { BulkCreateFeedingInput } from "@/lib/bulk/api"; +import type { CaptureTarget } from "@/components/form/capture-target-toggle"; import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; import { emptyToNull } from "@/lib/forms/empty-to-null"; @@ -13,6 +15,10 @@ export const FEEDING_UNIT_OPTIONS: { value: string; label: string }[] = [ /** Everything the form holds, as plain values that survive a draft save. */ export interface FeedingFormValues { colonyId: string; + /** One colony, or the same feeding for several. */ + target: CaptureTarget; + bulkApiaryId: string | null; + bulkColonyIds: string[]; date: string; feedType: FeedType; amount: string; @@ -25,6 +31,9 @@ export interface FeedingFormValues { export function initialFeedingValues(colonyId: string): FeedingFormValues { return { colonyId, + target: "single", + bulkApiaryId: null, + bulkColonyIds: [], date: todayLocalIsoDate(), feedType: "SUGAR_WATER", amount: "", @@ -35,11 +44,9 @@ export function initialFeedingValues(colonyId: string): FeedingFormValues { }; } -export function toFeedingInput( - values: FeedingFormValues, -): Parameters[0] { +/** Everything of the feeding that is the same for one colony or for many. */ +function sharedFeedingFields(values: FeedingFormValues) { return { - colonyId: values.colonyId, date: toApiDate(values.date), feedType: values.feedType, amount: Number(values.amount), @@ -49,3 +56,14 @@ export function toFeedingInput( notes: emptyToNull(values.notes), }; } + +export function toFeedingInput( + values: FeedingFormValues, +): Parameters[0] { + return { colonyId: values.colonyId, ...sharedFeedingFields(values) }; +} + +/** One feeding for the chosen colonies; the batch number is what the user typed. */ +export function toBulkFeedingInput(values: FeedingFormValues): BulkCreateFeedingInput { + return { ...sharedFeedingFields(values), colonyIds: values.bulkColonyIds }; +} diff --git a/ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx index 313881d..a4b0b56 100644 --- a/ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import NewFeedingPage from "./page"; +import { toast } from "sonner"; import { createFeeding } from "@/lib/feedings/api"; +import { createFeedingsBulk } from "@/lib/bulk/api"; +import { queueBulk } from "@/lib/offline/db"; +import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; const push = vi.fn(); @@ -13,7 +17,19 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); vi.mock("@/lib/feedings/api", () => ({ createFeeding: vi.fn() })); -vi.mock("@/lib/offline/db", () => ({ queueFeeding: vi.fn() })); +vi.mock("@/lib/bulk/api", async (importOriginal) => ({ + ...(await importOriginal()), + createFeedingsBulk: vi.fn(), +})); +vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() })); +vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({ + ...(await importOriginal()), + listPacks: vi.fn().mockResolvedValue([]), + refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })); +vi.mock("@/lib/offline/db", () => ({ queueFeeding: vi.fn(), queueBulk: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } })); function renderPage() { const client = new QueryClient({ @@ -34,6 +50,11 @@ describe("NewFeedingPage", () => { { id: "colony-1", code: "V-01", status: "ACTIVE" }, ] as never); vi.mocked(createFeeding).mockReset(); + vi.mocked(createFeedingsBulk).mockReset(); + vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined); + vi.mocked(fetchApiaries) + .mockReset() + .mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never); }); it("marks what is required and keeps the extra fields folded", () => { @@ -86,4 +107,65 @@ describe("NewFeedingPage", () => { expect(screen.getByLabelText(/^Menge/)).toHaveFocus(); expect(createFeeding).not.toHaveBeenCalled(); }); + + describe("for several colonies", () => { + const BULK_COLONIES = [ + { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" }, + { id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" }, + { id: "colony-3", code: "V-03", status: "QUEENLESS", apiaryId: "apiary-1" }, + ]; + + async function fillBulkForm() { + vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never); + renderPage(); + await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" })); + await userEvent.click(screen.getByRole("combobox", { name: /Stand/ })); + await userEvent.click(await screen.findByRole("option", { name: "Waldrand" })); + await screen.findByText("3 von 3 Völkern gewählt"); + await userEvent.click(screen.getByRole("checkbox", { name: "V-02" })); + await userEvent.type(screen.getByLabelText(/^Menge/), "2"); + } + + it("bulk mode sends colonyIds with the shared feeding", async () => { + vi.mocked(createFeedingsBulk).mockResolvedValue({ bulkId: "b-1", records: [] } as never); + await fillBulkForm(); + + await userEvent.click(screen.getByRole("button", { name: "Fütterung speichern" })); + + await waitFor(() => expect(createFeedingsBulk).toHaveBeenCalledTimes(1)); + const [input, options] = vi.mocked(createFeedingsBulk).mock.calls[0]; + expect(input).toMatchObject({ + colonyIds: ["colony-1", "colony-3"], + feedType: "SUGAR_WATER", + amount: 2, + unit: "l", + }); + expect(input).not.toHaveProperty("colonyId"); + expect(options?.idempotencyKey).toBeTruthy(); + expect(createFeeding).not.toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith("Fütterung für 2 Völker gespeichert"); + expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"); + }); + + it("offline queues exactly one bulk entry", async () => { + vi.mocked(createFeedingsBulk).mockRejectedValue(new TypeError("Failed to fetch")); + await fillBulkForm(); + + await userEvent.click(screen.getByRole("button", { name: "Fütterung speichern" })); + + await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1)); + expect(vi.mocked(queueBulk).mock.calls[0][0]).toBe("feedings"); + expect(vi.mocked(queueBulk).mock.calls[0][1]).toMatchObject({ + colonyIds: ["colony-1", "colony-3"], + }); + await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1")); + }); + + it("says the batch number counts for every chosen colony", async () => { + await fillBulkForm(); + await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ })); + + expect(screen.getByText(/Gilt für alle gewählten Völker\./)).toBeInTheDocument(); + }); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx index ebd7689..535f508 100644 --- a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx @@ -6,6 +6,15 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Utensils } from "lucide-react"; import { createFeeding } from "@/lib/feedings/api"; +import { createFeedingsBulk } from "@/lib/bulk/api"; +import { + bulkDestination, + bulkQueuedText, + bulkSavedText, + bulkSelectionError, +} from "@/lib/bulk/capture-texts"; +import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies"; +import { useBulkCapture } from "@/lib/bulk/use-bulk-capture"; import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels"; import { queueFeeding } from "@/lib/offline/db"; import { useCaptureMutation } from "@/lib/offline/use-capture-mutation"; @@ -19,7 +28,8 @@ import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; -import { LivingColonySelectField } from "@/components/colony-select-field"; +import { BulkSubmitNote } from "@/components/form/bulk-submit-note"; +import { CaptureColonyField } from "@/components/form/capture-colony-field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; @@ -27,17 +37,27 @@ import { FEEDING_UNIT_OPTIONS, FeedingFormValues, initialFeedingValues, + toBulkFeedingInput, toFeedingInput, } from "./feeding-form-values"; -type RequiredKey = "colonyId" | "date" | "amount"; +type RequiredKey = "colonyId" | "bulkColonies" | "date" | "amount"; type FieldErrors = Partial>; -const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date", "amount"]; +const SHARED_NOTE = "Gilt für alle gewählten Völker."; +const BATCH_HINT = "Nummer der Futterpackung, meist „Ch.-B.“ oder „Lot“."; + +function requiredKeys(values: FeedingFormValues): RequiredKey[] { + return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "amount"]; +} function validate(values: FeedingFormValues): FieldErrors { const errors: FieldErrors = {}; - if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen."; + if (values.target === "bulk") { + errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds); + } else if (!values.colonyId) { + errors.colonyId = "Bitte ein Volk auswählen."; + } if (!values.date) errors.date = "Bitte ein Datum angeben."; if (!values.amount.trim()) { errors.amount = "Bitte eine Menge angeben."; @@ -72,11 +92,33 @@ export default function NewFeedingPage() { ) { setValues((current) => ({ ...current, [key]: value })); setDirty(true); - if ((REQUIRED_KEYS as string[]).includes(key)) { - setFieldErrors((current) => ({ ...current, [key]: undefined })); - } + // The colonies of a bulk capture are one field in three values. + const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key; + setFieldErrors((current) => ({ ...current, [field]: undefined })); } + const isBulk = values.target === "bulk"; + const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null); + + const bulkCapture = useBulkCapture({ + kind: "feedings", + create: createFeedingsBulk, + failureMessage: "Fütterung konnte nicht gespeichert werden.", + onSaved: (outcome, input) => { + draft.clear(); + const count = input.colonyIds.length; + if (outcome.via === "queued") { + toast.info(bulkQueuedText("Fütterung", count)); + } else { + for (const colonyId of input.colonyIds) { + void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "feedings"] }); + } + toast.success(bulkSavedText("Fütterung", count)); + } + router.push(bulkDestination(values.bulkApiaryId)); + }, + }); + const capture = useCaptureMutation({ create: createFeeding, queue: queueFeeding, @@ -106,20 +148,38 @@ export default function NewFeedingPage() { event.preventDefault(); const errors = validate(values); setFieldErrors(errors); - const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]); + const firstInvalid = requiredKeys(values).find((key) => errors[key]); if (firstInvalid) { document .querySelector(`[data-field="${firstInvalid}"]`) ?.focus(); return; } + if (isBulk) { + bulkCapture.submit(toBulkFeedingInput(values)); + return; + } capture.submit(toFeedingInput(values)); } + /** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */ + function resendWithoutRefused() { + const refused = new Set(bulkCapture.invalidColonyIds); + const next = { + ...values, + bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)), + }; + setValues(next); + if (next.bulkColonyIds.length > 0) { + bulkCapture.submit(toBulkFeedingInput(next)); + } + } + function restoreDraft() { const restored = draft.restore(); if (restored) { - setValues(restored); + // A draft from before the bulk choice existed lacks its values. + setValues({ ...initialFeedingValues(""), ...restored }); setDirty(true); } } @@ -152,11 +212,21 @@ export default function NewFeedingPage() { noValidate > - set("colonyId", colonyId)} - error={fieldErrors.colonyId} + set("target", target)} + colonyId={values.colonyId} + onColonyChange={(colonyId) => set("colonyId", colonyId)} + colonyError={fieldErrors.colonyId} + bulkApiaryId={values.bulkApiaryId} + onBulkApiaryChange={(apiaryId) => { + set("bulkApiaryId", apiaryId); + set("bulkColonyIds", []); + }} + bulkColonyIds={values.bulkColonyIds} + onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)} + bulkError={fieldErrors.bulkColonies} /> {({ id, describedBy, invalid }) => ( @@ -237,7 +307,7 @@ export default function NewFeedingPage() { {({ id, describedBy }) => ( + ) : capture.submitError ? (

- {capture.isPending + {capture.isPending || bulkCapture.isPending ? "Wird gespeichert…" : "Fütterung speichern"} diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx index 4d2f366..1d2c705 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -11,14 +11,15 @@ import type { DailyWeather } from "@/lib/weather/types"; import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates"; import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonyOverview } from "@/lib/apiaries/field-api"; -import { queueInspection } from "@/lib/offline/db"; +import type { StoredPhoto } from "@/lib/offline/db"; import { syncPendingEntries } from "@/lib/offline/sync"; +import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation"; import { - attachSessionToInspection, - attachSessionToQueuedInspection, + handOverSessionPhotos, discardSessionPhotos, listSessionPhotos, - releaseSessionPhotos, + moveDraftPhotos, + queueInspectionWithPhotos, } from "@/lib/photos/store"; import { DRAFT_PREFIX } from "@/lib/forms/draft-storage"; import { initialInspectionValues } from "./inspection-form-values"; @@ -47,16 +48,15 @@ vi.mock("@/lib/inspections/api", () => ({ })); vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); -vi.mock("@/lib/offline/db", () => ({ queueInspection: vi.fn() })); vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() })); vi.mock("@/lib/photos/store", () => ({ PHOTOS_PER_INSPECTION_MAX: 5, listSessionPhotos: vi.fn(), addDraftPhoto: vi.fn(), removePhoto: vi.fn(), - attachSessionToInspection: vi.fn(), - attachSessionToQueuedInspection: vi.fn(), - releaseSessionPhotos: vi.fn(), + handOverSessionPhotos: vi.fn(), + queueInspectionWithPhotos: vi.fn(), + moveDraftPhotos: vi.fn(), discardSessionPhotos: vi.fn(), })); @@ -66,11 +66,17 @@ function renderPage() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - return render( + const rendered = render( , ); + return { ...rendered, client }; +} + +/** jsdom applies no CSS: a class that would take the element out of the accessibility tree. */ +function hidesWhileEmpty(element: HTMLElement): boolean { + return [...element.classList].some((name) => /(^|:)hidden$/.test(name)); } describe("NewInspectionPage", () => { @@ -87,11 +93,12 @@ describe("NewInspectionPage", () => { vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] }); vi.mocked(toast.info).mockReset(); vi.mocked(toast.success).mockReset(); + vi.mocked(toast.error).mockReset(); permissions = new Set(); vi.mocked(listSessionPhotos).mockReset().mockResolvedValue([]); - vi.mocked(attachSessionToInspection).mockReset().mockResolvedValue(undefined); - vi.mocked(attachSessionToQueuedInspection).mockReset().mockResolvedValue(undefined); - vi.mocked(releaseSessionPhotos).mockReset().mockResolvedValue(undefined); + vi.mocked(handOverSessionPhotos).mockReset().mockResolvedValue(0); + vi.mocked(queueInspectionWithPhotos).mockReset().mockResolvedValue(0); + vi.mocked(moveDraftPhotos).mockReset().mockResolvedValue({ moved: 0, discarded: 0 }); vi.mocked(discardSessionPhotos).mockReset().mockResolvedValue(undefined); vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT); }); @@ -453,6 +460,15 @@ describe("NewInspectionPage", () => { expect(screen.getByRole("status")).toBeEmptyDOMElement(); }); + + it("keeps the weather announcement region present while it is empty", () => { + renderPage(); + + // Hidden while empty, it would swallow the first announcement. + const live = screen.getByRole("status"); + expect(live).toBeEmptyDOMElement(); + expect(hidesWhileEmpty(live)).toBe(false); + }); }); describe("in a stand round", () => { @@ -480,7 +496,6 @@ describe("NewInspectionPage", () => { overviewOf(["colony-1", "colony-2", "colony-3"]) as never, ); vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never); - vi.mocked(queueInspection).mockReset(); startRound("user-1", apiary, colonies); }); @@ -519,6 +534,21 @@ describe("NewInspectionPage", () => { expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-2", "colony-3"]); }); + it("does not bring back the draft of an inspection saved right after the last change", async () => { + const draftKey = `${DRAFT_PREFIX}user-1:inspection-new`; + renderPage(); + await screen.findByText("Runde Obstwiese: 1 von 3"); + + // Saved well within the draft's 600 ms delay; the form stays mounted behind the panel. + await userEvent.type(screen.getByLabelText(/^Wetter/), "x"); + await save(); + expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument(); + await act(() => new Promise((resolve) => setTimeout(resolve, 800))); + + // „Weiter mit B3“ would otherwise offer this saved entry as a draft. + expect(window.localStorage.getItem(draftKey)).toBeNull(); + }); + it("after save shows „Weiter mit B3“ instead of going to the colony", async () => { renderPage(); await screen.findByText("Runde Obstwiese: 1 von 3"); @@ -558,13 +588,12 @@ describe("NewInspectionPage", () => { it("an offline save also advances the round", async () => { vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch")); - vi.mocked(queueInspection).mockResolvedValue(undefined); renderPage(); await screen.findByText("Runde Obstwiese: 1 von 3"); await save(); expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument(); - expect(queueInspection).toHaveBeenCalled(); + expect(queueInspectionWithPhotos).toHaveBeenCalled(); expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-1"]); expect(push).not.toHaveBeenCalled(); }); @@ -595,10 +624,25 @@ describe("NewInspectionPage", () => { describe("photos", () => { const DRAFT_KEY = `${DRAFT_PREFIX}user-1:inspection-new`; + const PHOTO: StoredPhoto = { + id: "photo-1", + sessionId: "any", + ownerUserId: "user-1", + blob: new Blob(["jpeg"], { type: "image/jpeg" }), + fileName: "durchsicht-2026-10-13-1.jpg", + createdAt: "2026-10-13T08:16:00.000Z", + state: "draft", + uploadIdempotencyKey: "upload-key-0000000001", + }; + + beforeAll(() => { + // jsdom has no object URLs; the thumbnails only need a string. + URL.createObjectURL = vi.fn(() => "blob:photo"); + URL.revokeObjectURL = vi.fn(); + }); beforeEach(() => { permissions = new Set(["documents:upload"]); - vi.mocked(queueInspection).mockReset(); }); afterEach(() => { @@ -621,6 +665,19 @@ describe("NewInspectionPage", () => { ); } + /** Types into the form and waits until its draft is on the device. */ + async function typeUntilDrafted() { + await userEvent.type(screen.getByLabelText(/^Wetter/), "sonnig"); + await waitFor(() => expect(window.localStorage.getItem(DRAFT_KEY)).not.toBeNull(), { + timeout: 2000, + }); + } + + /** Lets already runnable promise callbacks run. */ + function settle(): Promise { + return act(() => new Promise((resolve) => setTimeout(resolve, 0))); + } + async function save() { await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" })); } @@ -635,52 +692,137 @@ describe("NewInspectionPage", () => { ); }); - it("hands the photos to the saved inspection and sends them in the background", async () => { + it("hands the photos to the saved inspection, sends them and refreshes what shows them", async () => { vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never); - renderPage(); + vi.mocked(handOverSessionPhotos).mockResolvedValue(2); + vi.mocked(syncPendingEntries).mockResolvedValue({ synced: 2, failed: 0, skippedForeign: 0 }); + const { client } = renderPage(); + const invalidate = vi.spyOn(client, "invalidateQueries"); await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled()); await save(); - await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1")); - expect(attachSessionToInspection).toHaveBeenCalledWith(shownSession(), "i-1"); - expect(vi.mocked(attachSessionToInspection).mock.invocationCallOrder[0]).toBeLessThan( + await waitFor(() => + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["documents", "INSPECTION", "i-1"] }), + ); + // The band's count and the inspection's document list. + expect(invalidate).toHaveBeenCalledWith({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); + expect(handOverSessionPhotos).toHaveBeenCalledWith({ + userId: "user-1", + sessionId: shownSession(), + inspectionId: "i-1", + }); + // A sync already running may have passed these photos: it runs once more. + expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }); + expect(vi.mocked(handOverSessionPhotos).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(syncPendingEntries).mock.invocationCallOrder[0], ); - expect(attachSessionToQueuedInspection).not.toHaveBeenCalled(); + expect(vi.mocked(syncPendingEntries).mock.invocationCallOrder[0]).toBeLessThan( + invalidate.mock.invocationCallOrder[invalidate.mock.invocationCallOrder.length - 1], + ); + expect(queueInspectionWithPhotos).not.toHaveBeenCalled(); }); - it("ties the photos to the queued inspection before it is queued", async () => { + it("hands over the photos of the form that was saved, even when a draft is continued meanwhile", async () => { + storeDraft("draft-session"); + let finishSaving: (inspection: never) => void = () => undefined; + vi.mocked(createInspection).mockReturnValue( + new Promise((resolve) => { + finishSaving = resolve as typeof finishSaving; + }), + ); + vi.mocked(handOverSessionPhotos).mockResolvedValue(0); + renderPage(); + await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled()); + const savedFormSession = shownSession(); + + await save(); + await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" })); + await waitFor(() => expect(shownSession()).toBe("draft-session")); + await act(async () => finishSaving({ id: "i-1", colonyId: "colony-1" } as never)); + + await waitFor(() => expect(handOverSessionPhotos).toHaveBeenCalled()); + expect(handOverSessionPhotos).toHaveBeenCalledWith({ + userId: "user-1", + sessionId: savedFormSession, + inspectionId: "i-1", + }); + }); + + it("sends nothing after an online save without photos", async () => { + vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never); + vi.mocked(handOverSessionPhotos).mockResolvedValue(0); + renderPage(); + + await save(); + + await waitFor(() => expect(handOverSessionPhotos).toHaveBeenCalled()); + await settle(); + expect(syncPendingEntries).not.toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith("/colonies/colony-1"); + }); + + it("clears the draft of the saved inspection at once, also when its photos must wait", async () => { + vi.mocked(listSessionPhotos).mockResolvedValue([PHOTO]); + vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never); + // The hand-over stays noted; the next start or sync finishes it. + vi.mocked(handOverSessionPhotos).mockRejectedValue( + new Error("The database connection is closing."), + ); + renderPage(); + expect(await screen.findByRole("img", { name: "Foto 1" })).toBeInTheDocument(); + await typeUntilDrafted(); + + await save(); + + // Never offered again: restoring it would save the inspection twice. + expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull(); + await waitFor(() => expect(toast.info).toHaveBeenCalledWith("Fotos werden später gesendet.")); + expect(toast.error).not.toHaveBeenCalled(); + expect(syncPendingEntries).not.toHaveBeenCalled(); + }); + + it("queues the inspection together with its photos", async () => { vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch")); - vi.mocked(queueInspection).mockResolvedValue(undefined); + vi.mocked(queueInspectionWithPhotos).mockResolvedValue(1); renderPage(); await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled()); await save(); - await waitFor(() => expect(queueInspection).toHaveBeenCalled()); - const [, idempotencyKey] = vi.mocked(queueInspection).mock.calls[0]; - expect(attachSessionToQueuedInspection).toHaveBeenCalledWith(shownSession(), idempotencyKey); - // A sync that sends the inspection right away already finds its photos. - expect(vi.mocked(attachSessionToQueuedInspection).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(queueInspection).mock.invocationCallOrder[0], + await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled()); + expect(queueInspectionWithPhotos).toHaveBeenCalledWith( + shownSession(), + expect.objectContaining({ colonyId: "colony-1" }), + expect.any(String), + "user-1", + ); + expect(handOverSessionPhotos).not.toHaveBeenCalled(); + expect(toast.info).toHaveBeenCalledWith( + "Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.", ); - expect(attachSessionToInspection).not.toHaveBeenCalled(); - expect(releaseSessionPhotos).not.toHaveBeenCalled(); }); - it("gives the photos back to the form when the inspection cannot be queued", async () => { + it("keeps the photos with the form when the inspection cannot be queued", async () => { vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch")); - vi.mocked(queueInspection).mockRejectedValue(new Error("QuotaExceededError")); + vi.mocked(queueInspectionWithPhotos).mockRejectedValue(new Error("QuotaExceededError")); renderPage(); await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled()); + const formSession = shownSession(); await save(); expect(await screen.findByRole("alert")).toHaveTextContent( "Durchsicht konnte nicht gespeichert werden.", ); - expect(releaseSessionPhotos).toHaveBeenCalledWith(shownSession()); + expect(queueInspectionWithPhotos).toHaveBeenCalledWith( + formSession, + expect.anything(), + expect.any(String), + "user-1", + ); + expect(shownSession()).toBe(formSession); + expect(push).not.toHaveBeenCalled(); }); it("„Verwerfen“ of an earlier draft also deletes its photos", async () => { @@ -693,8 +835,14 @@ describe("NewInspectionPage", () => { expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull(); }); - it("brings the photos of a restored draft back", async () => { + it("brings the photos of a restored draft back and takes the fresh form's photos along", async () => { storeDraft("draft-session-0001"); + let finishMove: (moved: { moved: number; discarded: number }) => void = () => undefined; + vi.mocked(moveDraftPhotos).mockReturnValue( + new Promise((resolve) => { + finishMove = resolve; + }), + ); renderPage(); await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled()); const freshSession = shownSession(); @@ -703,9 +851,34 @@ describe("NewInspectionPage", () => { await waitFor(() => expect(shownSession()).toBe("draft-session-0001")); expect(screen.getByDisplayValue("8")).toBeInTheDocument(); - // The fresh form's own photos give way to the restored entry, like its values. - expect(discardSessionPhotos).toHaveBeenCalledWith(freshSession); - expect(discardSessionPhotos).not.toHaveBeenCalledWith("draft-session-0001"); + // Photos taken before restoring join the restored entry - never deleted unasked. + expect(moveDraftPhotos).toHaveBeenCalledWith(freshSession, "draft-session-0001"); + expect(discardSessionPhotos).not.toHaveBeenCalled(); + const readsBefore = vi.mocked(listSessionPhotos).mock.calls.length; + + await act(async () => finishMove({ moved: 2, discarded: 0 })); + + // Read again once they are there. + await waitFor(() => + expect(vi.mocked(listSessionPhotos).mock.calls.length).toBeGreaterThan(readsBefore), + ); + expect(shownSession()).toBe("draft-session-0001"); + expect(toast.info).not.toHaveBeenCalled(); + }); + + it("says how many photos did not fit into the restored draft and were deleted", async () => { + storeDraft("draft-session-0001"); + vi.mocked(moveDraftPhotos).mockResolvedValue({ moved: 1, discarded: 2 }); + renderPage(); + + await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" })); + + await waitFor(() => + expect(toast.info).toHaveBeenCalledWith( + "2 Fotos wurden nicht übernommen und verworfen. Höchstens 5 Fotos je Durchsicht.", + ), + ); + expect(discardSessionPhotos).not.toHaveBeenCalled(); }); }); }); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index 7a8e9f0..b004d81 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -17,17 +17,18 @@ import { undoTakeOver, type TakeoverField, } from "@/lib/inspections/takeover"; -import { useLastInspection } from "@/lib/inspections/use-last-inspection"; -import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types"; +import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection"; +import type { CreateInspectionInput } from "@/lib/inspections/types"; import { useCurrentUserId } from "@/lib/auth/auth-context"; -import { queueInspection } from "@/lib/offline/db"; +import { photosNotTakenOver } from "@/lib/offline/queue-texts"; import { syncPendingEntries } from "@/lib/offline/sync"; -import { useCaptureMutation } from "@/lib/offline/use-capture-mutation"; +import { OFFLINE_QUEUE_QUERY_KEY, useCaptureMutation } from "@/lib/offline/use-capture-mutation"; import { - attachSessionToInspection, - attachSessionToQueuedInspection, + PHOTOS_PER_INSPECTION_MAX, discardSessionPhotos, - releaseSessionPhotos, + handOverSessionPhotos, + moveDraftPhotos, + queueInspectionWithPhotos, } from "@/lib/photos/store"; import { toCalendarDay, todayLocalIsoDate } from "@/lib/dates"; import { captureBackTarget } from "@/lib/forms/back-target"; @@ -51,7 +52,7 @@ import { NumberStepper } from "@/components/form/number-stepper"; import { PreviousValueHint } from "@/components/form/previous-value-hint"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; -import { PhotoCaptureField } from "@/components/photos/photo-capture-field"; +import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field"; import { FEED_STATUS_OPTIONS, INSPECTION_OBSERVATIONS, @@ -63,6 +64,7 @@ import { const UNDO_TOAST_MS = 8000; const WEATHER_UPDATED = "Wetter aktualisiert"; const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag"; +const PHOTOS_LATER = "Fotos werden später gesendet."; const TAKEOVER_LABELS: Record = { queenSeen: "Königin gesehen", @@ -139,43 +141,8 @@ function useWeatherAutofill(values: InspectionFormValues, setValues: SetValues): return `${WEATHER_UPDATED} (${day}): ${summary}`; } -/** - * Offline save: the form's photos are tied to the queue entry's key before - * the entry exists, so a sync that sends the inspection right away already - * finds them. If queueing fails, they belong to the open form again. - */ -async function queueInspectionWithPhotos( - photoSessionId: string, - input: CreateInspectionInput, - idempotencyKey: string, - ownerUserId: string, -): Promise { - await attachSessionToQueuedInspection(photoSessionId, idempotencyKey); - try { - await queueInspection(input, idempotencyKey, ownerUserId); - } catch (error) { - await releaseSessionPhotos(photoSessionId); - throw error; - } -} - -/** - * Online save: the photos belong to the saved inspection and go up in the - * background - inside a sync run, so a logout's cancelSync() stops them too. - */ -async function sendPhotosOf( - photoSessionId: string, - inspectionId: string, - userId: string | null, -): Promise { - await attachSessionToInspection(photoSessionId, inspectionId); - if (userId) { - await syncPendingEntries(userId); - } -} - interface LastInspectionBarProps { - last: Inspection; + last: LastInspection; onTakeOverAll: () => void; } @@ -247,12 +214,72 @@ function InspectionCaptureForm({ if (key === "date") setDateError(null); } + /** How many photos the form's photo section shows (none while it is hidden). */ + function shownPhotoCount(photoSessionId: string): number { + return ( + queryClient.getQueryData([...PHOTO_SESSION_QUERY_KEY, photoSessionId])?.length ?? + 0 + ); + } + + /** + * Online save: the photos belong to the saved inspection and go up in the + * background - inside a sync run, so a logout's cancelSync() stops them too. + * If they cannot be tied to it right now, the hand-over stays noted for this + * user: the next start or sync finishes it, and keeps the photos till then. + */ + async function handOverPhotos(photoSessionId: string, inspectionId: string): Promise { + // Inside the signed-in app there always is a user; nothing is sent without one. + if (!userId) { + return; + } + let handedOver: number; + try { + handedOver = await handOverSessionPhotos({ + userId, + sessionId: photoSessionId, + inspectionId, + }); + } catch { + if (shownPhotoCount(photoSessionId) > 0) { + toast.info(PHOTOS_LATER); + } + return; + } + if (handedOver === 0) { + return; + } + try { + // A sync already running may have passed these photos: it runs once more. + await syncPendingEntries(userId, { afterChange: true }); + } catch { + toast.error("Fotos konnten nicht gesendet werden."); + } + // The band's count, and the documents of the inspection (DocumentUploadCard). + void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); + void queryClient.invalidateQueries({ queryKey: ["documents", "INSPECTION", inspectionId] }); + } + + // The photo session of the form that was submitted: "Weitermachen" while the + // save runs swaps the form's session, and the photos of the saved entry must + // not follow it. + const submittedPhotoSession = useRef(values.photoSessionId); + const capture = useCaptureMutation({ create: createInspection, - queue: (input: CreateInspectionInput, idempotencyKey, ownerUserId) => - queueInspectionWithPhotos(values.photoSessionId, input, idempotencyKey, ownerUserId), + // Queued together with its photos in one step: none is left waiting for + // an entry that was never queued. + queue: async (input: CreateInspectionInput, idempotencyKey, ownerUserId) => { + await queueInspectionWithPhotos( + submittedPhotoSession.current, + input, + idempotencyKey, + ownerUserId, + ); + }, failureMessage: "Durchsicht konnte nicht gespeichert werden.", onSaved: (outcome, input) => { + // At once: a draft of a saved inspection would be offered to save again. draft.clear(); const savedOffline = outcome.via === "queued"; if (outcome.via === "queued") { @@ -264,9 +291,7 @@ function InspectionCaptureForm({ queryKey: ["colonies", outcome.result.colonyId, "inspections"], }); toast.success("Durchsicht gespeichert"); - void sendPhotosOf(values.photoSessionId, outcome.result.id, userId).catch(() => - toast.error("Fotos konnten nicht gesendet werden."), - ); + void handOverPhotos(submittedPhotoSession.current, outcome.result.id); } const advanced = stand.round ? stand.markDone(input.colonyId) : null; if (advanced) { @@ -289,19 +314,35 @@ function InspectionCaptureForm({ document.getElementById("inspection-date")?.focus(); return; } + submittedPhotoSession.current = values.photoSessionId; capture.submit(toInspectionInput(values)); } + /** + * Photos taken before „Weitermachen" join the restored entry, as far as the + * photo limit allows. Those that do not fit are deleted, and the toast says + * so - the form could not show them any more. + */ + async function takePhotosAlong(fromSessionId: string, toSessionId: string): Promise { + try { + const { discarded } = await moveDraftPhotos(fromSessionId, toSessionId); + if (discarded > 0) { + toast.info( + `${photosNotTakenOver(discarded)}. Höchstens ${PHOTOS_PER_INSPECTION_MAX} Fotos je Durchsicht.`, + ); + } + } catch { + toast.error("Fotos konnten nicht übernommen werden."); + } + void queryClient.invalidateQueries({ queryKey: PHOTO_SESSION_QUERY_KEY }); + } + function restoreDraft() { const restored = draft.restore(); if (restored) { + const freshSessionId = values.photoSessionId; // A draft from before photos existed has no session: the form keeps its own. - const photoSessionId = restored.photoSessionId ?? values.photoSessionId; - if (photoSessionId !== values.photoSessionId) { - // Like the typed values, photos taken before restoring give way to the - // restored entry. Unsaved photos left behind are purged after 3 days. - void discardSessionPhotos(values.photoSessionId).catch(() => undefined); - } + const photoSessionId = restored.photoSessionId ?? freshSessionId; // A draft from before these fields existed lacks them. setValues({ ...initialInspectionValues(restored.colonyId), @@ -310,6 +351,9 @@ function InspectionCaptureForm({ weatherAuto: restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""), }); setDirty(true); + if (photoSessionId !== freshSessionId) { + void takePhotosAlong(freshSessionId, photoSessionId); + } } } @@ -605,7 +649,8 @@ function InspectionCaptureForm({ title="Wetter" description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar" > -

+ {/* Present while empty (only visually hidden), or its first message is not announced. */} +

{weatherNotice}

diff --git a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx index 5f934b4..663f486 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.spec.tsx @@ -1,15 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { Card, CardContent } from "@/components/ui/card"; import { StickySubmit } from "@/components/form/sticky-submit"; -import { purgeStaleDraftPhotos } from "@/lib/photos/store"; +import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store"; import AppLayout from "./layout"; +const pushMock = vi.fn(); vi.mock("next/navigation", () => ({ - useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + useRouter: () => ({ replace: vi.fn(), push: pushMock }), usePathname: () => "/inspections/new", })); +let sessionMode: "online" | "offline" = "online"; + vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ user: { @@ -23,20 +27,43 @@ vi.mock("@/lib/auth/auth-context", () => ({ hasAvatar: false, }, isLoading: false, + sessionMode, logout: vi.fn(), }), })); +vi.mock("@/components/auth/logout-confirm-dialog", () => ({ + LogoutConfirmDialog: ({ + open, + userId, + onLoggedOut, + }: { + open: boolean; + userId: string; + onLoggedOut: () => void; + }) => + open ? ( +
+ {userId} + +
+ ) : null, +})); + vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null })); vi.mock("@/components/offline/offline-band", () => ({ OfflineBand: ({ userId, pageContentRef, + syncEnabled, }: { userId: string; pageContentRef: { current: HTMLElement | null }; + syncEnabled?: boolean; }) => ( -
+
{userId}
- +
+ router.push("/login")} + />
); } diff --git a/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts b/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts index efc4776..50756e1 100644 --- a/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts +++ b/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts @@ -46,6 +46,11 @@ describe("nav-config", () => { expect(admin?.items.map((item) => item.href)).toEqual(["/settings/audit-log"]); }); + it("shows the System entry only with system:read", () => { + expect(hrefs([])).not.toContain("/settings/system"); + expect(hrefs(["system:read"])).toContain("/settings/system"); + }); + it("hides items the user may not see", () => { expect(hrefs([])).not.toContain("/winter"); expect(hrefs(["winter-checks:read"])).toContain("/winter"); diff --git a/ApiCore-Frontend/src/app/(app)/nav-config.ts b/ApiCore-Frontend/src/app/(app)/nav-config.ts index fb4e0dc..2839d02 100644 --- a/ApiCore-Frontend/src/app/(app)/nav-config.ts +++ b/ApiCore-Frontend/src/app/(app)/nav-config.ts @@ -1,4 +1,5 @@ import { + Activity, BarChart3, Boxes, CalendarDays, @@ -146,6 +147,12 @@ export const ADMIN_GROUP: NavGroup = { icon: History, permission: "audit:read", }, + { + label: "System", + href: "/settings/system", + icon: Activity, + permission: "system:read", + }, ], }; diff --git a/ApiCore-Frontend/src/app/(app)/offline/apiary/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/offline/apiary/page.spec.tsx new file mode 100644 index 0000000..faba961 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/offline/apiary/page.spec.tsx @@ -0,0 +1,86 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { getDb } from "@/lib/offline/db"; +import { deleteAllPacks } from "@/lib/apiaries/offline-pack"; +import { + offlinePack, + packColony, + packTask, + storedPack, +} from "@/lib/apiaries/offline-pack.fixtures"; +import OfflineApiaryPage from "./page"; + +let searchString = "id=apiary-1"; +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(searchString), +})); +vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })); +// No network: every server request fails the way it does in airplane mode. +vi.mock("@/lib/auth/api-client", async (importOriginal) => ({ + ...(await importOriginal()), + apiFetch: vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))), +})); + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("OfflineApiaryPage", () => { + beforeEach(async () => { + searchString = "id=apiary-1"; + await deleteAllPacks(); + }); + + it("lists the pack's colonies with their light, linking to the offline colony view", async () => { + const pack = offlinePack({ + apiaryId: "apiary-1", + apiaryName: "Obstwiese", + colonies: [packColony("colony-1", "A1"), packColony("colony-2", "A2", { status: "QUEENLESS" })], + tasks: [packTask("t-1", "Zaun prüfen", { relatedType: "APIARY", relatedId: "apiary-1" })], + }); + const db = await getDb(); + await db.put("offline-packs", storedPack(pack)); + renderPage(); + + expect(await screen.findByRole("heading", { level: 1, name: "Obstwiese" })).toBeInTheDocument(); + const second = screen.getByRole("link", { name: /A2/ }); + expect(second).toHaveAttribute("href", "/offline/colony?id=colony-2"); + expect(within(second).getByText("Kritisch")).toBeInTheDocument(); + expect(second).toHaveTextContent(/Weisellos/); + expect(screen.getByText("Zaun prüfen")).toBeInTheDocument(); + expect(screen.getByText(/^Geladen vor/)).toBeInTheDocument(); + // The stand round works offline too: its colonies come from the pack. + await vi.waitFor(() => + expect(screen.getByRole("button", { name: "Runde starten" })).toHaveAttribute( + "href", + "/inspections/new?colonyId=colony-1&round=apiary-1", + ), + ); + }); + + it("leads back to the apiary page once the network is back", async () => { + const db = await getDb(); + await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1" }))); + renderPage(); + + expect(await screen.findByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute( + "href", + "/apiaries/apiary-1", + ); + }); + + it("explains how to load an apiary that is not loaded", async () => { + searchString = "id=apiary-unknown"; + renderPage(); + + expect(await screen.findByText("Dieser Stand ist nicht offline geladen.")).toBeInTheDocument(); + expect(screen.getByText("Lade den Stand bei Netz mit „Für offline laden“.")).toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/offline/apiary/page.tsx b/ApiCore-Frontend/src/app/(app)/offline/apiary/page.tsx new file mode 100644 index 0000000..720b039 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/offline/apiary/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { MapPin } from "lucide-react"; +import { useCurrentUserId } from "@/lib/auth/auth-context"; +import type { StoredPack } from "@/lib/offline/db"; +import { DetailHeader } from "@/components/detail-header"; +import { PackAgeNote, useOwnPack } from "@/components/offline/load-pack-button"; +import { ColonyOverviewLinks, StartRoundButton } from "../../apiaries/[id]/apiary-colonies-card"; +import { + NotLoadedOffline, + OFFLINE_HOME, + OfflineLoading, + OfflinePageFrame, + OfflineReadError, + OfflineSection, + OpenTaskList, + ReturnOnline, + returnHrefFrom, +} from "../offline-parts"; + +function offlineColonyHref(colonyId: string): string { + return `/offline/colony?id=${encodeURIComponent(colonyId)}`; +} + +/** + * The apiary as it was loaded for offline use - where a QR/NFC link to + * /apiaries/:id lands without network. Read-only; the stand round starts + * from here like online. + */ +export default function OfflineApiaryPage() { + return ( + }> + + + ); +} + +function OfflineApiary() { + const searchParams = useSearchParams(); + const apiaryId = searchParams.get("id") ?? ""; + const returnHref = returnHrefFrom(searchParams, `/apiaries/${encodeURIComponent(apiaryId)}`); + const userId = useCurrentUserId(); + const query = useOwnPack(apiaryId, userId); + + if (query.isLoading) { + return ; + } + if (query.isError) { + return void query.refetch()} />; + } + if (!query.data) { + return ; + } + return ( + + ); +} + +interface OfflineApiaryViewProps { + stored: StoredPack; + /** When the pack was read from storage - the "now" of its age. */ + readAt: number; + /** The page the user wanted, for when the network is back. */ + returnHref: string; +} + +function OfflineApiaryView({ stored, readAt, returnHref }: OfflineApiaryViewProps) { + const { apiary, overview, tasks } = stored.pack; + const apiaryTasks = + tasks?.filter((task) => task.relatedType === "APIARY" && task.relatedId === apiary.id) ?? null; + + return ( + + } + backHref={OFFLINE_HOME} + backLabel="Offline geladene Stände" + /> + + +
+ + {overview.rows.length === 0 ? ( +

Keine Völker an diesem Stand.

+ ) : ( + + )} +
+ {apiaryTasks !== null ? ( + + + + ) : null} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/offline/colony/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/offline/colony/page.spec.tsx new file mode 100644 index 0000000..efddbd8 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/offline/colony/page.spec.tsx @@ -0,0 +1,219 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { getDb, type StoredPack } from "@/lib/offline/db"; +import { deleteAllPacks } from "@/lib/apiaries/offline-pack"; +import { + DAY_MS, + MINUTE_MS, + offlinePack, + packColony, + packInspection, + packQueen, + packTask, + storedPack, +} from "@/lib/apiaries/offline-pack.fixtures"; +import OfflineColonyPage from "./page"; + +let searchString = "id=colony-1"; +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(searchString), +})); +vi.mock("@/lib/auth/auth-context", () => ({ + useCurrentUserId: () => "user-1", + useAuth: () => ({ hasPermission: () => true }), +})); + +function day(isoDay: string): string { + return `${isoDay}T00:00:00.000Z`; +} + +function germanDay(timestamp: number): string { + const date = new Date(timestamp); + const dd = String(date.getDate()).padStart(2, "0"); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + return `${dd}.${mm}.${date.getFullYear()}`; +} + +const COLONY = packColony("colony-1", "A1", { currentQueenId: "queen-1", strength: 9 }); + +function fullPack() { + return offlinePack({ + apiaryId: "apiary-1", + apiaryName: "Obstwiese", + colonies: [COLONY, packColony("colony-2", "A2")], + queens: [packQueen("queen-1", "K-12", { hatchYear: 2025, breed: "Carnica" })], + inspections: [ + packInspection("i-1", "colony-1", day("2026-10-01"), { colonyStrength: 9, queenSeen: true }), + packInspection("i-2", "colony-1", day("2026-09-20")), + packInspection("i-3", "colony-1", day("2026-09-10")), + packInspection("i-4", "colony-1", day("2026-08-30")), + packInspection("i-5", "colony-2", day("2026-09-25")), + ], + tasks: [ + packTask("t-1", "Futter nachgeben", { relatedType: "COLONY", relatedId: "colony-1", dueDate: day("2026-10-15") }), + packTask("t-2", "Anderes Volk", { relatedType: "COLONY", relatedId: "colony-2" }), + packTask("t-3", "Zaun prüfen", { relatedType: "APIARY", relatedId: "apiary-1" }), + ], + }); +} + +async function putPacks(...packs: StoredPack[]): Promise { + const db = await getDb(); + await Promise.all(packs.map((pack) => db.put("offline-packs", pack))); +} + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("OfflineColonyPage", () => { + beforeEach(async () => { + searchString = "id=colony-1"; + await deleteAllPacks(); + }); + + it("renders colony, queen, last three inspections and open tasks from the pack", async () => { + await putPacks(storedPack(fullPack())); + renderPage(); + + const title = await screen.findByRole("heading", { level: 1, name: "A1" }); + const header = title.closest("header") as HTMLElement; + expect(within(header).getByText(/Obstwiese/)).toBeInTheDocument(); + // The same traffic light as online: icon and word. + expect(within(header).getByText("Gut")).toBeInTheDocument(); + expect(within(header).getByText("Aktiv")).toBeInTheDocument(); + const colony = screen.getByRole("region", { name: "Volk" }); + expect(within(colony).getByText("9 Waben")).toBeInTheDocument(); + + const queen = screen.getByRole("region", { name: "Königin" }); + expect(within(queen).getByText("K-12")).toBeInTheDocument(); + expect(within(queen).getByText(/Carnica/)).toBeInTheDocument(); + expect(within(queen).getByText(/2025/)).toBeInTheDocument(); + + const inspections = screen.getByRole("region", { name: "Letzte Durchsichten" }); + expect(within(inspections).getByText("01.10.2026")).toBeInTheDocument(); + expect(within(inspections).getByText("20.09.2026")).toBeInTheDocument(); + expect(within(inspections).getByText("10.09.2026")).toBeInTheDocument(); + expect(within(inspections).queryByText("30.08.2026")).not.toBeInTheDocument(); + expect(within(inspections).queryByText("25.09.2026")).not.toBeInTheDocument(); + + const tasks = screen.getByRole("region", { name: "Offene Aufgaben" }); + expect(within(tasks).getByText("Futter nachgeben")).toBeInTheDocument(); + expect(within(tasks).queryByText("Anderes Volk")).not.toBeInTheDocument(); + expect(within(tasks).queryByText("Zaun prüfen")).not.toBeInTheDocument(); + + expect(screen.getByRole("button", { name: /Durchsicht erfassen/ })).toHaveAttribute( + "href", + "/inspections/new?colonyId=colony-1", + ); + expect(screen.getByRole("button", { name: /Varroa-Messung/ })).toHaveAttribute( + "href", + "/varroa/new?colonyId=colony-1", + ); + expect(screen.getByRole("button", { name: /Fütterung/ })).toHaveAttribute( + "href", + "/feedings/new?colonyId=colony-1", + ); + // Read-only: nothing here edits the pack's records. + expect(screen.queryByRole("button", { name: /Bearbeiten/ })).not.toBeInTheDocument(); + }); + + it("leaves out sections the user may not read", async () => { + await putPacks(storedPack({ ...fullPack(), queens: null, inspections: null, tasks: null })); + renderPage(); + + await screen.findByRole("heading", { level: 1, name: "A1" }); + expect(screen.queryByRole("region", { name: "Königin" })).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Letzte Durchsichten" })).not.toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Offene Aufgaben" })).not.toBeInTheDocument(); + }); + + it("shows the not-loaded message for an unknown colony", async () => { + searchString = "id=colony-unknown"; + await putPacks( + storedPack(fullPack()), + // Another account's pack never counts as loaded for this user. + storedPack( + offlinePack({ apiaryId: "apiary-9", colonies: [packColony("colony-unknown", "X1")] }), + { ownerUserId: "user-2" }, + ), + ); + renderPage(); + + expect(await screen.findByText("Dieses Volk ist nicht offline geladen.")).toBeInTheDocument(); + expect( + screen.getByText("Lade den Stand bei Netz mit „Für offline laden“."), + ).toBeInTheDocument(); + expect(screen.queryByRole("heading", { level: 1, name: "X1" })).not.toBeInTheDocument(); + }); + + it("shows the pack age and the stale hint", async () => { + const savedAt = Date.now() - 2 * DAY_MS; + await putPacks(storedPack(fullPack(), { savedAt: new Date(savedAt).toISOString() })); + renderPage(); + + expect(await screen.findByText("Geladen vor 2 Tagen")).toBeInTheDocument(); + expect( + screen.getByText(`Stand-Daten von ${germanDay(savedAt)} – bei Netz neu laden`), + ).toBeInTheDocument(); + }); + + it("leads back to the page the user wanted once the network is back", async () => { + searchString = "id=colony-1&from=%2Fcolonies%2Fcolony-1%3Ftab%3Dvarroa"; + await putPacks(storedPack(fullPack())); + renderPage(); + + const back = await screen.findByRole("button", { name: /Mit Netz öffnen/ }); + expect(back).toHaveAttribute("href", "/colonies/colony-1?tab=varroa"); + }); + + it("never follows a foreign return address - it leads to the colony's own page", async () => { + searchString = "id=colony-1&from=https%3A%2F%2Fevil.example%2Fcolonies"; + await putPacks(storedPack(fullPack())); + renderPage(); + + const back = await screen.findByRole("button", { name: /Mit Netz öffnen/ }); + expect(back).toHaveAttribute("href", "/colonies/colony-1"); + }); + + it("offers to try again while still offline", async () => { + Object.defineProperty(window.navigator, "onLine", { configurable: true, get: () => false }); + try { + await putPacks(storedPack(fullPack())); + renderPage(); + + const retry = await screen.findByRole("button", { name: /Erneut versuchen/ }); + expect(retry).toHaveAttribute("href", "/colonies/colony-1"); + } finally { + Reflect.deleteProperty(window.navigator, "onLine"); + } + }); + + it("also offers the way back for a colony that is not loaded", async () => { + searchString = "id=colony-unknown&from=%2Fcolonies%2Fcolony-unknown"; + renderPage(); + + await screen.findByText("Dieses Volk ist nicht offline geladen."); + expect(screen.getByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute( + "href", + "/colonies/colony-unknown", + ); + }); + + it("shows a fresh pack's age without the stale hint", async () => { + await putPacks( + storedPack(fullPack(), { savedAt: new Date(Date.now() - 5 * MINUTE_MS).toISOString() }), + ); + renderPage(); + + expect(await screen.findByText("Geladen vor 5 Minuten")).toBeInTheDocument(); + expect(screen.queryByText(/bei Netz neu laden/)).not.toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/offline/colony/page.tsx b/ApiCore-Frontend/src/app/(app)/offline/colony/page.tsx new file mode 100644 index 0000000..f8df522 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/offline/colony/page.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { Suspense } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Layers } from "lucide-react"; +import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; +import { + OFFLINE_PACKS_QUERY_KEY, + findPackForColony, + type PackColony, + type PackInspection, + type PackQueen, +} from "@/lib/apiaries/offline-pack"; +import { + COLONY_CONDITION_LABELS, + COLONY_STATUS_LABELS, + COLONY_TYPE_LABELS, +} from "@/lib/colonies/labels"; +import { colonyStatusLight } from "@/lib/colonies/status-light"; +import type { ColonyCondition } from "@/lib/colonies/types"; +import { todayLocalIsoDate } from "@/lib/dates"; +import type { StoredPack } from "@/lib/offline/db"; +import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels"; +import { StatusLightBadge } from "@/components/colonies/status-light-badge"; +import { DetailHeader, DetailStatus } from "@/components/detail-header"; +import { PackAgeNote } from "@/components/offline/load-pack-button"; +import { Button } from "@/components/ui/button"; +import { QUICK_ACTIONS } from "../../quick-actions"; +import { + NotLoadedOffline, + OfflineLoading, + OfflinePageFrame, + OfflineReadError, + OfflineSection, + OpenTaskList, + ReturnOnline, + calendarDayText, + returnHrefFrom, +} from "../offline-parts"; + +const LAST_INSPECTIONS = 3; +const INSPECTION_FORM = "/inspections/new"; +// The capture forms that take a colony and open without network (app shell). +const COLONY_CAPTURE_FORMS = ["/varroa/new", "/swarm-control/new", "/treatments/new", "/feedings/new"]; + +/** + * A colony as it was loaded for offline use - where a QR/NFC link to + * /colonies/:id lands without network. Read-only, with the way to the + * capture forms. + */ +export default function OfflineColonyPage() { + return ( + }> + + + ); +} + +function OfflineColony() { + const searchParams = useSearchParams(); + const colonyId = searchParams.get("id") ?? ""; + const returnHref = returnHrefFrom(searchParams, `/colonies/${encodeURIComponent(colonyId)}`); + const userId = useCurrentUserId(); + const query = useQuery({ + queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "colony", colonyId], + queryFn: () => findPackForColony(colonyId, userId ?? ""), + enabled: userId !== null && colonyId !== "", + // Reads this device's storage, never the network. + networkMode: "always", + }); + + if (query.isLoading) { + return ; + } + if (query.isError) { + return void query.refetch()} />; + } + const colony = query.data?.pack.colonies.find((candidate) => candidate.id === colonyId); + if (!query.data || !colony) { + return ; + } + return ( + + ); +} + +function captureHref(form: string, colonyId: string): string { + return `${form}?colonyId=${encodeURIComponent(colonyId)}`; +} + +interface OfflineColonyViewProps { + stored: StoredPack; + colony: PackColony; + /** When the pack was read from storage - the "now" of its age. */ + readAt: number; + /** The page the user wanted, for when the network is back. */ + returnHref: string; +} + +function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColonyViewProps) { + const { hasPermission } = useAuth(); + const { pack } = stored; + const row = pack.overview.rows.find((candidate) => candidate.id === colony.id); + const light = row + ? colonyStatusLight(row, { + today: todayLocalIsoDate(), + inspectionsVisible: pack.overview.inspectionsVisible, + }) + : null; + const inspection = QUICK_ACTIONS.find((action) => action.href === INSPECTION_FORM); + const queen = pack.queens?.find((candidate) => candidate.id === colony.currentQueenId) ?? null; + const tasks = + pack.tasks?.filter((task) => task.relatedType === "COLONY" && task.relatedId === colony.id) ?? + null; + + return ( + + + {light ? : null} + + + } + primaryAction={ + inspection && hasPermission(inspection.permission) ? ( + + ) : undefined + } + backHref={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`} + backLabel="Zum Stand" + /> + + + {light && light.reasons.length > 0 ? ( +

{light.reasons.join(" · ")}

+ ) : null} +
+ + {pack.queens !== null ? : null} + {pack.inspections !== null ? ( + + ) : null} + {tasks !== null ? ( + + + + ) : null} + +
+
+ ); +} + +function conditionText(condition: ColonyCondition | null): string { + return condition ? COLONY_CONDITION_LABELS[condition] : "–"; +} + +function Fact({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ColonyFacts({ colony }: { colony: PackColony }) { + return ( + +
+ + + + + +
+ {colony.notes ?

{colony.notes}

: null} +
+ ); +} + +function QueenFacts({ queen }: { queen: PackQueen | null }) { + if (!queen) { + return ( + +

Keine Königin erfasst.

+
+ ); + } + const marking = [queen.markingColor, queen.markingNumber].filter(Boolean).join(" "); + return ( + +

{queen.code}

+

+ {[ + queen.hatchYear !== null ? `Jahrgang ${queen.hatchYear}` : null, + queen.breed, + marking ? `Zeichen ${marking}` : null, + QUEEN_STATUS_LABELS[queen.status], + ] + .filter(Boolean) + .join(" · ")} +

+
+ ); +} + +function inspectionSummary(inspection: PackInspection): string { + const parts = [ + inspection.colonyStrength !== null ? `${inspection.colonyStrength} Waben` : null, + inspection.broodFrames !== null ? `${inspection.broodFrames} Brutwaben` : null, + inspection.queenSeen !== null + ? `Königin ${inspection.queenSeen ? "gesehen" : "nicht gesehen"}` + : null, + ].filter(Boolean); + return parts.length > 0 ? parts.join(" · ") : "Ohne Angaben zu Stärke und Königin"; +} + +function LastInspections({ + inspections, + colonyId, +}: { + inspections: readonly PackInspection[]; + colonyId: string; +}) { + const latest = inspections + .filter((inspection) => inspection.colonyId === colonyId) + .sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt)) + .slice(0, LAST_INSPECTIONS); + return ( + + {latest.length === 0 ?

Noch keine Durchsicht.

: null} + {latest.map((inspection) => ( +
+

{calendarDayText(inspection.date)}

+

{inspectionSummary(inspection)}

+ {inspection.notes ?

{inspection.notes}

: null} +
+ ))} +
+ ); +} + +interface MoreCapturesProps { + colonyId: string; + hasPermission: (permission: string) => boolean; +} + +function MoreCaptures({ colonyId, hasPermission }: MoreCapturesProps) { + const actions = QUICK_ACTIONS.filter( + (action) => COLONY_CAPTURE_FORMS.includes(action.href) && hasPermission(action.permission), + ); + if (actions.length === 0) { + return null; + } + return ( + +
+ {actions.map((action) => ( + + ))} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/offline/offline-parts.tsx b/ApiCore-Frontend/src/app/(app)/offline/offline-parts.tsx new file mode 100644 index 0000000..26ab221 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/offline/offline-parts.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { ReactNode, useId } from "react"; +import Link from "next/link"; +import { CloudOff, RotateCw, Wifi } from "lucide-react"; +import type { PackTask } from "@/lib/apiaries/offline-pack"; +import { toCalendarDay } from "@/lib/dates"; +import { useConnectivity } from "@/lib/offline/connectivity"; +import { safeReturnPath } from "@/lib/offline/return-path"; +import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels"; +import { EmptyState } from "@/components/empty-state"; +import { ErrorState, LoadingState } from "@/components/query-state"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +// Shared by the offline pages (/offline/apiary, /offline/colony): read-only +// views of an offline pack, opened by the service worker without network. + +export const OFFLINE_HOME = "/offline"; + +/** A calendar-day timestamp of the API ("2026-10-01T00:00:00.000Z") as "01.10.2026". */ +export function calendarDayText(isoTimestamp: string): string { + const [year, month, day] = toCalendarDay(isoTimestamp).split("-"); + return `${day}.${month}.${year}`; +} + +export function OfflinePageFrame({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function OfflineLoading({ what }: { what: string }) { + return ( + + + + ); +} + +export function OfflineReadError({ onRetry }: { onRetry: () => void }) { + return ( + + + + ); +} + +/** + * Where "Mit Netz öffnen" leads: the page the user wanted (`from`, from the + * service worker - only a path of this app), else `fallbackHref`. + */ +export function returnHrefFrom(searchParams: URLSearchParams, fallbackHref: string): string { + return safeReturnPath(searchParams.get("from")) ?? fallbackHref; +} + +/** + * The way back out of the offline view: once the network is back, to the + * page the user wanted; until then a new attempt. A full page load, so the + * service worker decides again. + */ +export function ReturnOnline({ href }: { href: string }) { + const isOnline = useConnectivity() === "online"; + const Icon = isOnline ? Wifi : CloudOff; + return ( +
+

+ + {isOnline ? "Netz ist wieder da." : "Kein Netz – du siehst die gespeicherten Daten."} +

+ +
+ ); +} + +interface NotLoadedOfflineProps { + title: string; + /** The online page of the record, for when the network is back. */ + returnHref: string; +} + +/** What to do when the deep-linked record was never loaded for offline use. */ +export function NotLoadedOffline({ title, returnHref }: NotLoadedOfflineProps) { + return ( + + + }> + Offline geladene Stände + + } + /> + + ); +} + +/** A card that is a named region, so it can be found by its title. */ +export function OfflineSection({ title, children }: { title: string; children: ReactNode }) { + const headingId = useId(); + return ( + + + +

{title}

+
+
+ {children} +
+ ); +} + +export function OpenTaskList({ tasks }: { tasks: readonly PackTask[] }) { + if (tasks.length === 0) { + return

Keine offenen Aufgaben.

; + } + return ( +
    + {tasks.map((task) => ( +
  • +

    {task.title}

    +

    + {[ + task.dueDate ? `fällig ${calendarDayText(task.dueDate)}` : null, + `Priorität ${TASK_PRIORITY_LABELS[task.priority]}`, + ] + .filter(Boolean) + .join(" · ")} +

    +
  • + ))} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/offline/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/offline/page.spec.tsx new file mode 100644 index 0000000..db4105c --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/offline/page.spec.tsx @@ -0,0 +1,73 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { getDb } from "@/lib/offline/db"; +import { deleteAllPacks } from "@/lib/apiaries/offline-pack"; +import { offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures"; +import OfflinePage from "./page"; + +let searchString = ""; +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(searchString), +})); +vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })); + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("OfflinePage", () => { + beforeEach(async () => { + searchString = ""; + await deleteAllPacks(); + }); + + it("lists the apiaries loaded for offline use, by name", async () => { + const db = await getDb(); + await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-2", apiaryName: "Waldrand" }))); + await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1", apiaryName: "Obstwiese" }))); + await db.put( + "offline-packs", + storedPack(offlinePack({ apiaryId: "apiary-3", apiaryName: "Fremd" }), { ownerUserId: "user-2" }), + ); + renderPage(); + + const links = await screen.findAllByRole("link", { name: /Obstwiese|Waldrand/ }); + expect(links.map((link) => link.getAttribute("href"))).toEqual([ + "/offline/apiary?id=apiary-1", + "/offline/apiary?id=apiary-2", + ]); + expect(screen.queryByText("Fremd")).not.toBeInTheDocument(); + }); + + it("explains how to prepare for offline use when nothing is loaded", async () => { + renderPage(); + + expect(await screen.findByText("Noch kein Stand offline geladen")).toBeInTheDocument(); + expect(screen.getByText("Lade einen Stand bei Netz mit „Für offline laden“.")).toBeInTheDocument(); + }); + + it("leads back to the page the user wanted once the network is back", async () => { + searchString = "from=%2Fstatistics%3Fyear%3D2026"; + renderPage(); + + expect(await screen.findByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute( + "href", + "/statistics?year=2026", + ); + }); + + it("offers no way back to a foreign address", async () => { + searchString = "from=%2F%2Fevil.example"; + renderPage(); + + await screen.findByText("Noch kein Stand offline geladen"); + expect(screen.queryByRole("button", { name: /Mit Netz öffnen/ })).not.toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/offline/page.tsx b/ApiCore-Frontend/src/app/(app)/offline/page.tsx new file mode 100644 index 0000000..762efdb --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/offline/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Suspense } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronRight, CloudOff, LayoutDashboard } from "lucide-react"; +import { useCurrentUserId } from "@/lib/auth/auth-context"; +import { safeReturnPath } from "@/lib/offline/return-path"; +import { OFFLINE_PACKS_QUERY_KEY, listPacks, packAgeText } from "@/lib/apiaries/offline-pack"; +import { EmptyState } from "@/components/empty-state"; +import { PageHeader } from "@/components/page-header"; +import { ErrorState, LoadingState } from "@/components/query-state"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ReturnOnline } from "./offline-parts"; + +/** The page the service worker could not open (`from`), once the network is back. */ +function ReturnToWantedPage() { + const href = safeReturnPath(useSearchParams().get("from")); + return href ? : null; +} + +/** + * Where the service worker sends a page it cannot open without network: + * the apiaries loaded for offline use. Static - the HTML is cached as part of + * the app shell and reads this device's storage only. + */ +export default function OfflinePage() { + const userId = useCurrentUserId(); + const query = useQuery({ + queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "list"], + queryFn: async () => + (await listPacks(userId ?? "")).sort((a, b) => + a.pack.apiary.name.localeCompare(b.pack.apiary.name, "de"), + ), + enabled: userId !== null, + networkMode: "always", + }); + // Ages are told as of reading the packs (refreshed on every visit). + const now = query.dataUpdatedAt; + + return ( +
+ + + + + + + +

Offline geladene Stände

+
+
+ + {query.isLoading ? : null} + {query.isError ? ( + void query.refetch()} isRetrying={query.isFetching} /> + ) : null} + {query.data?.length === 0 ? ( + + ) : null} + {query.data?.map((stored) => ( + + + {stored.pack.apiary.name} + + Geladen {packAgeText(stored.savedAt, now)} + + + + + ))} + +
+
+ +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.spec.tsx new file mode 100644 index 0000000..131182f --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.spec.tsx @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { deactivateAccount } from "@/lib/profile/api"; +import { DangerZoneSection } from "./danger-zone-section"; + +const pushMock = vi.fn(); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/lib/profile/api", () => ({ deactivateAccount: vi.fn() })); + +const logoutMock = vi.fn(); +vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ logout: logoutMock }) })); + +function renderZone() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +async function confirmDeactivation() { + await userEvent.click(screen.getByRole("button", { name: "Konto löschen" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.type(within(dialog).getByLabelText("Passwort"), "secret-password"); + await userEvent.click(within(dialog).getByRole("button", { name: "Konto löschen" })); +} + +describe("DangerZoneSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + logoutMock.mockResolvedValue(undefined); + }); + + it("signs out and deletes the unsent entries of a deactivated account", async () => { + vi.mocked(deactivateAccount).mockResolvedValue(undefined); + renderZone(); + + await confirmDeactivation(); + + // Nobody can ever send them: they must not wait for the next account. + await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true })); + expect(pushMock).toHaveBeenCalledWith("/login"); + }); + + it("stays signed in and keeps the entries when the account could not be deactivated", async () => { + vi.mocked(deactivateAccount).mockRejectedValue(new Error("wrong password")); + renderZone(); + + await confirmDeactivation(); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(logoutMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.tsx b/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.tsx index 0d2ec4e..159d564 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.tsx @@ -37,7 +37,8 @@ function DeactivateAccountDialog() { mutationFn: () => deactivateAccount(currentPassword), onSuccess: async () => { toast.success("Konto deaktiviert"); - await logout(); + // The account is gone: its unsent entries can never be sent by anyone. + await logout({ discardQueue: true }); router.push("/login"); }, onError: () => setError("Passwort ist falsch oder das Konto kann nicht deaktiviert werden."), diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/webhook-deliveries-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/webhook-deliveries-dialog.spec.tsx new file mode 100644 index 0000000..0de6158 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/webhook-deliveries-dialog.spec.tsx @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WebhookDeliveriesDialog } from "./webhook-deliveries-dialog"; +import { fetchWebhookDeliveries } from "@/lib/webhooks/api"; + +vi.mock("@/lib/webhooks/api", () => ({ fetchWebhookDeliveries: vi.fn() })); + +function renderDialog() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("WebhookDeliveriesDialog", () => { + afterEach(() => vi.clearAllMocks()); + + it("shows the delivery log of a webhook", async () => { + vi.mocked(fetchWebhookDeliveries).mockResolvedValue([ + { + id: "d1", + event: "harvest.created", + status: "DELIVERED", + attempts: 1, + lastStatusCode: 200, + lastError: null, + createdAt: "2026-10-13T08:00:00.000Z", + deliveredAt: "2026-10-13T08:00:01.000Z", + }, + { + id: "d2", + event: "task.created", + status: "FAILED", + attempts: 8, + lastStatusCode: 500, + lastError: "Zeitüberschreitung", + createdAt: "2026-10-12T08:00:00.000Z", + deliveredAt: null, + }, + { + id: "d3", + event: "task.completed", + status: "PENDING", + attempts: 0, + lastStatusCode: null, + lastError: null, + createdAt: "2026-10-12T07:00:00.000Z", + deliveredAt: null, + }, + ]); + const user = userEvent.setup(); + renderDialog(); + + expect(fetchWebhookDeliveries).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Zustellprotokoll" })); + + expect(await screen.findByRole("heading", { name: "Zustellprotokoll" })).toBeInTheDocument(); + expect(await screen.findByText("Ernte erfasst")).toBeInTheDocument(); + expect(screen.getByText("Zugestellt")).toBeInTheDocument(); + expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument(); + expect(screen.getByText("Offen")).toBeInTheDocument(); + expect(screen.getByText("HTTP 200")).toBeInTheDocument(); + expect(screen.getByText("HTTP 500")).toBeInTheDocument(); + expect(screen.getByText("8 Versuche")).toBeInTheDocument(); + expect(screen.getByText("1 Versuch")).toBeInTheDocument(); + expect(fetchWebhookDeliveries).toHaveBeenCalledWith("s1"); + }); + + it("says so when nothing was delivered yet", async () => { + vi.mocked(fetchWebhookDeliveries).mockResolvedValue([]); + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByRole("button", { name: "Zustellprotokoll" })); + + expect(await screen.findByText("Noch keine Zustellungen.")).toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/webhook-deliveries-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/webhook-deliveries-dialog.tsx new file mode 100644 index 0000000..83e3a88 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/webhook-deliveries-dialog.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { CircleCheck, CircleX, Clock } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchWebhookDeliveries } from "@/lib/webhooks/api"; +import { WEBHOOK_EVENT_LABELS } from "@/lib/webhooks/labels"; +import { WEBHOOK_DELIVERY_STATUS_LABELS, attemptsLabel } from "@/lib/system/labels"; +import type { WebhookDeliveryStatus, WebhookEvent } from "@/lib/webhooks/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { QueryState } from "@/components/query-state"; + +const STATUS_ICONS: Record = { + PENDING: Clock, + PROCESSING: Clock, + DELIVERED: CircleCheck, + FAILED: CircleX, +}; + +function DeliveryLog({ subscriptionId }: { subscriptionId: string }) { + const query = useQuery({ + queryKey: ["webhook-deliveries", subscriptionId], + queryFn: () => fetchWebhookDeliveries(subscriptionId), + }); + + return ( + Noch keine Zustellungen.

} + > + {(deliveries) => ( +
    + {deliveries.map((delivery) => { + const Icon = STATUS_ICONS[delivery.status]; + return ( +
  • +
    + + + {WEBHOOK_DELIVERY_STATUS_LABELS[delivery.status]} + + + {WEBHOOK_EVENT_LABELS[delivery.event as WebhookEvent] ?? delivery.event} + +
    +

    + {new Date(delivery.createdAt).toLocaleString("de-DE")} + {attemptsLabel(delivery.attempts)} + {delivery.lastStatusCode ? HTTP {delivery.lastStatusCode} : null} +

    + {delivery.lastError ? ( +

    {delivery.lastError}

    + ) : null} +
  • + ); + })} +
+ )} +
+ ); +} + +/** The newest deliveries of one webhook - loaded only once the dialog opens. */ +export function WebhookDeliveriesDialog({ + subscriptionId, + url, +}: { + subscriptionId: string; + url: string; +}) { + const [open, setOpen] = useState(false); + + return ( + + Zustellprotokoll} /> + + + Zustellprotokoll + {url} + + {open ? : null} + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx index 11db482..2afe73d 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx @@ -12,6 +12,7 @@ import { EmptyState } from "@/components/empty-state"; import { QueryState } from "@/components/query-state"; import { CreateWebhookDialog } from "./create-webhook-dialog"; import { EditWebhookDialog } from "./edit-webhook-dialog"; +import { WebhookDeliveriesDialog } from "./webhook-deliveries-dialog"; /** One card per connection - events in words, not technical keys. */ export function WebhooksTab() { @@ -77,6 +78,7 @@ export function WebhooksTab() {
+ = {}): BackupSchedule lastBackupSizeBytes: 10 * MB, lastBackupExceedsImportLimit: false, envOverrides: { storagePath: true, cronSchedule: true, retentionCount: true }, + lastJobRun: { + status: "SUCCEEDED", + startedAt: "2026-10-13T01:00:02.000Z", + finishedAt: "2026-10-13T01:00:09.000Z", + error: null, + }, ...overrides, }; } @@ -93,6 +99,61 @@ describe("BackupPage", () => { expect(screen.queryByText(LIMIT_WARNING)).not.toBeInTheDocument(); }); + it("shows that the schedule is checked hourly and when the job last ran", async () => { + vi.mocked(getBackupSchedule).mockResolvedValue(schedule()); + + renderWithClient(); + + const lastRun = new Date("2026-10-13T01:00:02.000Z").toLocaleString("de-DE"); + expect( + await screen.findByText(`Wird stündlich geprüft · letzter Lauf ${lastRun}`), + ).toBeInTheDocument(); + expect(screen.queryByText(/Fehlgeschlagen/)).not.toBeInTheDocument(); + }); + + it("shows why the last job run failed", async () => { + vi.mocked(getBackupSchedule).mockResolvedValue( + schedule({ + lastJobRun: { + status: "FAILED", + startedAt: "2026-10-13T01:00:02.000Z", + finishedAt: "2026-10-13T01:00:05.000Z", + error: "EACCES: permission denied", + }, + }), + ); + + renderWithClient(); + + const lastRun = new Date("2026-10-13T01:00:02.000Z").toLocaleString("de-DE"); + expect( + await screen.findByText( + `Wird stündlich geprüft · letzter Lauf ${lastRun} · Fehlgeschlagen: EACCES: permission denied`, + ), + ).toBeInTheDocument(); + }); + + it("says so while the job has not run yet", async () => { + vi.mocked(getBackupSchedule).mockResolvedValue(schedule({ lastJobRun: null })); + + renderWithClient(); + + expect( + await screen.findByText("Wird stündlich geprüft · noch kein Lauf"), + ).toBeInTheDocument(); + }); + + it("labels the newest backup file as the last backup, not as the last run", async () => { + vi.mocked(getBackupSchedule).mockResolvedValue(schedule()); + + renderWithClient(); + + expect(await screen.findByText("Letzte Sicherung")).toBeInTheDocument(); + expect( + screen.getByText(new Date("2026-10-12T03:00:00.000Z").toLocaleString("de-DE")), + ).toBeInTheDocument(); + }); + it("warns after a manual export that is larger than the import limit, but still downloads it", async () => { vi.mocked(getBackupSchedule).mockResolvedValue(schedule({ importLimitBytes: 10 })); vi.mocked(exportBackup).mockResolvedValue({ diff --git a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx index a7acddf..2e75e5d 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx @@ -14,7 +14,7 @@ import { } from "@/lib/backup/api"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/lib/auth/auth-context"; -import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types"; +import { BackupJobRunStatus, BackupScheduleStatus, BackupSettings } from "@/lib/backup/types"; import { unsealedImportWarning } from "@/lib/backup/import-summary"; import { importCsv } from "@/lib/import/api"; import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types"; @@ -224,11 +224,25 @@ function BackupSection() { function formatDateTime(value: string | null): string { if (!value) { - return "Noch nicht gelaufen"; + return "Noch keine Sicherung"; } return new Date(value).toLocaleString("de-DE"); } +// The schedule is checked by an hourly CronJob, not by a timer in the API: +// what that job did last is what tells whether backups still happen. +function jobRunSummary(run: BackupJobRunStatus | null): string { + const checked = "Wird stündlich geprüft"; + if (!run) { + return `${checked} · noch kein Lauf`; + } + const lastRun = `${checked} · letzter Lauf ${formatDateTime(run.startedAt)}`; + if (run.status === "FAILED") { + return run.error ? `${lastRun} · Fehlgeschlagen: ${run.error}` : `${lastRun} · Fehlgeschlagen`; + } + return run.status === "RUNNING" ? `${lastRun} · läuft gerade` : lastRun; +} + // Mounted only once `settings` has arrived, so its useState initial values // already reflect the fetched data - matches the pattern already used for // WebsiteSettingsForm in social-media-tab.tsx. @@ -399,10 +413,21 @@ function ScheduleSection() {
{status.cronSchedule}
Aufbewahrung
{status.retentionCount} neueste Backups
-
Letzter Lauf
+
Letzte Sicherung
{formatDateTime(status.lastRunAt)}
)} + {status ? ( +

+ {jobRunSummary(status.lastJobRun)} +

+ ) : null} + ); +} + +function useRetry(mutationFn: (id: string) => Promise) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => mutationFn(id), + onSuccess: () => { + toast.success("Wird erneut versucht"); + return queryClient.invalidateQueries({ queryKey: SYSTEM_QUERY_KEY }); + }, + onError: (error) => toast.error(errorMessage(error, "Erneuter Versuch fehlgeschlagen")), + }); +} + +function FailedEvents({ canManage }: { canManage: boolean }) { + const query = useQuery({ queryKey: FAILED_EVENTS_QUERY_KEY, queryFn: fetchFailedDomainEvents }); + const retry = useRetry(retryDomainEvent); + + return ( + + {(events) => ( + + {events.map((event) => ( +
  • +
    + {eventLabel(event.type)} + + {formatDateTime(event.occurredAt)} · {attemptsLabel(event.attempts)} + + {event.failedHandlers.map((handler) => ( +

    + {handler.handler} + {handler.lastError ? `: ${handler.lastError}` : ""} +

    + ))} +
    + retry.mutate(event.id)} + /> +
  • + ))} +
    + )} +
    + ); +} + +function FailedDeliveries({ canManage }: { canManage: boolean }) { + const query = useQuery({ + queryKey: FAILED_DELIVERIES_QUERY_KEY, + queryFn: fetchFailedWebhookDeliveries, + }); + const retry = useRetry(retryWebhookDelivery); + + return ( + + {(deliveries) => ( + + {deliveries.map((delivery) => ( +
  • +
    + {delivery.targetHost} + {webhookEventLabel(delivery.event)} + + {formatDateTime(delivery.createdAt)} · {attemptsLabel(delivery.attempts)} + {delivery.lastStatusCode ? ` · HTTP ${delivery.lastStatusCode}` : ""} + + {delivery.lastError ? ( +

    {delivery.lastError}

    + ) : null} +
    + retry.mutate(delivery.id)} + /> +
  • + ))} +
    + )} +
    + ); +} + +function QueueSection({ + pending, + failed, + oldestPendingAgeSeconds, + canManage, +}: { + pending: number; + failed: number; + oldestPendingAgeSeconds: number | null; + canManage: boolean; +}) { + const isStale = + oldestPendingAgeSeconds !== null && oldestPendingAgeSeconds > QUEUE_STALE_WARNING_SECONDS; + return ( + + + Ereignis-Warteschlange + + + {isStale ? : null} +
    + {pending === 0 || oldestPendingAgeSeconds === null ? ( +

    Keine offenen Ereignisse

    + ) : ( + <> +

    Offen: {pending}

    +

    + Ältestes offenes Ereignis: {formatAge(oldestPendingAgeSeconds)} +

    + + )} +

    Fehlgeschlagen: {failed}

    +
    + +
    +
    + ); +} + +export default function SystemPage() { + const { user } = useAuth(); + const canManage = Boolean(user?.permissions.includes("system:manage")); + const overviewQuery = useQuery({ queryKey: OVERVIEW_QUERY_KEY, queryFn: fetchSystemOverview }); + + return ( +
    + + + {(overview) => ( + <> + + + + + Webhook-Zustellungen + + +

    + Offen: {overview.webhookDeliveries.pending} · Fehlgeschlagen:{" "} + {overview.webhookDeliveries.failed} +

    + +
    +
    + + )} +
    +
    + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx index 014efa4..f36f52d 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx @@ -3,8 +3,14 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query"; import NewTreatmentPage from "./page"; +import { toast } from "sonner"; import { createTreatment, fetchTreatmentPrefill } from "@/lib/treatments/api"; +import { createTreatmentsBulk } from "@/lib/bulk/api"; +import { queueBulk } from "@/lib/offline/db"; +import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack"; +import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; +import { ApiError } from "@/lib/auth/api-client"; const push = vi.fn(); vi.mock("next/navigation", () => ({ @@ -16,7 +22,19 @@ vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn(), fetchTreatmentPrefill: vi.fn(), })); -vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn() })); +vi.mock("@/lib/bulk/api", async (importOriginal) => ({ + ...(await importOriginal()), + createTreatmentsBulk: vi.fn(), +})); +vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() })); +vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({ + ...(await importOriginal()), + listPacks: vi.fn().mockResolvedValue([]), + refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })); +vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn(), queueBulk: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } })); /** jsdom reports online; a test can take the device offline until it ends. */ function goOffline() { @@ -47,6 +65,12 @@ describe("NewTreatmentPage", () => { { id: "colony-1", code: "V-01", status: "ACTIVE" }, ] as never); vi.mocked(createTreatment).mockReset(); + vi.mocked(createTreatmentsBulk).mockReset(); + vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined); + vi.mocked(refreshPacksAfterSync).mockClear(); + vi.mocked(fetchApiaries) + .mockReset() + .mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never); vi.mocked(fetchTreatmentPrefill).mockReset().mockResolvedValue(null); }); @@ -297,4 +321,165 @@ describe("NewTreatmentPage", () => { expect(vi.mocked(createTreatment).mock.calls[0][0]).toMatchObject({ supplier: null }); }); }); + + describe("for several colonies", () => { + const BULK_COLONIES = [ + { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" }, + { id: "colony-2", code: "V-02", status: "WEAK", apiaryId: "apiary-1" }, + ]; + const SAVED = { bulkId: "b-1", records: [] }; + const SAVE = { name: "Behandlung speichern" }; + + async function fillBulkForm() { + vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never); + renderPage(); + await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" })); + await userEvent.click(screen.getByRole("combobox", { name: /Stand/ })); + await userEvent.click(await screen.findByRole("option", { name: "Waldrand" })); + await screen.findByText("2 von 2 Völkern gewählt"); + await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar"); + await userEvent.type(screen.getByLabelText(/Chargennummer/), "Ch-4711"); + } + + it("bulk mode sends one POST /treatments/bulk with colonyIds and the shared fields", async () => { + vi.mocked(createTreatmentsBulk).mockResolvedValue(SAVED as never); + await fillBulkForm(); + + expect(screen.getByText(/Gilt für alle gewählten Völker\./)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", SAVE)); + + await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1)); + const [input, options] = vi.mocked(createTreatmentsBulk).mock.calls[0]; + expect(input).toMatchObject({ + colonyIds: ["colony-1", "colony-2"], + product: "Oxuvar", + batchNumber: "Ch-4711", + }); + expect(input).not.toHaveProperty("colonyId"); + expect(options?.idempotencyKey).toBeTruthy(); + expect(createTreatment).not.toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith("Behandlung für 2 Völker gespeichert"); + expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"); + }); + + it("sends only the colonies that stay selected", async () => { + vi.mocked(createTreatmentsBulk).mockResolvedValue(SAVED as never); + await fillBulkForm(); + await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" })); + + await userEvent.click(screen.getByRole("button", SAVE)); + + await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1)); + expect(vi.mocked(createTreatmentsBulk).mock.calls[0][0].colonyIds).toEqual(["colony-1"]); + }); + + it("asks for a colony instead of sending nothing", async () => { + await fillBulkForm(); + await userEvent.click(screen.getByRole("button", { name: "Alle abwählen" })); + + await userEvent.click(screen.getByRole("button", SAVE)); + + expect(await screen.findByText("Bitte mindestens ein Volk wählen.")).toBeInTheDocument(); + expect(createTreatmentsBulk).not.toHaveBeenCalled(); + }); + + it("offline queues exactly one bulk entry", async () => { + vi.mocked(createTreatmentsBulk).mockRejectedValue(new TypeError("Failed to fetch")); + await fillBulkForm(); + + await userEvent.click(screen.getByRole("button", SAVE)); + + await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1)); + const [kind, input, key, owner] = vi.mocked(queueBulk).mock.calls[0]; + expect(kind).toBe("treatments"); + expect(input).toMatchObject({ colonyIds: ["colony-1", "colony-2"], product: "Oxuvar" }); + expect(key).toBe(vi.mocked(createTreatmentsBulk).mock.calls[0][1]?.idempotencyKey); + expect(owner).toBe("user-1"); + expect(createTreatment).not.toHaveBeenCalled(); + await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1")); + }); + + it("a 400 names the ended colony by code and keeps the form", async () => { + vi.mocked(createTreatmentsBulk).mockRejectedValue( + new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", { + invalidColonyIds: ["colony-2"], + }), + ); + await fillBulkForm(); + + await userEvent.click(screen.getByRole("button", SAVE)); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Einige Völker sind nicht (mehr) erfassbar: V-02"); + expect(screen.getByLabelText(/^Mittel/)).toHaveValue("Oxuvar"); + expect(screen.getByLabelText(/Chargennummer/)).toHaveValue("Ch-4711"); + expect(push).not.toHaveBeenCalled(); + expect(queueBulk).not.toHaveBeenCalled(); + // The pack of the refused colony is reloaded at once. + await waitFor(() => + expect(refreshPacksAfterSync).toHaveBeenCalledWith( + "user-1", + expect.any(Number), + undefined, + { force: true, colonyIds: ["colony-2"] }, + ), + ); + }); + + it("„Ohne V-02 senden“ sends the rest under a key of its own", async () => { + vi.mocked(createTreatmentsBulk) + .mockRejectedValueOnce( + new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", { + invalidColonyIds: ["colony-2"], + }), + ) + .mockResolvedValue(SAVED as never); + await fillBulkForm(); + await userEvent.click(screen.getByRole("button", SAVE)); + + await userEvent.click(await screen.findByRole("button", { name: "Ohne V-02 senden" })); + + await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(2)); + const [first, second] = vi.mocked(createTreatmentsBulk).mock.calls; + expect(second[0].colonyIds).toEqual(["colony-1"]); + expect(second[1]?.idempotencyKey).not.toBe(first[1]?.idempotencyKey); + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith("Behandlung für 1 Volk gespeichert"), + ); + }); + + it("shows „wird bereits verarbeitet“ as a request to wait, not as an error", async () => { + vi.mocked(createTreatmentsBulk).mockRejectedValue( + new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"), + ); + await fillBulkForm(); + + await userEvent.click(screen.getByRole("button", SAVE)); + + expect( + await screen.findByText("Wird gerade verarbeitet. Bitte gleich noch einmal speichern."), + ).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("repeats the key for the same selection and renews it for another", async () => { + vi.mocked(createTreatmentsBulk).mockRejectedValue(new ApiError(400, "Menge fehlt")); + await fillBulkForm(); + const save = screen.getByRole("button", SAVE); + + await userEvent.click(save); + await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1)); + await userEvent.click(save); + await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(2)); + await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" })); + await userEvent.click(save); + await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(3)); + + const keys = vi + .mocked(createTreatmentsBulk) + .mock.calls.map(([, options]) => options?.idempotencyKey); + expect(keys[1]).toBe(keys[0]); + expect(keys[2]).not.toBe(keys[0]); + }); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx index 23216e8..fd40999 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx @@ -6,6 +6,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Syringe } from "lucide-react"; import { createTreatment } from "@/lib/treatments/api"; +import { createTreatmentsBulk } from "@/lib/bulk/api"; +import { bulkDestination, bulkQueuedText, bulkSavedText, bulkSelectionError } from "@/lib/bulk/capture-texts"; +import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies"; +import { useBulkCapture } from "@/lib/bulk/use-bulk-capture"; import { queueTreatment } from "@/lib/offline/db"; import { useCaptureMutation } from "@/lib/offline/use-capture-mutation"; import { captureBackTarget } from "@/lib/forms/back-target"; @@ -19,24 +23,35 @@ import { FormBackLink } from "@/components/form/form-back-link"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; -import { LivingColonySelectField } from "@/components/colony-select-field"; +import { BulkSubmitNote } from "@/components/form/bulk-submit-note"; +import { CaptureColonyField } from "@/components/form/capture-colony-field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; import { TreatmentFormValues, initialTreatmentValues, + toBulkTreatmentInput, toTreatmentInput, } from "./treatment-form-values"; -type RequiredKey = "colonyId" | "date" | "product"; +type RequiredKey = "colonyId" | "bulkColonies" | "date" | "product"; type FieldErrors = Partial>; -const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date", "product"]; +const SHARED_NOTE = "Gilt für alle gewählten Völker."; +const BATCH_HINT = "Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“."; + +function requiredKeys(values: TreatmentFormValues): RequiredKey[] { + return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "product"]; +} function validate(values: TreatmentFormValues): FieldErrors { const errors: FieldErrors = {}; - if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen."; + if (values.target === "bulk") { + errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds); + } else if (!values.colonyId) { + errors.colonyId = "Bitte ein Volk auswählen."; + } if (!values.date) errors.date = "Bitte ein Datum angeben."; if (!values.product.trim()) errors.product = "Bitte das Mittel angeben."; return errors; @@ -69,11 +84,34 @@ export default function NewTreatmentPage() { setValues((current) => ({ ...current, [key]: value })); setDirty(true); markEdited(key); - if ((REQUIRED_KEYS as string[]).includes(key)) { - setFieldErrors((current) => ({ ...current, [key]: undefined })); - } + // Whatever was wrong with the field is wrong no longer; the colonies of a + // bulk capture are one field in three values. + const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key; + setFieldErrors((current) => ({ ...current, [field]: undefined })); } + const isBulk = values.target === "bulk"; + const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null); + + const bulkCapture = useBulkCapture({ + kind: "treatments", + create: createTreatmentsBulk, + failureMessage: "Behandlung konnte nicht gespeichert werden.", + onSaved: (outcome, input) => { + draft.clear(); + const count = input.colonyIds.length; + if (outcome.via === "queued") { + toast.info(bulkQueuedText("Behandlung", count)); + } else { + for (const colonyId of input.colonyIds) { + void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "treatments"] }); + } + toast.success(bulkSavedText("Behandlung", count)); + } + router.push(bulkDestination(values.bulkApiaryId)); + }, + }); + const capture = useCaptureMutation({ create: createTreatment, queue: queueTreatment, @@ -100,20 +138,35 @@ export default function NewTreatmentPage() { event.preventDefault(); const errors = validate(values); setFieldErrors(errors); - const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]); + const firstInvalid = requiredKeys(values).find((key) => errors[key]); if (firstInvalid) { document .querySelector(`[data-field="${firstInvalid}"]`) ?.focus(); return; } + if (isBulk) { + bulkCapture.submit(toBulkTreatmentInput(values)); + return; + } capture.submit(toTreatmentInput(values)); } + /** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */ + function resendWithoutRefused() { + const refused = new Set(bulkCapture.invalidColonyIds); + const next = { ...values, bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)) }; + setValues(next); + if (next.bulkColonyIds.length > 0) { + bulkCapture.submit(toBulkTreatmentInput(next)); + } + } + function restoreDraft() { const restored = draft.restore(); if (restored) { - setValues(restored); + // A draft from before the bulk choice existed lacks its values. + setValues({ ...initialTreatmentValues(""), ...restored }); resetPrefill(); setDirty(true); } @@ -148,11 +201,21 @@ export default function NewTreatmentPage() { noValidate > - set("colonyId", colonyId)} - error={fieldErrors.colonyId} + set("target", target)} + colonyId={values.colonyId} + onColonyChange={(colonyId) => set("colonyId", colonyId)} + colonyError={fieldErrors.colonyId} + bulkApiaryId={values.bulkApiaryId} + onBulkApiaryChange={(apiaryId) => { + set("bulkApiaryId", apiaryId); + set("bulkColonyIds", []); + }} + bulkColonyIds={values.bulkColonyIds} + onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)} + bulkError={fieldErrors.bulkColonies} /> {({ id, describedBy, invalid }) => ( @@ -234,7 +297,7 @@ export default function NewTreatmentPage() { {({ id, describedBy }) => ( + ) : capture.submitError ? (

    - {capture.isPending + {capture.isPending || bulkCapture.isPending ? "Wird gespeichert…" : "Behandlung speichern"} diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/treatment-form-values.ts b/ApiCore-Frontend/src/app/(app)/treatments/new/treatment-form-values.ts index f93df82..b255749 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/treatment-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/treatment-form-values.ts @@ -1,10 +1,16 @@ import type { createTreatment } from "@/lib/treatments/api"; +import type { BulkCreateTreatmentInput } from "@/lib/bulk/api"; +import type { CaptureTarget } from "@/components/form/capture-target-toggle"; import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface TreatmentFormValues { colonyId: string; + /** One colony, or the same treatment for several. */ + target: CaptureTarget; + bulkApiaryId: string | null; + bulkColonyIds: string[]; date: string; startDate: string; endDate: string; @@ -24,6 +30,9 @@ export interface TreatmentFormValues { export function initialTreatmentValues(colonyId: string): TreatmentFormValues { return { colonyId, + target: "single", + bulkApiaryId: null, + bulkColonyIds: [], date: todayLocalIsoDate(), startDate: "", endDate: "", @@ -41,11 +50,9 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues { }; } -export function toTreatmentInput( - values: TreatmentFormValues, -): Parameters[0] { +/** Everything of the treatment that is the same for one colony or for many. */ +function sharedTreatmentFields(values: TreatmentFormValues) { return { - colonyId: values.colonyId, date: toApiDate(values.date), startDate: emptyToNull(values.startDate, toApiDate), endDate: emptyToNull(values.endDate, toApiDate), @@ -62,3 +69,14 @@ export function toTreatmentInput( prescribingVet: emptyToNull(values.prescribingVet), }; } + +export function toTreatmentInput( + values: TreatmentFormValues, +): Parameters[0] { + return { colonyId: values.colonyId, ...sharedTreatmentFields(values) }; +} + +/** One treatment for the chosen colonies; the batch number is what the user typed. */ +export function toBulkTreatmentInput(values: TreatmentFormValues): BulkCreateTreatmentInput { + return { ...sharedTreatmentFields(values), colonyIds: values.bulkColonyIds }; +} diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx index c376451..5d08be8 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import NewVarroaMeasurementPage from "./page"; +import { toast } from "sonner"; import { createVarroaMeasurement, fetchVarroaThreshold } from "@/lib/varroa/api"; +import { createVarroaBulk } from "@/lib/bulk/api"; +import { queueBulk } from "@/lib/offline/db"; +import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; const push = vi.fn(); @@ -17,7 +21,19 @@ vi.mock("@/lib/varroa/api", async (importOriginal) => ({ createVarroaMeasurement: vi.fn(), fetchVarroaThreshold: vi.fn(), })); -vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn() })); +vi.mock("@/lib/bulk/api", async (importOriginal) => ({ + ...(await importOriginal()), + createVarroaBulk: vi.fn(), +})); +vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() })); +vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({ + ...(await importOriginal()), + listPacks: vi.fn().mockResolvedValue([]), + refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" })); +vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn(), queueBulk: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } })); function renderPage() { const client = new QueryClient({ @@ -38,6 +54,11 @@ describe("NewVarroaMeasurementPage", () => { { id: "colony-1", code: "V-01", status: "ACTIVE" }, ] as never); vi.mocked(createVarroaMeasurement).mockReset(); + vi.mocked(createVarroaBulk).mockReset(); + vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined); + vi.mocked(fetchApiaries) + .mockReset() + .mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never); vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 }); }); @@ -168,4 +189,119 @@ describe("NewVarroaMeasurementPage", () => { await userEvent.type(screen.getByLabelText(/^Tage/), "0"); expect(screen.getByRole("alert")).toHaveTextContent("größer als 0"); }); + + describe("for several colonies", () => { + const BULK_COLONIES = [ + { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" }, + { id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" }, + ]; + const SAVE = { name: "Messung speichern" }; + + async function startBulk() { + vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never); + renderPage(); + await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" })); + await userEvent.click(screen.getByRole("combobox", { name: /Stand/ })); + await userEvent.click(await screen.findByRole("option", { name: "Waldrand" })); + await screen.findByText("2 von 2 Völkern gewählt"); + } + + it("bulk sends entries with each colony's own count", async () => { + vi.mocked(createVarroaBulk).mockResolvedValue({ bulkId: "b-1", records: [] } as never); + await startBulk(); + + await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30"); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9"); + await userEvent.click(screen.getByRole("radio", { name: "3 Tage" })); + await userEvent.click(screen.getByRole("button", SAVE)); + + await waitFor(() => expect(createVarroaBulk).toHaveBeenCalledTimes(1)); + const [input, options] = vi.mocked(createVarroaBulk).mock.calls[0]; + expect(input).toMatchObject({ + method: "NATURAL_MITE_FALL", + durationMinutes: 4320, + entries: [ + { colonyId: "colony-1", miteCount: 30 }, + { colonyId: "colony-2", miteCount: 9 }, + ], + }); + expect(input).not.toHaveProperty("miteCount"); + expect(input).not.toHaveProperty("colonyId"); + expect(options?.idempotencyKey).toBeTruthy(); + expect(createVarroaMeasurement).not.toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith("Varroamessung für 2 Völker gespeichert"); + expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"); + }); + + it("has no shared mite count and shows rate and light per colony", async () => { + await startBulk(); + + expect(screen.queryByLabelText(/^Milbenzahl(?! V-)/)).not.toBeInTheDocument(); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30"); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "3"); + await userEvent.click(screen.getByRole("radio", { name: "3 Tage" })); + + const first = screen.getByRole("group", { name: "V-01" }); + const second = screen.getByRole("group", { name: "V-02" }); + expect(await within(first).findByRole("status")).toHaveTextContent( + "10,0 Milben/Tag – über der Schwelle", + ); + expect(within(second).getByRole("status")).toHaveTextContent( + "1,0 Milben/Tag – unter der Schwelle", + ); + }); + + it("says in words which colonies still lack a count, and when none does", async () => { + await startBulk(); + + expect(screen.getByText("Noch ohne Milbenzahl: V-01, V-02")).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30"); + expect(screen.getByText("Noch ohne Milbenzahl: V-02")).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9"); + + expect(screen.queryByText(/Noch ohne Milbenzahl/)).not.toBeInTheDocument(); + expect(screen.getByText("Jedes Volk hat eine Milbenzahl.")).toBeInTheDocument(); + }); + + it("asks for a count for every selected colony", async () => { + await startBulk(); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30"); + + await userEvent.click(screen.getByRole("button", SAVE)); + + expect(await screen.findByText("Bitte die Milbenzahl für V-02 eintragen.")).toBeInTheDocument(); + expect(screen.getByLabelText(/Milbenzahl V-02/)).toHaveFocus(); + expect(createVarroaBulk).not.toHaveBeenCalled(); + }); + + it("offline queues exactly one bulk entry", async () => { + vi.mocked(createVarroaBulk).mockRejectedValue(new TypeError("Failed to fetch")); + await startBulk(); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30"); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9"); + + await userEvent.click(screen.getByRole("button", SAVE)); + + await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1)); + expect(vi.mocked(queueBulk).mock.calls[0][0]).toBe("varroa"); + expect(vi.mocked(queueBulk).mock.calls[0][1]).toMatchObject({ + entries: [ + { colonyId: "colony-1", miteCount: 30 }, + { colonyId: "colony-2", miteCount: 9 }, + ], + }); + await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1")); + }); + + it("keeps each colony's count when another colony is taken out and put back", async () => { + await startBulk(); + await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9"); + + await userEvent.click(screen.getByRole("checkbox", { name: "V-02" })); + expect(screen.queryByLabelText(/Milbenzahl V-02/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("checkbox", { name: "V-02" })); + + expect(screen.getByLabelText(/Milbenzahl V-02/)).toHaveValue(9); + }); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx index eb989d3..44aaf51 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx @@ -6,6 +6,16 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Bug } from "lucide-react"; import { createVarroaMeasurement } from "@/lib/varroa/api"; +import { createVarroaBulk } from "@/lib/bulk/api"; +import { + bulkDestination, + bulkQueuedText, + bulkSavedText, + bulkSelectionError, +} from "@/lib/bulk/capture-texts"; +import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies"; +import { useBulkCapture } from "@/lib/bulk/use-bulk-capture"; +import { BulkMiteCounts } from "@/components/varroa/bulk-mite-counts"; import { mitesPerDay } from "@/lib/varroa/rate"; import { emptyToNull } from "@/lib/forms/empty-to-null"; import { VarroaPeriodField } from "@/components/varroa/varroa-period-field"; @@ -24,7 +34,8 @@ import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; -import { LivingColonySelectField } from "@/components/colony-select-field"; +import { BulkSubmitNote } from "@/components/form/bulk-submit-note"; +import { CaptureColonyField } from "@/components/form/capture-colony-field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; @@ -32,18 +43,29 @@ import { VARROA_METHOD_HINTS, VarroaFormValues, initialVarroaValues, + toBulkVarroaInput, toVarroaInput, } from "./varroa-form-values"; -type RequiredKey = "colonyId" | "date"; +type RequiredKey = "colonyId" | "bulkColonies" | "date"; type FieldErrors = Partial>; +/** What is missing at each colony of a bulk count, by colony id. */ +type CountErrors = Record; -const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"]; +const SHARED_NOTE = "Gilt für alle gewählten Völker."; const VARROA_METHOD_OPTIONS = choiceOptions(VARROA_METHOD_LABELS, VARROA_METHODS); +function requiredKeys(values: VarroaFormValues): RequiredKey[] { + return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date"]; +} + function validate(values: VarroaFormValues): FieldErrors { const errors: FieldErrors = {}; - if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen."; + if (values.target === "bulk") { + errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds); + } else if (!values.colonyId) { + errors.colonyId = "Bitte ein Volk auswählen."; + } if (!values.date) errors.date = "Bitte ein Datum angeben."; return errors; } @@ -62,6 +84,7 @@ export default function NewVarroaMeasurementPage() { ); const [dirty, setDirty] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); + const [countErrors, setCountErrors] = useState({}); const draft = useFormDraft("varroa-new", values, dirty); function set( @@ -70,11 +93,43 @@ export default function NewVarroaMeasurementPage() { ) { setValues((current) => ({ ...current, [key]: value })); setDirty(true); - if ((REQUIRED_KEYS as string[]).includes(key)) { - setFieldErrors((current) => ({ ...current, [key]: undefined })); - } + // The colonies of a bulk capture are one field in three values. + const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key; + setFieldErrors((current) => ({ ...current, [field]: undefined })); } + function setMiteCount(colonyId: string, count: string) { + set("bulkMiteCounts", { ...values.bulkMiteCounts, [colonyId]: count }); + setCountErrors((current) => ({ ...current, [colonyId]: undefined })); + } + + const isBulk = values.target === "bulk"; + const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null); + const chosenColonies = (apiaryColonies.data?.items ?? []).filter((colony) => + values.bulkColonyIds.includes(colony.id), + ); + + const bulkCapture = useBulkCapture({ + kind: "varroa", + create: createVarroaBulk, + failureMessage: "Varroamessung konnte nicht gespeichert werden.", + onSaved: (outcome, input) => { + draft.clear(); + const count = input.entries.length; + if (outcome.via === "queued") { + toast.info(bulkQueuedText("Varroamessung", count)); + } else { + for (const { colonyId } of input.entries) { + void queryClient.invalidateQueries({ + queryKey: ["colonies", colonyId, "varroa-measurements"], + }); + } + toast.success(bulkSavedText("Varroamessung", count)); + } + router.push(bulkDestination(values.bulkApiaryId)); + }, + }); + const capture = useCaptureMutation({ create: createVarroaMeasurement, queue: queueVarroaMeasurement, @@ -101,20 +156,57 @@ export default function NewVarroaMeasurementPage() { event.preventDefault(); const errors = validate(values); setFieldErrors(errors); - const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]); + const firstInvalid = requiredKeys(values).find((key) => errors[key]); if (firstInvalid) { document .querySelector(`[data-field="${firstInvalid}"]`) ?.focus(); return; } + if (isBulk) { + submitBulk(values); + return; + } capture.submit(toVarroaInput(values)); } + /** Every chosen colony needs its own count: an empty one would be a measurement of nothing. */ + function submitBulk(current: VarroaFormValues) { + const missing = chosenColonies.filter( + (colony) => (current.bulkMiteCounts[colony.id] ?? "").trim() === "", + ); + setCountErrors( + Object.fromEntries( + missing.map((colony) => [colony.id, `Bitte die Milbenzahl für ${colony.code} eintragen.`]), + ), + ); + if (missing.length > 0) { + document + .querySelector(`[data-field="miteCount-${missing[0].id}"]`) + ?.focus(); + return; + } + bulkCapture.submit(toBulkVarroaInput(current)); + } + + /** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */ + function resendWithoutRefused() { + const refused = new Set(bulkCapture.invalidColonyIds); + const next = { + ...values, + bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)), + }; + setValues(next); + if (next.bulkColonyIds.length > 0) { + bulkCapture.submit(toBulkVarroaInput(next)); + } + } + function restoreDraft() { const restored = draft.restore(); if (restored) { - setValues(restored); + // A draft from before the bulk choice existed lacks its values. + setValues({ ...initialVarroaValues(""), ...restored }); setDirty(true); } } @@ -157,11 +249,21 @@ export default function NewVarroaMeasurementPage() { noValidate > - set("colonyId", colonyId)} - error={fieldErrors.colonyId} + set("target", target)} + colonyId={values.colonyId} + onColonyChange={(colonyId) => set("colonyId", colonyId)} + colonyError={fieldErrors.colonyId} + bulkApiaryId={values.bulkApiaryId} + onBulkApiaryChange={(apiaryId) => { + set("bulkApiaryId", apiaryId); + set("bulkColonyIds", []); + }} + bulkColonyIds={values.bulkColonyIds} + onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)} + bulkError={fieldErrors.bulkColonies} /> {({ id, describedBy, invalid }) => ( @@ -180,7 +282,11 @@ export default function NewVarroaMeasurementPage() { - + {({ id, labelId, describedBy }) => ( - + + {isBulk ? ( + + ) : ( )} + )} {isNaturalFall ? ( <> set("durationMinutes", minutes)} hint="Wie lange die Windel lag." /> - + {isBulk ? null : } ) : ( - - {({ id, describedBy }) => ( - set("assessment", event.target.value)} - /> - )} - - + {isBulk ? null : ( + + {({ id, describedBy }) => ( + set("assessment", event.target.value)} + /> + )} + + )} + {({ id }) => (