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

@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { NotificationDeliveryStatus } from '../../../generated/prisma';
export class NotificationDeliveriesQueryDto {
@ApiPropertyOptional({
enum: NotificationDeliveryStatus,
description: 'Nur Zustellungen mit diesem Status',
})
@IsOptional()
@IsEnum(NotificationDeliveryStatus)
status?: NotificationDeliveryStatus;
}
@@ -0,0 +1,325 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import {
DELIVERY_RETRY_ALL_LIMIT,
NotificationDeliveriesService,
} from './notification-deliveries.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
lockRowsForUpdate: jest.fn().mockResolvedValue(undefined),
}));
type RawCall = [readonly string[], ...unknown[]];
const lockRow = jest.mocked(lockRowForUpdate);
const lockRows = jest.mocked(lockRowsForUpdate);
describe('NotificationDeliveriesService', () => {
let tx: {
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
$executeRaw: jest.Mock<Promise<number>, RawCall>;
notificationDelivery: { findUnique: jest.Mock; findMany: jest.Mock };
};
let prisma: {
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
$transaction: jest.Mock;
notificationDelivery: { findMany: jest.Mock };
};
let audit: { record: jest.Mock };
let service: NotificationDeliveriesService;
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
const sqlText = (call: RawCall): string => call[0].join('?');
const pollRow = (overrides: Record<string, unknown> = {}) => ({
leaseOwner: null,
lastPollAgeSeconds: null,
lastError: null,
...overrides,
});
const senderRow = (overrides: Record<string, unknown> = {}) => ({
pending: 0,
oldestPendingAgeSeconds: null,
failed24h: 0,
...overrides,
});
/** overview(): poll state, sender counts, last errors (in this order). */
function mockOverview(
poll: Record<string, unknown> | null,
sender: Record<string, unknown>,
errors: unknown[] = [],
): void {
prisma.$queryRaw
.mockResolvedValueOnce(poll ? [poll] : [])
.mockResolvedValueOnce([sender])
.mockResolvedValueOnce(errors);
}
beforeEach(() => {
jest.clearAllMocks();
lockRow.mockResolvedValue(true);
delete process.env.TELEGRAM_BOT_USERNAME;
delete process.env.TELEGRAM_BOT_TOKEN;
tx = {
$queryRaw: jest.fn<Promise<unknown>, RawCall>().mockResolvedValue([]),
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
notificationDelivery: { findUnique: jest.fn(), findMany: jest.fn() },
};
prisma = {
$queryRaw: jest.fn<Promise<unknown>, RawCall>(),
$transaction: jest.fn((fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
notificationDelivery: { findMany: jest.fn().mockResolvedValue([]) },
};
audit = { record: jest.fn().mockResolvedValue(undefined) };
service = new NotificationDeliveriesService(
prisma as unknown as PrismaService,
audit as unknown as AuditLogService,
);
});
afterAll(() => {
if (originalUsername === undefined) {
delete process.env.TELEGRAM_BOT_USERNAME;
} else {
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
}
if (originalToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = originalToken;
}
});
it('shows the oldest pending age from the database clock and never a chat id or token', async () => {
mockOverview(
pollRow(),
senderRow({ pending: 3, oldestPendingAgeSeconds: 42.5, failed24h: 1 }),
[
{
lastStatusCode: 502,
lastError: 'HTTP 502',
createdAt: new Date('2026-10-14T08:00:00Z'),
},
],
);
prisma.notificationDelivery.findMany.mockResolvedValue([
{
id: 'd1',
status: 'FAILED',
templateCode: null,
attempts: 8,
availableAt: new Date('2026-10-14T08:00:00Z'),
lastStatusCode: 502,
lastError: 'HTTP 502',
createdAt: new Date('2026-10-14T07:00:00Z'),
deliveredAt: null,
notification: { category: 'TASK_DUE' },
},
]);
const { telegram, deliveries } = await service.overview();
expect(telegram.sender).toEqual({
pending: 3,
oldestPendingAgeSeconds: 42.5,
failed24h: 1,
lastErrors: [
{
statusCode: 502,
error: 'HTTP 502',
createdAt: new Date('2026-10-14T08:00:00Z'),
},
],
});
const senderSql = sqlText(prisma.$queryRaw.mock.calls[1]);
expect(senderSql).toContain(
'EXTRACT(EPOCH FROM now() - MIN("availableAt")',
);
expect(senderSql).toContain('"NotificationDelivery"');
expect(deliveries).toEqual([
expect.objectContaining({
id: 'd1',
category: 'TASK_DUE',
templateCode: null,
attempts: 8,
}),
]);
const raw = JSON.stringify({ telegram, deliveries });
expect(raw).not.toMatch(/chatId|token/i);
expect(deliveries[0]).not.toHaveProperty('notification');
const args = (
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
)[0][0] as { select: Record<string, unknown>; where: unknown };
expect(args.select).not.toHaveProperty('chatId');
expect(args.select).not.toHaveProperty('userId');
});
it('configured follows the bot username, not the token, and receiver.lastPollAgeSeconds is null while the worker never polled', async () => {
process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot';
mockOverview(pollRow(), senderRow());
const configured = await service.overview();
expect(configured.telegram).toMatchObject({
botNameSet: true,
botUsername: 'apicore_test_bot',
receiver: {
leaseOwner: null,
lastPollAgeSeconds: null,
lastError: null,
},
});
delete process.env.TELEGRAM_BOT_USERNAME;
process.env.TELEGRAM_BOT_TOKEN = '123456789:' + 'A'.repeat(35);
mockOverview(null, senderRow());
const tokenOnly = await service.overview();
expect(tokenOnly.telegram.botNameSet).toBe(false);
expect(tokenOnly.telegram.botUsername).toBeNull();
expect(tokenOnly.telegram.receiver.lastPollAgeSeconds).toBeNull();
expect(JSON.stringify(tokenOnly)).not.toContain('AAAAAAAAAA');
});
it('reports the receiver lease owner and poll age from the database clock', async () => {
mockOverview(
pollRow({ leaseOwner: 'pod-1', lastPollAgeSeconds: 7.25 }),
senderRow(),
);
const { telegram } = await service.overview();
expect(telegram.receiver.leaseOwner).toBe('pod-1');
expect(telegram.receiver.lastPollAgeSeconds).toBe(7.25);
const pollSql = sqlText(prisma.$queryRaw.mock.calls[0]);
expect(pollSql).toContain('"TelegramPollState"');
expect(pollSql).toContain('EXTRACT(EPOCH FROM now() - "lastPollAt")');
expect(pollSql).toContain('"leaseUntil" > now()');
});
it('filters the list by status and caps it', async () => {
mockOverview(pollRow(), senderRow());
await service.overview('FAILED');
const args = (
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
)[0][0] as { where: unknown; take: number };
expect(args.where).toEqual({ status: 'FAILED' });
expect(args.take).toBe(100);
mockOverview(pollRow(), senderRow());
await service.overview();
const unfiltered = (
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
)[1][0] as { where: unknown };
expect(unfiltered.where).toEqual({});
});
it('retry moves FAILED back to PENDING, writes an audit entry and wakes the worker', async () => {
tx.notificationDelivery.findUnique.mockResolvedValue({
status: 'FAILED',
attempts: 8,
});
await service.retry('d1', 'user-1');
expect(lockRow).toHaveBeenCalledWith(tx, 'NotificationDelivery', 'd1');
const update = sqlText(tx.$executeRaw.mock.calls[0]);
expect(update).toContain('"status" = \'PENDING\'');
expect(update).toContain('"attempts" = 0');
expect(update).toContain('"availableAt" = now()');
expect(update).toContain('"claimedAt" = NULL');
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'NotificationDelivery',
entityId: 'd1',
action: 'UPDATE',
userId: 'user-1',
beforeData: { status: 'FAILED', attempts: 8 },
afterData: { status: 'PENDING', attempts: 0 },
}),
tx,
);
expect(
tx.$executeRaw.mock.calls.some((c) =>
c.slice(1).includes('notification_delivery'),
),
).toBe(true);
});
it('retry of a non-FAILED delivery is 409 and of an unknown one 404', async () => {
tx.notificationDelivery.findUnique.mockResolvedValueOnce({
status: 'DELIVERED',
attempts: 1,
});
await expect(service.retry('d1', 'u')).rejects.toBeInstanceOf(
ConflictException,
);
tx.notificationDelivery.findUnique.mockResolvedValueOnce(null);
await expect(service.retry('nope', 'u')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(audit.record).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('retryFailed requeues every FAILED delivery under lock and reports the count', async () => {
tx.notificationDelivery.findMany.mockResolvedValue([
{ id: 'a' },
{ id: 'b' },
]);
tx.$queryRaw.mockResolvedValueOnce([
{ id: 'a', attempts: 8 },
{ id: 'b', attempts: 3 },
]);
await expect(service.retryFailed('user-1')).resolves.toEqual({
retried: 2,
});
expect(lockRows).toHaveBeenCalledWith(tx, 'NotificationDelivery', [
'a',
'b',
]);
const update = sqlText(tx.$queryRaw.mock.calls[0]);
expect(update).toContain('"status" = \'FAILED\'');
expect(update).toContain('"availableAt" = now()');
expect(audit.record).toHaveBeenCalledTimes(2);
expect(
tx.$executeRaw.mock.calls.some((c) =>
c.slice(1).includes('notification_delivery'),
),
).toBe(true);
});
it('retryFailed with nothing failed retries nothing and wakes nobody', async () => {
tx.notificationDelivery.findMany.mockResolvedValue([]);
await expect(service.retryFailed('user-1')).resolves.toEqual({
retried: 0,
});
expect(audit.record).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('retryFailed takes the newest FAILED ones, at most the limit', async () => {
tx.notificationDelivery.findMany.mockResolvedValue([{ id: 'a' }]);
tx.$queryRaw.mockResolvedValueOnce([{ id: 'a', attempts: 8 }]);
await service.retryFailed('user-1');
const args = (
tx.notificationDelivery.findMany.mock.calls as unknown[][]
)[0][0] as { take: number; orderBy: unknown; where: unknown };
expect(args.take).toBe(DELIVERY_RETRY_ALL_LIMIT);
expect(args.where).toEqual({ status: 'FAILED' });
expect(args.orderBy).toEqual([{ createdAt: 'desc' }, { id: 'desc' }]);
});
it('retryFailed reports 0 when the re-check after the lock finds nothing FAILED', async () => {
tx.notificationDelivery.findMany.mockResolvedValue([{ id: 'a' }]);
tx.$queryRaw.mockResolvedValueOnce([]);
await expect(service.retryFailed('user-1')).resolves.toEqual({
retried: 0,
});
expect(lockRows).toHaveBeenCalled();
expect(audit.record).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,254 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
NotificationCategory,
NotificationDeliveryStatus,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import {
telegramBotAvailable,
telegramConfig,
} from '../telegram/telegram-config';
export interface TelegramStatusView {
/**
* The bot name is set. The API pod never has the token, so it cannot know
* whether sending works: that runs only in the worker, when the secret exists.
*/
botNameSet: boolean;
botUsername: string | null;
/** Whether updates are received shows only the poll state of the worker. */
receiver: {
leaseOwner: string | null;
lastPollAgeSeconds: number | null;
lastError: string | null;
};
sender: {
pending: number;
oldestPendingAgeSeconds: number | null;
failed24h: number;
lastErrors: { statusCode: number | null; error: string; createdAt: Date }[];
};
}
/** A delivery for the System page - never the chat id and never the text. */
export interface NotificationDeliveryView {
id: string;
status: NotificationDeliveryStatus;
category: NotificationCategory | null;
templateCode: string | null;
attempts: number;
availableAt: Date;
lastStatusCode: number | null;
lastError: string | null;
createdAt: Date;
deliveredAt: Date | null;
}
export interface NotificationDeliveriesOverview {
telegram: TelegramStatusView;
deliveries: NotificationDeliveryView[];
}
/** Most deliveries one list or one "retry all" call handles. */
export const DELIVERY_LIST_LIMIT = 100;
export const DELIVERY_RETRY_ALL_LIMIT = 100;
const LAST_ERRORS_LIMIT = 5;
interface PollRow {
leaseOwner: string | null;
lastPollAgeSeconds: number | null;
lastError: string | null;
}
interface SenderRow {
pending: number;
oldestPendingAgeSeconds: number | null;
failed24h: number;
}
interface RetriedRow {
id: string;
attempts: number;
}
/**
* Read model and retry actions of the Telegram card on the System page. All
* ages are computed by the database: the API pod's clock may differ.
*/
@Injectable()
export class NotificationDeliveriesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditLogService,
) {}
async overview(
status?: NotificationDeliveryStatus,
): Promise<NotificationDeliveriesOverview> {
const [[poll], [sender], errors, rows] = await Promise.all([
// The lease owner counts only while its lease runs; no row = never polled.
this.prisma.$queryRaw<PollRow[]>`
SELECT
CASE WHEN "leaseUntil" > now() THEN "leaseOwner" END AS "leaseOwner",
EXTRACT(EPOCH FROM now() - "lastPollAt")::float8 AS "lastPollAgeSeconds",
"lastError"
FROM "TelegramPollState"
WHERE "id" = 'singleton'`,
// Oldest pending = the longest overdue one; a delivery held back until
// the end of the quiet hours is not late yet.
this.prisma.$queryRaw<SenderRow[]>`
SELECT
count(*) FILTER (WHERE "status" = 'PENDING')::int AS "pending",
EXTRACT(EPOCH FROM now() - MIN("availableAt") FILTER (WHERE "status" = 'PENDING' AND "availableAt" <= now()))::float8 AS "oldestPendingAgeSeconds",
count(*) FILTER (WHERE "status" = 'FAILED' AND "createdAt" > now() - interval '24 hours')::int AS "failed24h"
FROM "NotificationDelivery"`,
this.prisma.$queryRaw<
{ lastStatusCode: number | null; lastError: string; createdAt: Date }[]
>`
SELECT "lastStatusCode", "lastError", "createdAt"
FROM "NotificationDelivery"
WHERE "lastError" IS NOT NULL
ORDER BY "createdAt" DESC, "id" DESC
LIMIT ${LAST_ERRORS_LIMIT}`,
this.prisma.notificationDelivery.findMany({
where: status ? { status } : {},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: DELIVERY_LIST_LIMIT,
// Never chatId, userId or a text: only what the System page shows.
select: {
id: true,
status: true,
templateCode: true,
attempts: true,
availableAt: true,
lastStatusCode: true,
lastError: true,
createdAt: true,
deliveredAt: true,
notification: { select: { category: true } },
},
}),
]);
return {
telegram: {
botNameSet: telegramBotAvailable(),
botUsername: telegramConfig().botUsername,
receiver: {
leaseOwner: poll?.leaseOwner ?? null,
lastPollAgeSeconds: poll?.lastPollAgeSeconds ?? null,
lastError: poll?.lastError ?? null,
},
sender: {
pending: sender?.pending ?? 0,
oldestPendingAgeSeconds: sender?.oldestPendingAgeSeconds ?? null,
failed24h: sender?.failed24h ?? 0,
lastErrors: errors.map((row) => ({
statusCode: row.lastStatusCode,
error: row.lastError,
createdAt: row.createdAt,
})),
},
},
deliveries: rows.map(({ notification, ...delivery }) => ({
...delivery,
category: notification?.category ?? null,
})),
};
}
/**
* Re-queues one FAILED delivery: lock, re-read, then PENDING with
* attempts 0 and availableAt from the database clock; audit in the same
* transaction, then wake the worker. 404 unknown, 409 not FAILED.
*/
async retry(id: string, actingUserId: string): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'NotificationDelivery', id);
const before = await tx.notificationDelivery.findUnique({
where: { id },
select: { status: true, attempts: true },
});
if (!before) {
throw new NotFoundException('Notification delivery not found');
}
if (before.status !== 'FAILED') {
throw new ConflictException('Notification delivery is not FAILED');
}
await tx.$executeRaw`
UPDATE "NotificationDelivery"
SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(),
"claimedAt" = NULL, "lastStatusCode" = NULL, "lastError" = NULL
WHERE "id" = ${id}`;
await this.audit.record(
{
entityType: 'NotificationDelivery',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: { status: 'FAILED', attempts: before.attempts },
afterData: { status: 'PENDING', attempts: 0 },
},
tx,
);
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
});
}
/**
* Re-queues the newest FAILED deliveries (at most DELIVERY_RETRY_ALL_LIMIT
* per call; call again for more).
*/
async retryFailed(actingUserId: string): Promise<{ retried: number }> {
return this.prisma.$transaction(async (tx) => {
const candidates = await tx.notificationDelivery.findMany({
where: { status: 'FAILED' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: DELIVERY_RETRY_ALL_LIMIT,
select: { id: true },
});
const ids = candidates.map((row) => row.id);
if (ids.length === 0) {
return { retried: 0 };
}
await lockRowsForUpdate(tx, 'NotificationDelivery', ids);
// The status is checked again after the lock: a row retried or sent
// meanwhile is left alone.
const retried = await tx.$queryRaw<RetriedRow[]>`
WITH old AS (
SELECT "id", "attempts" FROM "NotificationDelivery"
WHERE "id" = ANY(${ids}) AND "status" = 'FAILED'
)
UPDATE "NotificationDelivery" d
SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(),
"claimedAt" = NULL, "lastStatusCode" = NULL, "lastError" = NULL
FROM old
WHERE d."id" = old."id"
RETURNING d."id" AS "id", old."attempts" AS "attempts"`;
for (const row of retried) {
await this.audit.record(
{
entityType: 'NotificationDelivery',
entityId: row.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: { status: 'FAILED', attempts: row.attempts },
afterData: { status: 'PENDING', attempts: 0 },
},
tx,
);
}
if (retried.length > 0) {
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
}
return { retried: retried.length };
});
}
}
@@ -11,6 +11,11 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { NotificationDeliveriesQueryDto } from './dto/notification-deliveries.query';
import {
NotificationDeliveriesOverview,
NotificationDeliveriesService,
} from './notification-deliveries.service';
import {
JobRunListQueryDto,
SYSTEM_LIST_DEFAULT_LIMIT,
@@ -28,7 +33,10 @@ import {
@ApiBearerAuth()
@Controller('system')
export class SystemController {
constructor(private readonly queue: SystemQueueService) {}
constructor(
private readonly queue: SystemQueueService,
private readonly deliveries: NotificationDeliveriesService,
) {}
@RequirePermission('system:read')
@Get('overview')
@@ -84,4 +92,32 @@ export class SystemController {
): Promise<void> {
return this.queue.retryWebhookDelivery(id, actor.id);
}
@RequirePermission('system:manage')
@Get('notification-deliveries')
notificationDeliveries(
@Query() query: NotificationDeliveriesQueryDto,
): Promise<NotificationDeliveriesOverview> {
return this.deliveries.overview(query.status);
}
// Declared before ':id/retry' so that "retry-failed" is never read as an id.
@RequirePermission('system:manage')
@Post('notification-deliveries/retry-failed')
@HttpCode(HttpStatus.OK)
retryFailedNotificationDeliveries(
@CurrentUser() actor: AuthenticatedUser,
): Promise<{ retried: number }> {
return this.deliveries.retryFailed(actor.id);
}
@RequirePermission('system:manage')
@Post('notification-deliveries/:id/retry')
@HttpCode(HttpStatus.NO_CONTENT)
retryNotificationDelivery(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<void> {
return this.deliveries.retry(id, actor.id);
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { JobRunService } from './job-run.service';
import { NotificationDeliveriesService } from './notification-deliveries.service';
import { SystemController } from './system.controller';
import { SystemQueueService } from './system-queue.service';
@@ -11,7 +12,7 @@ import { SystemQueueService } from './system-queue.service';
@Module({
imports: [NotificationsModule],
controllers: [SystemController],
providers: [JobRunService, SystemQueueService],
providers: [JobRunService, SystemQueueService, NotificationDeliveriesService],
exports: [JobRunService],
})
export class SystemModule {}