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,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],
@@ -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<unknown>) =>
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();
});
});
@@ -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;
}
}