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 { const [[poll], [sender], errors, rows] = await Promise.all([ // The lease owner counts only while its lease runs; no row = never polled. this.prisma.$queryRaw` 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` 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 { 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` 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 }; }); } }