Files
ApiCore/ApiCore-Backend/src/system/notification-deliveries.service.ts
T
Nick SzaunigandClaude Opus 5.5 fab7f7e12f 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]>
2026-10-14 22:47:17 +02:00

255 lines
8.5 KiB
TypeScript

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 };
});
}
}