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,273 @@
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import {
|
||||
DOMAIN_EVENT_WEBHOOKS,
|
||||
WebhookEventHandlers,
|
||||
} from './webhook.handlers';
|
||||
import type { WebhooksService } from './webhooks.service';
|
||||
|
||||
type RecordModel =
|
||||
| 'inspection'
|
||||
| 'varroaMeasurement'
|
||||
| 'treatment'
|
||||
| 'feeding'
|
||||
| 'harvest'
|
||||
| 'honeyBatch'
|
||||
| 'task';
|
||||
|
||||
const RECORD_MODELS: readonly RecordModel[] = [
|
||||
'inspection',
|
||||
'varroaMeasurement',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
'honeyBatch',
|
||||
'task',
|
||||
];
|
||||
|
||||
function envelope<T extends DomainEventType>(
|
||||
type: T,
|
||||
aggregateId: string,
|
||||
payload: DomainEventEnvelope<T>['payload'],
|
||||
): AnyDomainEventEnvelope {
|
||||
return {
|
||||
id: 'e1',
|
||||
type,
|
||||
aggregateType: 'Inspection',
|
||||
aggregateId,
|
||||
payload,
|
||||
actorUserId: 'u1',
|
||||
correlationId: 'corr-1',
|
||||
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
|
||||
attempts: 0,
|
||||
} as AnyDomainEventEnvelope;
|
||||
}
|
||||
|
||||
describe('WebhookEventHandlers', () => {
|
||||
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
|
||||
let webhooks: { enqueueDeliveries: jest.Mock };
|
||||
let handlers: WebhookEventHandlers;
|
||||
|
||||
const forward = (event: AnyDomainEventEnvelope) =>
|
||||
handlers.forward(event, tx as unknown as Prisma.TransactionClient);
|
||||
|
||||
beforeEach(() => {
|
||||
tx = Object.fromEntries(
|
||||
RECORD_MODELS.map((model) => [
|
||||
model,
|
||||
{ findUnique: jest.fn().mockResolvedValue(null) },
|
||||
]),
|
||||
) as Record<RecordModel, { findUnique: jest.Mock }>;
|
||||
webhooks = { enqueueDeliveries: jest.fn().mockResolvedValue(1) };
|
||||
handlers = new WebhookEventHandlers(webhooks as unknown as WebhooksService);
|
||||
});
|
||||
|
||||
it('maps exactly these domain events to webhook events', () => {
|
||||
expect(DOMAIN_EVENT_WEBHOOKS).toEqual({
|
||||
InspectionRecorded: 'inspection.created',
|
||||
VarroaMeasured: 'varroa.measured',
|
||||
TreatmentRecorded: 'treatment.created',
|
||||
FeedingRecorded: 'feeding.created',
|
||||
HarvestRecorded: 'harvest.created',
|
||||
BatchBottled: 'batch.updated',
|
||||
BottlingCorrected: 'batch.updated',
|
||||
StockChanged: 'stock.changed',
|
||||
TaskCreated: 'task.created',
|
||||
TaskCompleted: 'task.completed',
|
||||
});
|
||||
});
|
||||
|
||||
it('registers forward as "webhooks.forward" for every mapped type', () => {
|
||||
// The decorator stores its metadata on the method itself.
|
||||
const method = Reflect.get(WebhookEventHandlers.prototype, 'forward');
|
||||
expect(domainEventHandlerMetadata(method as object)).toEqual([
|
||||
{
|
||||
types: Object.keys(DOMAIN_EVENT_WEBHOOKS),
|
||||
handlerId: 'webhooks.forward',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('enqueues inspection.created with the inspection as data', async () => {
|
||||
const inspection = { id: 'i1', colonyId: 'c1', notes: 'calm' };
|
||||
tx.inspection.findUnique.mockResolvedValueOnce(inspection);
|
||||
|
||||
await forward(envelope('InspectionRecorded', 'i1', { colonyId: 'c1' }));
|
||||
|
||||
expect(tx.inspection.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'i1' },
|
||||
});
|
||||
// Through the delivery transaction: the rows commit with the delivery.
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'inspection.created',
|
||||
inspection,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['VarroaMeasured', 'varroaMeasurement', 'varroa.measured'],
|
||||
['TreatmentRecorded', 'treatment', 'treatment.created'],
|
||||
['FeedingRecorded', 'feeding', 'feeding.created'],
|
||||
] as const)(
|
||||
'enqueues %s as %s record under %s',
|
||||
async (type, model, webhookEvent) => {
|
||||
const record = { id: 'r1', colonyId: 'c1' };
|
||||
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||
|
||||
await forward(envelope(type, 'r1', { colonyId: 'c1' }));
|
||||
|
||||
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
});
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
webhookEvent,
|
||||
record,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['TaskCreated', 'task.created'],
|
||||
['TaskCompleted', 'task.completed'],
|
||||
] as const)('enqueues %s as %s with the task', async (type, webhookEvent) => {
|
||||
const task = { id: 't1', title: 'Varroa zählen' };
|
||||
tx.task.findUnique.mockResolvedValueOnce(task);
|
||||
|
||||
await forward(
|
||||
envelope(type, 't1', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: 'c1',
|
||||
dueDate: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(tx.task.findUnique).toHaveBeenCalledWith({ where: { id: 't1' } });
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
webhookEvent,
|
||||
task,
|
||||
);
|
||||
});
|
||||
|
||||
it('enqueues harvest.created with the harvest and its colonies', async () => {
|
||||
const harvest = {
|
||||
id: 'h1',
|
||||
colonies: [{ colonyId: 'c1', colony: { id: 'c1', code: 'V-1' } }],
|
||||
};
|
||||
tx.harvest.findUnique.mockResolvedValueOnce(harvest);
|
||||
|
||||
await forward(
|
||||
envelope('HarvestRecorded', 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }),
|
||||
);
|
||||
|
||||
expect(tx.harvest.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
include: { colonies: { include: { colony: true } } },
|
||||
});
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'harvest.created',
|
||||
harvest,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['BatchBottled', { honeyBatchId: 'hb1' }],
|
||||
['BottlingCorrected', { honeyBatchId: 'hb1', removed: true }],
|
||||
] as const)(
|
||||
'enqueues batch.updated with the honey batch for %s',
|
||||
async (type, payload) => {
|
||||
const honeyBatch = { id: 'hb1', lotNumber: 'L-2026-001' };
|
||||
tx.honeyBatch.findUnique.mockResolvedValueOnce(honeyBatch);
|
||||
|
||||
// The aggregate is the bottling batch; the data is its honey batch,
|
||||
// which still exists after a bottling was removed.
|
||||
await forward(
|
||||
envelope(
|
||||
type,
|
||||
'bb1',
|
||||
payload as DomainEventEnvelope<'BottlingCorrected'>['payload'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(tx.honeyBatch.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'hb1' },
|
||||
});
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'batch.updated',
|
||||
honeyBatch,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('enqueues stock.changed with the bottling batch and product ids, without raw material kinds', async () => {
|
||||
await forward(
|
||||
envelope('StockChanged', 'm1', {
|
||||
bottlingBatchIds: ['b1', 'b1', 'b2'],
|
||||
productIds: ['p1'],
|
||||
rawMaterialKinds: ['BEESWAX'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'stock.changed',
|
||||
{ bottlingBatchIds: ['b1', 'b2'], productIds: ['p1'] },
|
||||
);
|
||||
RECORD_MODELS.forEach((model) =>
|
||||
expect(tx[model].findUnique).not.toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('enqueues nothing for StockChanged that only touches raw material', async () => {
|
||||
await forward(
|
||||
envelope('StockChanged', 'm1', {
|
||||
bottlingBatchIds: [],
|
||||
productIds: [],
|
||||
rawMaterialKinds: ['PROPOLIS'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a record that no longer exists', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await forward(envelope('InspectionRecorded', 'gone', { colonyId: 'c1' }));
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enqueues nothing for a type without a webhook', async () => {
|
||||
await forward(envelope('TaskDue', 't1', { dueOn: '2026-10-13' }));
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
RECORD_MODELS.forEach((model) =>
|
||||
expect(tx[model].findUnique).not.toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
// The dispatcher rolls the delivery transaction back and retries.
|
||||
it('fails when the deliveries cannot be enqueued', async () => {
|
||||
tx.task.findUnique.mockResolvedValueOnce({ id: 't1' });
|
||||
webhooks.enqueueDeliveries.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
forward(
|
||||
envelope('TaskCompleted', 't1', {
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: null,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user