mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
About forty refusal messages still printed the error string the server sent: English in every locale, and for a dropped connection the browser's own TypeError text. Mood tags, targets and thresholds, family history, medication side effects, phases, API tokens and dose imports, the Coach model picker, the AI provider, fallback chain, timeout and Codex cards, the notification channel cards, the avatar, passkeys, security keys, the authenticator card, the re-proof dialog and the admin pages now resolve the refusal's code through localizedApiError and fall back to the surface's own sentence. Two refusals carried something a person needs and had no code: a private AI base URL (the AI_PRIVATE_ORIGINS hint) and removing the last passkey. Both now send a code with a sentence of their own. A throttled re-proof says so in the reader's language. Raw fetch sites throw the same ApiError as the verb helpers through the exported throwApiError, which replaces the admin's getApiErrorMessage; the authenticator card's copy of describeStepUp is gone, and describeStepUp no longer passes a non-step-up refusal's prose through (its test pinned that). The admin password reset keeps the server's verdict on a 422: the server words the strength and breach verdict in the account's language.
108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
/**
|
|
* The instant enable switch of a notification channel card (Telegram, ntfy,
|
|
* webhook, email).
|
|
*
|
|
* The switch saves on tap, optimistically, and rolls back when the server
|
|
* refuses. It sits outside the card's form: the form's Save covers the
|
|
* channel's fields (address, topic, token), the switch only whether the
|
|
* channel delivers. The save-model guard holds that split.
|
|
*/
|
|
import {
|
|
useMutation,
|
|
useQueryClient,
|
|
type QueryClient,
|
|
type QueryKey,
|
|
} from "@tanstack/react-query";
|
|
|
|
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
|
import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch";
|
|
import { localizedApiError } from "@/lib/api/localized-error";
|
|
import { showErrorToast } from "@/lib/errors/error-toast";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
|
|
|
|
interface EnabledSettings {
|
|
enabled: boolean;
|
|
}
|
|
|
|
/**
|
|
* A refused channel save in the reader's language. The private-origin policy
|
|
* reads like the test button's verdict; anything else goes through the API
|
|
* catalogue, and a request that never reached the server (offline, a dropped
|
|
* connection) reads as the plain "could not save". Never the server's prose.
|
|
*/
|
|
export function channelSaveError(
|
|
err: unknown,
|
|
t: (key: string) => string,
|
|
): string {
|
|
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
|
|
return (
|
|
translateCodeOrNull(
|
|
t,
|
|
"settings.testConnection.errors",
|
|
typeof code === "string" ? code : null,
|
|
) ?? localizedApiError(err, t, "settings.savingError")
|
|
);
|
|
}
|
|
|
|
export async function persistChannelEnabled(
|
|
endpoint: string,
|
|
enabled: boolean,
|
|
): Promise<void> {
|
|
const response = await apiFetchRaw(endpoint, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
if (!response.ok) await throwApiError(response);
|
|
}
|
|
|
|
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
|
|
queryClient: QueryClient,
|
|
queryKey: QueryKey,
|
|
enabled: boolean,
|
|
): T | undefined {
|
|
const previous = queryClient.getQueryData<T>(queryKey);
|
|
queryClient.setQueryData<T>(queryKey, (current) =>
|
|
current ? { ...current, enabled } : current,
|
|
);
|
|
return previous;
|
|
}
|
|
|
|
export function rollbackChannelEnabled<T extends EnabledSettings>(
|
|
queryClient: QueryClient,
|
|
queryKey: QueryKey,
|
|
previous: T | undefined,
|
|
): void {
|
|
queryClient.setQueryData(queryKey, previous);
|
|
}
|
|
|
|
/**
|
|
* The mutation behind a channel's enable switch. A toggle is no form, so its
|
|
* outcome is a toast either way; the switch itself already shows the state.
|
|
*/
|
|
export function useChannelEnabledToggle<T extends EnabledSettings>(
|
|
endpoint: string,
|
|
queryKey: QueryKey,
|
|
) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
return useMutation<void, Error, boolean, T | undefined>({
|
|
mutationFn: (enabled) => persistChannelEnabled(endpoint, enabled),
|
|
onMutate: async (enabled) => {
|
|
await queryClient.cancelQueries({ queryKey });
|
|
return optimisticallySetChannelEnabled<T>(queryClient, queryKey, enabled);
|
|
},
|
|
onSuccess: () => {
|
|
toastWrittenOutcome("success", t("settings.saved"));
|
|
},
|
|
onError: (err, _enabled, previous) => {
|
|
rollbackChannelEnabled(queryClient, queryKey, previous);
|
|
showErrorToast(channelSaveError(err, t), { cause: err });
|
|
},
|
|
onSettled: () => {
|
|
void queryClient.invalidateQueries({ queryKey });
|
|
},
|
|
});
|
|
}
|