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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

@@ -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<Inspection[]> {
@@ -25,13 +33,7 @@ export class InspectionsService {
}
async findById(id: string): Promise<Inspection> {
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<Inspection[]> {
@@ -45,78 +47,104 @@ export class InspectionsService {
dto: CreateInspectionDto,
actingUserId: string,
): Promise<Inspection> {
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<Inspection> {
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<Task> {
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<Inspection> {
const inspection = await db.inspection.findUnique({ where: { id } });
if (!inspection) {
throw new NotFoundException('Inspection not found');
}
return task;
return inspection;
}
}