mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(notifications): add a Gotify payload format to the webhook
Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras. Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does. The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter. Refs #947
This commit is contained in:
1 parent
92606f6409
commit
fafb5cdc08
17 files changed
+556
-50
No files matched your search
@@ -154,4 +154,27 @@ describe("notification channel cards", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("webhook card offers the payload format and sends it with the full save (#947)", async () => {
|
||||
const html = render(<WebhookCard isAuthenticated />);
|
||||
|
||||
expect(html).toContain('id="webhook-format"');
|
||||
expect(html).toMatch(/<option value="generic" selected="">/);
|
||||
expect(html).toContain('<option value="gotify">');
|
||||
|
||||
// The full save is the first mutation of the last render, whose closure
|
||||
// holds the values seeded from the server.
|
||||
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
|
||||
await save.mutationFn(false);
|
||||
|
||||
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
|
||||
string,
|
||||
{ body: string },
|
||||
];
|
||||
expect(JSON.parse(init.body)).toMatchObject({
|
||||
url: "https://saved.example/hook",
|
||||
format: "generic",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import type { WebhookPayloadFormat } from "@/lib/notifications/types";
|
||||
|
||||
interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
headerName: string;
|
||||
hasHeaderValue: boolean;
|
||||
format?: WebhookPayloadFormat;
|
||||
}
|
||||
|
||||
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
@@ -35,6 +38,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [headerName, setHeaderName] = useState("");
|
||||
const [headerValue, setHeaderValue] = useState("");
|
||||
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
@@ -50,13 +54,14 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings
|
||||
? `${settings.url}|${settings.headerName}`
|
||||
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setUrl(settings!.url);
|
||||
setHeaderName(settings!.headerName);
|
||||
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
@@ -68,6 +73,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
url,
|
||||
headerName: headerName || undefined,
|
||||
headerValue: headerValue || undefined,
|
||||
format,
|
||||
enabled,
|
||||
}),
|
||||
});
|
||||
@@ -180,6 +186,30 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
{t("settings.webhookUrlHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="webhook-format">
|
||||
{t("settings.webhookFormat")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="webhook-format"
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(e.target.value === "gotify" ? "gotify" : "generic")
|
||||
}
|
||||
>
|
||||
<option value="generic">
|
||||
{t("settings.webhookFormatGeneric")}
|
||||
</option>
|
||||
<option value="gotify">
|
||||
{t("settings.webhookFormatGotify")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
{format === "gotify" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.webhookFormatGotifyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-header-name">
|
||||
{t("settings.webhookHeaderName")}
|
||||
|
||||
Reference in new issue
Block a user