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,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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
Reference in new issue
Block a user