feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
- 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:
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" });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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%`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in new issue
Block a user