feat: enhance custom notification handling and schema

- Updated the custom notification type to include an array of headers, allowing for more flexible header management.
- Removed the obsolete KeyValueInput component, streamlining the notification settings interface.
- Adjusted the database schema to support JSONB for headers in the custom table, improving data handling.
- Enhanced the notification testing functionality to accommodate the new headers structure.
- Updated related API endpoints and utility functions to reflect these changes.
This commit is contained in:
Mauricio Siu
2025-12-07 13:42:30 -06:00
parent 212006ba9e
commit 5412c5a873
8 changed files with 7069 additions and 201 deletions

View File

@@ -1,5 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
import {
AlertTriangle,
Mail,
PenBoxIcon,
PlusIcon,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -36,7 +42,6 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { KeyValueInput } from "./key-value-input";
const notificationBaseSchema = z.object({
name: z.string().min(1, {
@@ -113,7 +118,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
.object({
type: z.literal("custom"),
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
headers: z.string().optional(),
headers: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional()
.default([]),
})
.merge(notificationBaseSchema),
z
@@ -237,6 +250,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: "toAddresses" as never,
});
const {
fields: headerFields,
append: appendHeader,
remove: removeHeader,
} = useFieldArray({
control: form.control,
name: "headers" as never,
});
useEffect(() => {
if (type === "email" && fields.length === 0) {
append("");
@@ -357,7 +379,14 @@ export const HandleNotifications = ({ notificationId }: Props) => {
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
endpoint: notification.custom?.endpoint || "",
headers: notification.custom?.headers || "",
headers: notification.custom?.headers
? Object.entries(notification.custom.headers).map(
([key, value]) => ({
key,
value,
}),
)
: [],
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
@@ -501,13 +530,25 @@ export const HandleNotifications = ({ notificationId }: Props) => {
serverThreshold: serverThreshold,
});
} else if (data.type === "custom") {
// Convert headers array to object
const headersRecord =
data.headers && data.headers.length > 0
? data.headers.reduce(
(acc, { key, value }) => {
if (key.trim()) acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: undefined;
promise = customMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
endpoint: data.endpoint,
headers: data.headers || "",
headers: headersRecord,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
@@ -1127,23 +1168,67 @@ export const HandleNotifications = ({ notificationId }: Props) => {
)}
/>
<FormField
control={form.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormControl>
<KeyValueInput
value={field.value || ""}
onChange={field.onChange}
label="Headers"
description="Optional. Custom headers for your POST request (e.g., Authorization, Content-Type)."
<div className="space-y-3">
<div>
<FormLabel>Headers</FormLabel>
<FormDescription>
Optional. Custom headers for your POST request (e.g.,
Authorization, Content-Type).
</FormDescription>
</div>
<div className="space-y-2">
{headerFields.map((field, index) => (
<div
key={field.id}
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
>
<FormField
control={form.control}
name={`headers.${index}.key` as never}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Key" {...field} />
</FormControl>
</FormItem>
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`headers.${index}.value` as never}
render={({ field }) => (
<FormItem className="flex-[2]">
<FormControl>
<Input placeholder="Value" {...field} />
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeHeader(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendHeader({ key: "", value: "" })}
className="w-full"
>
<PlusIcon className="h-4 w-4 mr-2" />
Add header
</Button>
</div>
</div>
)}
{type === "lark" && (
@@ -1338,7 +1423,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingEmail ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark
isLoadingLark ||
isLoadingCustom
}
variant="secondary"
type="button"
@@ -1392,6 +1478,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
await testLarkConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "custom") {
const headersRecord =
data.headers && data.headers.length > 0
? data.headers.reduce(
(acc, { key, value }) => {
if (key.trim()) acc[key] = value;
return acc;
},
{} as Record<string, string>,
)
: undefined;
await testCustomConnection({
endpoint: data.endpoint,
headers: headersRecord,
});
}
toast.success("Connection Success");
} catch (error) {

View File

@@ -1,165 +0,0 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface KeyValuePair {
key: string;
value: string;
enabled: boolean;
}
interface KeyValueInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
label: string;
description?: string;
}
const createEmptyPair = (): KeyValuePair => ({
key: "",
value: "",
enabled: true,
});
const parseJsonToPairs = (jsonString: string): KeyValuePair[] => {
try {
const parsed = JSON.parse(jsonString);
const pairs = Object.entries(parsed).map(([key, val]) => ({
key,
value: String(val),
enabled: true,
}));
return pairs.length > 0 ? pairs : [createEmptyPair()];
} catch {
return [createEmptyPair()];
}
};
const convertPairsToJson = (pairs: KeyValuePair[]): string => {
const enabledPairs = pairs.filter((pair) => pair.enabled && pair.key.trim());
if (enabledPairs.length === 0) {
return "";
}
const obj = enabledPairs.reduce(
(acc, pair) => {
acc[pair.key.trim()] = pair.value;
return acc;
},
{} as Record<string, string>,
);
return JSON.stringify(obj, null, 2);
};
export const KeyValueInput = ({
value,
onChange,
label,
description,
}: KeyValueInputProps) => {
const [pairs, setPairs] = useState<KeyValuePair[]>([]);
useEffect(() => {
const newPairs = value ? parseJsonToPairs(value) : [createEmptyPair()];
setPairs(newPairs);
}, [value]);
const syncPairsWithParent = (newPairs: KeyValuePair[]) => {
setPairs(newPairs);
onChange(convertPairsToJson(newPairs));
};
const addPair = () => {
syncPairsWithParent([...pairs, createEmptyPair()]);
};
const removePair = (index: number) => {
const filteredPairs = pairs.filter((_, i) => i !== index);
syncPairsWithParent(filteredPairs);
};
const updatePair = (
index: number,
field: keyof KeyValuePair,
newValue: string | boolean,
) => {
const updatedPairs = pairs.map((pair, i) =>
i === index ? { ...pair, [field]: newValue } : pair,
);
syncPairsWithParent(updatedPairs);
};
return (
<div className="space-y-3">
<div>
<Label className="text-sm font-medium">{label}</Label>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="space-y-2">
{pairs.map((pair, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
>
<div className="flex items-center">
<Checkbox
checked={pair.enabled}
onCheckedChange={(checked) =>
updatePair(index, "enabled", checked)
}
className="mr-2"
/>
</div>
<div className="flex-1">
<Input
placeholder="Key"
value={pair.key}
onChange={(e) => updatePair(index, "key", e.target.value)}
className="text-sm"
disabled={!pair.enabled}
/>
</div>
<div className="flex-[2]">
<Input
placeholder="Value"
value={pair.value}
onChange={(e) => updatePair(index, "value", e.target.value)}
className="text-sm"
disabled={!pair.enabled}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removePair(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addPair}
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
Add {label.toLowerCase()}
</Button>
</div>
);
};

View File

@@ -0,0 +1,9 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'custom' BEFORE 'lark';--> statement-breakpoint
CREATE TABLE "custom" (
"customId" text PRIMARY KEY NOT NULL,
"endpoint" text NOT NULL,
"headers" jsonb
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "customId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_customId_custom_customId_fk" FOREIGN KEY ("customId") REFERENCES "public"."custom"("customId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -904,6 +904,13 @@
"when": 1765101709413,
"tag": "0128_hard_falcon",
"breakpoints": true
},
{
"idx": 129,
"version": "7",
"when": 1765136384035,
"tag": "0129_pale_roughhouse",
"breakpoints": true
}
]
}

View File

@@ -573,7 +573,7 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}

View File

@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -131,7 +138,7 @@ export const custom = pgTable("custom", {
.primaryKey()
.$defaultFn(() => nanoid()),
endpoint: text("endpoint").notNull(),
headers: text("headers"), // JSON string
headers: jsonb("headers").$type<Record<string, string>>(),
});
export const lark = pgTable("lark", {
@@ -391,7 +398,7 @@ export const apiCreateCustom = notificationsSchema
})
.extend({
endpoint: z.string().min(1),
headers: z.string().optional(),
headers: z.record(z.string()).optional(),
});
export const apiUpdateCustom = apiCreateCustom.partial().extend({
@@ -402,7 +409,7 @@ export const apiUpdateCustom = apiCreateCustom.partial().extend({
export const apiTestCustomConnection = z.object({
endpoint: z.string().min(1),
headers: z.string().optional(),
headers: z.record(z.string()).optional(),
});
export const apiCreateLark = notificationsSchema

View File

@@ -181,17 +181,11 @@ export const sendCustomNotification = async (
payload: Record<string, any>,
) => {
try {
// Parse headers if provided
let headers: Record<string, string> = {
// Merge default headers with custom headers (now already an object from jsonb)
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(connection.headers || {}),
};
if (connection.headers) {
try {
headers = { ...headers, ...JSON.parse(connection.headers) };
} catch (error) {
console.error("Error parsing headers:", error);
}
}
// Default body with payload
const body = JSON.stringify(payload);