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:
1 parent
16cc557462
commit
fab7f7e12f
91 files changed
+8463
-104
No files matched your search
@@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user