feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
Backend Image / build-and-push (push) Successful in 4m27s
Frontend Image / build-and-push (push) Successful in 9m34s
Manifests / validate (push) Successful in 19s

- Bottling form: jar size and best-before from the product with source,
  differs-hints before saving, prefill status, water-content confirmation
  naming the measured value, idempotency key renewed on body change;
  product form gets shelf life (months) and whole-gram jar weight.
- Telegram frontend: link card with step-up dialog, t.me-only deep link,
  status polling, unlink; system Telegram card with receiver/sender state
  and confirmed retry; StepUpDialog shared with the backup import.
- Reauth calls never refresh-and-retry on 401 (no double lockout count);
  German texts for expired session and 429; service worker never caches
  Telegram, notification settings and system responses.
- Telegram backend round 2: shared link removal on every disconnect path,
  lease released on shutdown, connection errors not counted as poison.
- ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 01:21:08 +02:00
1 parent fab7f7e12f
commit 9760ce154f
73 files changed
+3633 -269

No files matched your search

@@ -144,6 +144,27 @@ describe("api-client token refresh", () => {
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/things"))).toHaveLength(1);
});
it.each([
["JSON", () => apiFetch("/proof", { method: "POST" }, { refreshOn401: false })],
[
"form data",
() => apiFetchFormData("/proof", new FormData(), { refreshOn401: false }),
],
])("sends %s once and shows the server's message on 401 with refreshOn401: false", async (_name, call) => {
const fetchMock = vi.fn<(url: string) => Promise<Response>>(async () =>
jsonResponse(401, { message: "Das Passwort ist falsch." }),
);
vi.stubGlobal("fetch", fetchMock);
await expect(call()).rejects.toMatchObject({
status: 401,
message: "Das Passwort ist falsch.",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0]?.[0])).not.toContain("/auth/refresh");
});
it("takes the token as it is without a handler", async () => {
stubServer({ accessToken: "fresh" });
+19 -3
View File
@@ -83,6 +83,18 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom
export interface FormDataOptions {
headers?: HeadersInit;
timeoutMs?: number;
/** See ApiFetchOptions.refreshOn401. */
refreshOn401?: boolean;
}
export interface ApiFetchOptions {
/**
* Default true: a 401 refreshes the token and repeats the request. Step-up
* calls (password / TOTP proof) set false: their 401 means "proof wrong",
* a repeat would count twice towards the lockout and a failed refresh
* could log the person out. The server's message is shown unchanged.
*/
refreshOn401?: boolean;
}
async function rawFetchFormData(
@@ -111,7 +123,7 @@ export async function apiFetchFormData<T>(
): Promise<T> {
let response = await rawFetchFormData(path, formData, options);
if (response.status === 401) {
if (response.status === 401 && options.refreshOn401 !== false) {
const refreshed = await refreshRequest();
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
response = await rawFetchFormData(path, formData, options);
@@ -125,10 +137,14 @@ export async function apiFetchFormData<T>(
return response.json() as Promise<T>;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
export async function apiFetch<T>(
path: string,
init: RequestInit = {},
options: ApiFetchOptions = {},
): Promise<T> {
let response = await rawFetch(path, init);
if (response.status === 401) {
if (response.status === 401 && options.refreshOn401 !== false) {
const refreshed = await refreshRequest();
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
response = await rawFetch(path, init);
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import { stepUpErrorText } from "./step-up-error";
describe("stepUpErrorText", () => {
it("maps a generic 401 to the session text", () => {
for (const message of ["Unauthorized", "", "Request failed"]) {
expect(stepUpErrorText(new ApiError(401, message), "x")).toBe(
"Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.",
);
}
});
it("passes a German reauth message through unchanged", () => {
expect(stepUpErrorText(new ApiError(401, "Das Passwort ist falsch."), "x")).toBe(
"Das Passwort ist falsch.",
);
});
it("maps 429 and falls back for other errors", () => {
expect(stepUpErrorText(new ApiError(429, "ThrottlerException"), "x")).toBe(
"Zu viele Versuche. Bitte in einer Minute noch einmal.",
);
expect(stepUpErrorText(new Error("boom"), "Fallback")).toBe("Fallback");
});
});
@@ -0,0 +1,27 @@
import { ApiError } from "@/lib/auth/api-client";
import { saveErrorText } from "@/lib/honey-batches/errors";
export const SESSION_EXPIRED_TEXT =
"Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.";
export const TOO_MANY_ATTEMPTS_TEXT = "Zu viele Versuche. Bitte in einer Minute noch einmal.";
/** Step-up calls skip the silent refresh, so a 401 can also mean "session over". */
function isGenericUnauthorized(error: ApiError): boolean {
const message = error.message.trim();
return message === "" || message === "Unauthorized" || message === "Request failed";
}
/**
* The ONE place that words a failed step-up call (password / TOTP proof):
* 429 -> too many attempts, a bare 401 -> session expired, a German server
* message (wrong password or code) unchanged, anything else as saveErrorText.
* The api client does not know the token's expiry, so there is no proactive
* refresh before the call.
*/
export function stepUpErrorText(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
if (error.status === 429) return TOO_MANY_ATTEMPTS_TEXT;
if (error.status === 401 && isGenericUnauthorized(error)) return SESSION_EXPIRED_TEXT;
}
return saveErrorText(error, fallback);
}
+3 -1
View File
@@ -22,7 +22,9 @@ export function importBackup(file: File, proof: BackupImportProof): Promise<Back
formData.append("mfaCode", proof.mfaCode);
}
formData.append("file", file);
return apiFetchFormData<BackupImportSummary>("/backup/import", formData);
return apiFetchFormData<BackupImportSummary>("/backup/import", formData, {
refreshOn401: false,
});
}
export function getBackupSchedule(): Promise<BackupScheduleStatus> {
+16
View File
@@ -1,7 +1,10 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import {
BottlingBatch,
BottlingBatchFilter,
BottlingPrefill,
BottlingPrefillQuery,
CreateBottlingBatchInput,
UpdateBottlingBatchInput,
} from "./types";
@@ -19,11 +22,24 @@ export function fetchBottlingBatch(id: string): Promise<BottlingBatch> {
return apiFetch<BottlingBatch>(`/bottling-batches/${encodeURIComponent(id)}`);
}
export function fetchBottlingPrefill(query: BottlingPrefillQuery): Promise<BottlingPrefill> {
const params = new URLSearchParams({ honeyBatchId: query.honeyBatchId });
if (query.productId) params.set("productId", query.productId);
if (query.date) params.set("date", query.date);
return apiFetch<BottlingPrefill>(`/bottling-batches/prefill?${params.toString()}`);
}
/**
* The same key for the first try and the confirmed retry after a 409: the
* server frees the key on the 409, so the retry is the one real create.
*/
export function createBottlingBatch(
input: CreateBottlingBatchInput,
options: CreateOptions = {},
): Promise<BottlingBatch> {
return apiFetch<BottlingBatch>("/bottling-batches", {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
@@ -0,0 +1,7 @@
// One decimal as a rule (20,5), more only when one would round onto the limit (20,04).
const WATER_CONTENT_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
/** "20,5 %" */
export function formatWaterContent(percent: number): string {
return `${WATER_CONTENT_FORMAT.format(percent)}\u00a0%`;
}
+26 -1
View File
@@ -1,4 +1,7 @@
import type { WithWarnings } from "@/lib/warnings/types";
import type { PlausibilityWarning, WithWarnings } from "@/lib/warnings/types";
/** The only finding a bottling has to be confirmed for (water content over the legal limit). */
export const WATER_CONTENT_ABOVE_LEGAL = "WATER_CONTENT_ABOVE_LEGAL";
export interface BottlingBatch extends WithWarnings {
id: string;
@@ -26,6 +29,28 @@ export interface CreateBottlingBatchInput {
jarCount: number;
bestBeforeDate: string;
lotNumber?: string | null;
/** Only on the confirmed retry after a 409 `CONFIRMATION_REQUIRED`. */
acknowledgedWarnings?: (typeof WATER_CONTENT_ABOVE_LEGAL)[];
/** The `waterContent` of that 409; the server accepts the confirmation only for this value. */
acknowledgedWaterContent?: number;
}
/** Jar size and best-before day from the product (master data), plus the batch's water content. */
export interface BottlingPrefill {
jarSize: number | null;
jarSizeSource: "PRODUCT" | null;
/** YYYY-MM-DD */
bestBeforeDate: string | null;
shelfLifeMonths: number | null;
waterContent: number | null;
warnings: PlausibilityWarning[];
}
export interface BottlingPrefillQuery {
honeyBatchId: string;
productId?: string | null;
/** YYYY-MM-DD; the server uses today when left out. */
date?: string;
}
export interface UpdateBottlingBatchInput {
@@ -15,8 +15,8 @@ export interface SaveError {
/**
* The ONE place that reads a failed honey save: 400 `issues[]` per field,
* Nest's English `message[]` replaced, a service message (409) as it is.
* A 409 `CONFIRMATION_REQUIRED` (water content) is to be recognised here
* (`status === 409` and `body.code`) once that flow exists.
* A 409 `CONFIRMATION_REQUIRED` (water content) is no failure to show as
* text: `confirmationRequired` recognises it so the form can ask first.
*/
export function describeSaveError(error: unknown, fallback: string): SaveError {
if (isNetworkError(error)) return { general: OFFLINE_TEXT, fields: {} };
@@ -35,3 +35,21 @@ export function saveErrorText(error: unknown, fallback: string): string {
const { general, fields } = describeSaveError(error, fallback);
return general ?? Object.values(fields).join(" ");
}
export interface ConfirmationRequired {
/** The measured value the server checked; the confirmed retry must send it back. */
waterContent: number;
message: string;
}
/** A 409 `CONFIRMATION_REQUIRED` with its measured value, otherwise `null`. */
export function confirmationRequired(error: unknown): ConfirmationRequired | null {
if (!(error instanceof ApiError) || error.status !== 409) return null;
const body = error.body as { code?: unknown; waterContent?: unknown; message?: unknown } | null;
if (body?.code !== "CONFIRMATION_REQUIRED") return null;
if (typeof body.waterContent !== "number" || !Number.isFinite(body.waterContent)) return null;
return {
waterContent: body.waterContent,
message: typeof body.message === "string" ? body.message : "",
};
}
@@ -1,5 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { NotificationSettings } from "./types";
import type { NotificationSettings, NotificationSettingsPatch } from "./types";
const PATH = "/profile/notification-settings";
@@ -9,7 +9,7 @@ export function fetchNotificationSettings(): Promise<NotificationSettings> {
/** Send only what changed; an omitted field stays as it is. */
export function updateNotificationSettings(
patch: Partial<NotificationSettings>,
patch: NotificationSettingsPatch,
): Promise<NotificationSettings> {
return apiFetch<NotificationSettings>(PATH, {
method: "PATCH",
@@ -0,0 +1,2 @@
/** One cache entry for the settings card and the Telegram card. */
export const NOTIFICATION_SETTINGS_QUERY_KEY = ["notification-settings"] as const;
@@ -1,4 +1,5 @@
import type { NotificationCategory } from "@/lib/notifications/types";
import type { TelegramLinkStatus } from "@/lib/telegram/types";
/** The signed-in user's own settings (GET/PATCH /profile/notification-settings). */
export interface NotificationSettings {
@@ -11,4 +12,9 @@ export interface NotificationSettings {
quietUntil: string | null;
telegramCategories: NotificationCategory[];
telegramDetail: boolean;
/** Read-only connection state; changed through /profile/telegram/*. */
telegram: TelegramLinkStatus;
}
/** What PATCH accepts: the editable fields. */
export type NotificationSettingsPatch = Partial<Omit<NotificationSettings, "telegram">>;
@@ -10,6 +10,14 @@ const base: NotificationSettings = {
quietUntil: "06:30",
telegramCategories: ["TASK_DUE", "DIGEST"],
telegramDetail: true,
telegram: {
botNameSet: true,
botUsername: "apicore_bot",
linked: false,
firstName: null,
linkedAt: null,
enabled: false,
},
};
describe("validateDigestTime", () => {
@@ -1,4 +1,4 @@
import type { NotificationSettings } from "./types";
import type { NotificationSettings, NotificationSettingsPatch } from "./types";
export const DIGEST_TIME_MIN = "05:00";
export const DIGEST_TIME_MAX = "12:00";
@@ -24,8 +24,8 @@ export function validateQuietHours(from: string, until: string): string | null {
export function settingsPatch(
original: NotificationSettings,
draft: NotificationSettings,
): Partial<NotificationSettings> {
const patch: Partial<NotificationSettings> = {};
): NotificationSettingsPatch {
const patch: NotificationSettingsPatch = {};
if (draft.taskDueEnabled !== original.taskDueEnabled) patch.taskDueEnabled = draft.taskDueEnabled;
if (draft.digestEnabled !== original.digestEnabled) patch.digestEnabled = draft.digestEnabled;
if (draft.digestTime !== original.digestTime) patch.digestTime = draft.digestTime;
@@ -0,0 +1,14 @@
import type { NotificationCategory } from "./types";
export const NOTIFICATION_CATEGORY_LABELS: Record<NotificationCategory, string> = {
TASK_DUE: "Aufgaben fällig",
DIGEST: "Morgenübersicht",
SUGGESTION: "Vorschläge",
WITHDRAWAL: "Wartezeit",
SEASON: "Saison",
SYSTEM: "System",
};
export const NOTIFICATION_CATEGORIES = Object.keys(
NOTIFICATION_CATEGORY_LABELS,
) as NotificationCategory[];
@@ -195,6 +195,21 @@ describe("service worker routes", () => {
expect(cachesApi("/api/v1/apiaries/a-1/weather")).toBe(true);
});
it("never caches the Telegram link, the notification settings and the System page", () => {
for (const path of [
"/api/v1/profile/telegram/link-token",
"/api/v1/profile/telegram/unlink",
"/api/v1/profile/notification-settings",
"/api/v1/system/overview",
"/api/v1/system/notification-deliveries",
"/api/v1/System/overview",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/profile")).toBe(true);
expect(cachesApi("/api/v1/systems")).toBe(true);
});
it("never caches exports and backups - full records incl. coordinates, addresses, megabytes", () => {
for (const path of [
"/api/v1/export/apiaries.json",
@@ -11,6 +11,8 @@ export interface Product {
description: string | null;
sku: string | null;
packagingSize: number | null;
/** Months the honey stays good; the bottling form derives its best-before day from it. */
shelfLifeMonths: number | null;
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
@@ -25,7 +27,9 @@ export interface CreateProductInput {
name: string;
description?: string | null;
sku?: string | null;
/** Grams per jar, like the bottling jar size. */
packagingSize?: number | null;
shelfLifeMonths?: number | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
@@ -37,7 +41,9 @@ export interface UpdateProductInput {
name?: string;
description?: string | null;
sku?: string | null;
/** Grams per jar, like the bottling jar size. */
packagingSize?: number | null;
shelfLifeMonths?: number | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
@@ -0,0 +1,22 @@
export const PACKAGING_SIZE_PROBLEM = "Bitte ganze Gramm von 1 bis 10000 angeben.";
export const SHELF_LIFE_PROBLEM = "Bitte ganze Monate von 1 bis 60 angeben.";
const MAX_PACKAGING_GRAMS = 10000;
const MAX_SHELF_LIFE_MONTHS = 60;
function isWholeNumberBetween(value: string, min: number, max: number): boolean {
const number = Number(value);
return Number.isInteger(number) && number >= min && number <= max;
}
/** Empty is fine (the field can be left out or cleared); otherwise whole grams 1 to 10000. */
export function packagingSizeProblem(value: string): string | null {
if (value.trim() === "") return null;
return isWholeNumberBetween(value, 1, MAX_PACKAGING_GRAMS) ? null : PACKAGING_SIZE_PROBLEM;
}
/** Empty is fine (no shelf life on the product); otherwise whole months 1 to 60. */
export function shelfLifeProblem(value: string): string | null {
if (value.trim() === "") return null;
return isWholeNumberBetween(value, 1, MAX_SHELF_LIFE_MONTHS) ? null : SHELF_LIFE_PROBLEM;
}
+4 -4
View File
@@ -12,14 +12,14 @@ export function changePassword(currentPassword: string, newPassword: string): Pr
return apiFetch<void>("/profile/password", {
method: "POST",
body: JSON.stringify({ currentPassword, newPassword }),
});
}, { refreshOn401: false });
}
export function deactivateAccount(currentPassword: string): Promise<void> {
return apiFetch<void>("/profile", {
method: "DELETE",
body: JSON.stringify({ currentPassword }),
});
}, { refreshOn401: false });
}
// Sent as a base64 data: URL in a plain JSON body rather than
@@ -73,12 +73,12 @@ export function confirmMfaEnrollment(
return apiFetch<void>("/profile/mfa/enroll/confirm", {
method: "POST",
body: JSON.stringify({ setupToken, code, currentPassword }),
});
}, { refreshOn401: false });
}
export function disableMfa(currentPassword: string, mfaCode: string): Promise<void> {
return apiFetch<void>("/profile/mfa/disable", {
method: "POST",
body: JSON.stringify({ currentPassword, mfaCode }),
});
}, { refreshOn401: false });
}
@@ -23,6 +23,20 @@ describe("system labels", () => {
expect(DOMAIN_EVENT_LABELS.InspectionRecorded).toBe("Durchsicht erfasst");
});
it("DOMAIN_EVENT_LABELS names ColonySplit, QueenAssigned, the extraction, honey batch and phenology events and SeasonPhaseChanged", () => {
expect(DOMAIN_EVENT_LABELS).toMatchObject({
ColonySplit: "Ableger angelegt",
QueenAssigned: "Königin zugewiesen",
ExtractionRecorded: "Schleuderung erfasst",
ExtractionCorrected: "Schleuderung korrigiert",
HoneyBatchRecorded: "Honigcharge angelegt",
HoneyBatchCorrected: "Honigcharge korrigiert",
PhenologyReported: "Pflanzenmeldung erfasst",
PhenologyCorrected: "Pflanzenmeldung korrigiert",
SeasonPhaseChanged: "Saisonabschnitt gewechselt",
});
});
it("warns after ten minutes", () => {
expect(QUEUE_STALE_WARNING_SECONDS).toBe(600);
});
@@ -47,6 +47,15 @@ export const DOMAIN_EVENT_LABELS: Record<string, string> = {
QueenAged: "Königin gealtert",
VarroaMeasurementDue: "Varroamessung fällig",
InspectionOverdue: "Durchsicht überfällig",
ColonySplit: "Ableger angelegt",
QueenAssigned: "Königin zugewiesen",
ExtractionRecorded: "Schleuderung erfasst",
ExtractionCorrected: "Schleuderung korrigiert",
HoneyBatchRecorded: "Honigcharge angelegt",
HoneyBatchCorrected: "Honigcharge korrigiert",
PhenologyReported: "Pflanzenmeldung erfasst",
PhenologyCorrected: "Pflanzenmeldung korrigiert",
SeasonPhaseChanged: "Saisonabschnitt gewechselt",
};
/** The oldest open event is this old (seconds) before the worker counts as stuck. */
+42
View File
@@ -0,0 +1,42 @@
import { apiFetch } from "@/lib/auth/api-client";
import type {
NotificationDeliveriesOverview,
NotificationDeliveryStatus,
StepUpProof,
TelegramLinkToken,
} from "./types";
const DELIVERIES_PATH = "/system/notification-deliveries";
/**
* Asks for a one-time Telegram link. The answer holds a secret (the server
* sends `Cache-Control: no-store`): the caller keeps it in component state
* only - not in React Query, not in storage, not in a log.
*/
export function createTelegramLinkToken(proof: StepUpProof): Promise<TelegramLinkToken> {
return apiFetch<TelegramLinkToken>(
"/profile/telegram/link-token",
{ method: "POST", body: JSON.stringify(proof) },
{ refreshOn401: false },
);
}
export function unlinkTelegram(): Promise<void> {
return apiFetch<void>("/profile/telegram/unlink", { method: "POST" });
}
export function fetchNotificationDeliveries(
status?: NotificationDeliveryStatus,
): Promise<NotificationDeliveriesOverview> {
const query = status ? `?status=${encodeURIComponent(status)}` : "";
return apiFetch<NotificationDeliveriesOverview>(`${DELIVERIES_PATH}${query}`);
}
export function retryNotificationDelivery(id: string): Promise<void> {
return apiFetch<void>(`${DELIVERIES_PATH}/${encodeURIComponent(id)}/retry`, { method: "POST" });
}
/** Re-queues up to 100 of the newest failed deliveries; call again for more. */
export function retryFailedNotificationDeliveries(): Promise<{ retried: number }> {
return apiFetch<{ retried: number }>(`${DELIVERIES_PATH}/retry-failed`, { method: "POST" });
}
@@ -0,0 +1,24 @@
const BERLIN = "Europe/Berlin";
const TIME = new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
timeZone: BERLIN,
});
const DATE = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: BERLIN,
});
/** "12:10" (Berlin wall clock). */
export function formatBerlinTime(iso: string): string {
return TIME.format(new Date(iso));
}
/** "14.10.2026" (Berlin calendar day). */
export function formatBerlinDate(iso: string): string {
return DATE.format(new Date(iso));
}
@@ -0,0 +1,17 @@
import type { NotificationDeliveryStatus } from "./types";
export const NOTIFICATION_DELIVERY_STATUS_LABELS: Record<NotificationDeliveryStatus, string> = {
PENDING: "Offen",
PROCESSING: "Wird zugestellt",
DELIVERED: "Zugestellt",
SKIPPED: "Übersprungen",
FAILED: "Fehlgeschlagen",
};
/** "1 Zustellung wird erneut versucht" / "3 Zustellungen werden erneut versucht". */
export function retriedLabel(count: number): string {
if (count === 0) return "Keine fehlgeschlagene Zustellung zu wiederholen";
return count === 1
? "1 Zustellung wird erneut versucht"
: `${count} Zustellungen werden erneut versucht`;
}
@@ -0,0 +1,68 @@
import type { NotificationCategory } from "@/lib/notifications/types";
/** Step-up proof: current password, plus the TOTP code when MFA is on. */
export interface StepUpProof {
password: string;
mfaCode?: string;
}
/** `telegram` part of GET /profile/notification-settings (never a chat id). */
export interface TelegramLinkStatus {
/** The bot name is set on the server. The API pod never holds the token. */
botNameSet: boolean;
botUsername: string | null;
linked: boolean;
firstName: string | null;
linkedAt: string | null;
enabled: boolean;
}
/** POST /profile/telegram/link-token. A secret: keep it in component state only. */
export interface TelegramLinkToken {
deepLink: string;
expiresAt: string;
}
export type NotificationDeliveryStatus =
| "PENDING"
| "PROCESSING"
| "DELIVERED"
| "SKIPPED"
| "FAILED";
/** GET /system/notification-deliveries, `telegram` part. */
export interface TelegramStatusView {
/** Only "the bot name is set"; whether sending works is known to the worker alone. */
botNameSet: boolean;
botUsername: string | null;
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: string }[];
};
}
/** One delivery - never a chat id, a user or the text. */
export interface NotificationDeliveryView {
id: string;
status: NotificationDeliveryStatus;
category: NotificationCategory | null;
templateCode: string | null;
attempts: number;
availableAt: string;
lastStatusCode: number | null;
lastError: string | null;
createdAt: string;
deliveredAt: string | null;
}
export interface NotificationDeliveriesOverview {
telegram: TelegramStatusView;
deliveries: NotificationDeliveryView[];
}