feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events

- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

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 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

@@ -188,7 +188,7 @@ describe('WebhookDeliveryWorker', () => {
`UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`,
);
expect(text(claim)).toContain(
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
`WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`,
);
expect(text(claim)).toContain(
'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED',
@@ -201,7 +201,7 @@ describe('WebhookDeliveryWorker', () => {
const claim = db.claimQuery();
expect(text(claim)).toContain(
`"availableAt" <= now() AND "subscriptionId" = ANY(?) ORDER BY`,
`"availableAt" <= CAST(now() AS timestamp(3)) AND "subscriptionId" = ANY(?) ORDER BY`,
);
expect(claim.values).toEqual([['wh-1', 'wh-2'], 50]);
});
@@ -18,6 +18,7 @@ type RecordModel =
| 'feeding'
| 'harvest'
| 'honeyBatch'
| 'queen'
| 'task';
const RECORD_MODELS: readonly RecordModel[] = [
@@ -27,6 +28,7 @@ const RECORD_MODELS: readonly RecordModel[] = [
'feeding',
'harvest',
'honeyBatch',
'queen',
'task',
];
@@ -79,6 +81,7 @@ describe('WebhookEventHandlers', () => {
StockChanged: 'stock.changed',
TaskCreated: 'task.created',
TaskCompleted: 'task.completed',
QueenAssigned: 'queen.replaced',
});
});
@@ -156,6 +159,27 @@ describe('WebhookEventHandlers', () => {
);
});
it('QueenAssigned forwards queen.replaced with the queen', async () => {
const queen = { id: 'q1', code: 'Q-001' };
tx.queen.findUnique.mockResolvedValueOnce(queen);
await forward(
envelope('QueenAssigned', 'q1', {
colonyId: 'c1',
historyId: 'h1',
assignedOn: '2026-10-14',
previousColonyId: null,
}),
);
expect(tx.queen.findUnique).toHaveBeenCalledWith({ where: { id: 'q1' } });
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
tx,
'queen.replaced',
queen,
);
});
it('enqueues harvest.created with the harvest and its colonies', async () => {
const harvest = {
id: 'h1',
@@ -23,6 +23,7 @@ export const DOMAIN_EVENT_WEBHOOKS: Readonly<
StockChanged: 'stock.changed',
TaskCreated: 'task.created',
TaskCompleted: 'task.completed',
QueenAssigned: 'queen.replaced',
});
/**
@@ -92,6 +93,8 @@ async function webhookData(
case 'TaskCreated':
case 'TaskCompleted':
return tx.task.findUnique(byAggregate);
case 'QueenAssigned':
return tx.queen.findUnique(byAggregate);
default:
return null;
}