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

@@ -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<BadRequestException> {
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();
});
});
@@ -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<Map<string, LivingColony>> {
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<string, LivingColony>(
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;
}
@@ -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<T> {
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 };
}
@@ -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;
@@ -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),
);
}
@@ -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<unknown>) => {
const pending: string[] = [];
tx.write.mockImplementation((value: string) => {
pending.push(value);
});
const result = await work(tx);
committed.push(...pending);
return result;
},
),
};
});
const run = <E extends { colonyId: string }, T>(
entries: E[],
perEntry: BulkEntryWork<E, T>,
) => 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);
});
});
@@ -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<E, T> = (
tx: Prisma.TransactionClient,
entry: E,
colony: LivingColony,
bulkId: string,
) => Promise<T>;
/**
* 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<E extends { colonyId: string }, T>(
prisma: PrismaService,
entries: readonly E[],
perEntry: BulkEntryWork<E, T>,
): Promise<BulkCreateResult<T>> {
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 };
}