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,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[];
}
@@ -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(
@@ -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],
@@ -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<string, unknown> & { 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();
});
});
});
+105 -32
View File
@@ -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<Feeding[]> {
@@ -53,18 +67,60 @@ export class FeedingsService {
}
async create(dto: CreateFeedingDto, actingUserId: string): Promise<Feeding> {
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<BulkCreateResult<Feeding>> {
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<Feeding> {
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<Feeding> {
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;
}
}