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:
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user