feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops

- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

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 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

+224
View File
@@ -19,6 +19,14 @@ interface FailedDeliveryBody {
[key: string]: unknown;
}
interface DeliveriesBody {
telegram: {
botNameSet: boolean;
sender: { failed24h: number };
};
deliveries: { id: string; [key: string]: unknown }[];
}
interface OverviewBody {
jobs: unknown[];
domainEvents: { failed: number; oldestPendingAgeSeconds: number | null };
@@ -234,4 +242,220 @@ describe('System API (e2e)', () => {
await prisma.webhookSubscription.delete({ where: { id: subscription.id } });
});
describe('notification deliveries (Telegram card)', () => {
const CHAT_ID = 987654321n;
let userId: string;
const createdNotificationIds: string[] = [];
const templateDeliveryIds: string[] = [];
/** A bot reply: addressed by chat id, no user, no notification. */
async function createTemplateDelivery(): Promise<string> {
const delivery = await prisma.notificationDelivery.create({
data: {
channel: 'TELEGRAM',
chatId: CHAT_ID,
templateCode: 'HELP',
status: 'FAILED',
attempts: 8,
lastError: 'HTTP 502',
},
});
templateDeliveryIds.push(delivery.id);
return delivery.id;
}
async function createDelivery(
status: 'FAILED' | 'DELIVERED',
): Promise<string> {
const notification = await prisma.notification.create({
data: {
userId,
priority: 'HINWEIS',
category: 'TASK_DUE',
message: 'private reminder text',
dedupeKey: `system-e2e:${suffix}:${createdNotificationIds.length}:${Date.now()}`,
},
});
createdNotificationIds.push(notification.id);
const delivery = await prisma.notificationDelivery.create({
data: {
channel: 'TELEGRAM',
userId,
notificationId: notification.id,
status,
attempts: status === 'FAILED' ? 8 : 1,
lastStatusCode: status === 'FAILED' ? 502 : null,
lastError: status === 'FAILED' ? 'HTTP 502' : null,
},
});
return delivery.id;
}
beforeAll(async () => {
const admin = await prisma.user.findUniqueOrThrow({
where: { email: `system-admin-${suffix}@example.com` },
select: { id: true },
});
userId = admin.id;
});
afterAll(async () => {
// Only this block's own notifications (deliveries cascade).
await prisma.notification.deleteMany({
where: { id: { in: createdNotificationIds } },
});
await prisma.notificationDelivery.deleteMany({
where: { id: { in: templateDeliveryIds } },
});
});
it('GET /system/notification-deliveries shows FAILED entries and retry moves them back to PENDING', async () => {
const failedId = await createDelivery('FAILED');
const deliveredId = await createDelivery('DELIVERED');
const templateId = await createTemplateDelivery();
const response = await request(server())
.get('/api/v1/system/notification-deliveries?status=FAILED')
.set(auth(adminToken))
.expect(200);
const body = response.body as DeliveriesBody;
const listed = body.deliveries.find((d) => d.id === failedId);
expect(listed).toMatchObject({
status: 'FAILED',
category: 'TASK_DUE',
attempts: 8,
lastStatusCode: 502,
});
expect(body.deliveries.some((d) => d.id === deliveredId)).toBe(false);
expect(body.deliveries.find((d) => d.id === templateId)).toMatchObject({
category: null,
templateCode: 'HELP',
});
expect(typeof body.telegram.botNameSet).toBe('boolean');
expect(body.telegram.sender.failed24h).toBeGreaterThanOrEqual(1);
const raw = JSON.stringify(body);
expect(raw).not.toContain(CHAT_ID.toString());
expect(raw).not.toContain('private reminder text');
expect(listed).not.toHaveProperty('chatId');
await request(server())
.get('/api/v1/system/notification-deliveries?status=NOPE')
.set(auth(adminToken))
.expect(400);
await request(server())
.post(`/api/v1/system/notification-deliveries/${failedId}/retry`)
.set(auth(adminToken))
.expect(204);
const reset = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: failedId },
});
expect(reset).toMatchObject({
status: 'PENDING',
attempts: 0,
lastError: null,
lastStatusCode: null,
claimedAt: null,
});
expect(reset.availableAt.getTime()).toBeLessThanOrEqual(
Date.now() + 5_000,
);
await expect(
prisma.auditLog.count({
where: { entityType: 'NotificationDelivery', entityId: failedId },
}),
).resolves.toBe(1);
// PENDING is not FAILED any more, a DELIVERED one never was.
await request(server())
.post(`/api/v1/system/notification-deliveries/${failedId}/retry`)
.set(auth(adminToken))
.expect(409);
await request(server())
.post(`/api/v1/system/notification-deliveries/${deliveredId}/retry`)
.set(auth(adminToken))
.expect(409);
await request(server())
.post('/api/v1/system/notification-deliveries/does-not-exist/retry')
.set(auth(adminToken))
.expect(404);
});
it('retry-failed requeues the FAILED entries and reports how many', async () => {
const own = [
await createDelivery('FAILED'),
await createDelivery('FAILED'),
];
// Other files share this DB: remember their FAILED rows to restore them.
const others = await prisma.notificationDelivery.findMany({
where: { status: 'FAILED', id: { notIn: own } },
});
const ownAudits = () =>
prisma.auditLog.count({
where: { entityType: 'NotificationDelivery', entityId: { in: own } },
});
try {
const response = await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(adminToken))
.expect(200);
expect(
(response.body as { retried: number }).retried,
).toBeGreaterThanOrEqual(2);
const rows = await prisma.notificationDelivery.findMany({
where: { id: { in: own } },
select: { status: true, attempts: true },
});
expect(rows).toEqual([
{ status: 'PENDING', attempts: 0 },
{ status: 'PENDING', attempts: 0 },
]);
await expect(ownAudits()).resolves.toBe(2);
// A second call leaves the own rows alone: no new audit rows.
await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(adminToken))
.expect(200);
await expect(
prisma.notificationDelivery.count({
where: { id: { in: own }, status: 'PENDING' },
}),
).resolves.toBe(2);
await expect(ownAudits()).resolves.toBe(2);
} finally {
for (const row of others) {
await prisma.notificationDelivery.updateMany({
where: { id: row.id },
data: {
status: row.status,
attempts: row.attempts,
availableAt: row.availableAt,
claimedAt: row.claimedAt,
lastStatusCode: row.lastStatusCode,
lastError: row.lastError,
},
});
}
}
});
it('staff without system:manage gets 403', async () => {
await request(server())
.get('/api/v1/system/notification-deliveries')
.set(auth(staffToken))
.expect(403);
await request(server())
.post('/api/v1/system/notification-deliveries/x/retry')
.set(auth(staffToken))
.expect(403);
await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(staffToken))
.expect(403);
});
});
});