- 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]>
255 lines
8.5 KiB
TypeScript
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 };
|
|
});
|
|
}
|
|
}
|