mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Working on implementation of alert policies.
This commit is contained in:
+11
-3
@@ -14,6 +14,8 @@ import {getOrganizationProjectDatabases} from "@/lib/services";
|
|||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
|
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
|
||||||
|
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
export default async function RoutePage(props: PageParams<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -37,7 +39,8 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
|
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
|
||||||
with: {
|
with: {
|
||||||
project: true,
|
project: true,
|
||||||
retentionPolicy: true
|
retentionPolicy: true,
|
||||||
|
alertPolicies: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +85,9 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organizationChannels = await getOrganizationChannels(organization.id);
|
||||||
|
console.log(organizationChannels);
|
||||||
|
|
||||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
|
|
||||||
const isMember = activeMember?.role === "member";
|
const isMember = activeMember?.role === "member";
|
||||||
@@ -101,6 +107,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
{/*<EditButton/>*/}
|
{/*<EditButton/>*/}
|
||||||
<RetentionPolicySheet database={dbItem}/>
|
<RetentionPolicySheet database={dbItem}/>
|
||||||
<CronButton database={dbItem}/>
|
<CronButton database={dbItem}/>
|
||||||
|
<AlertPolicyModal database={dbItem} notificationChannels={organizationChannels} organizationId={organization.id} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||||
@@ -114,9 +121,10 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
||||||
)}
|
)}
|
||||||
<PageContent className="flex flex-col w-full h-full">
|
<PageContent className="flex flex-col w-full h-full">
|
||||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||||
totalBackups={totalBackups}/>
|
totalBackups={totalBackups}/>
|
||||||
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
|
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem}
|
||||||
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
backups={backups}
|
backups={backups}
|
||||||
restorations={restorations}/>
|
restorations={restorations}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {useState} from "react";
|
|
||||||
import {Pencil, Plus} from "lucide-react";
|
import {Pencil, Plus} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -131,25 +131,28 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="flex gap-4 justify-end">
|
<div className="flex justify-between">
|
||||||
{defaultValues && (
|
<div>
|
||||||
<NotifierTestChannelButton notificationChannel={defaultValues}/>
|
{defaultValues && (
|
||||||
)}
|
<NotifierTestChannelButton notificationChannel={defaultValues}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
onSuccessAction?.();
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>
|
||||||
|
Add Channel
|
||||||
|
</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
onSuccessAction?.();
|
|
||||||
form.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>
|
|
||||||
Add Channel
|
|
||||||
</ButtonWithLoading>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
+35
-12
@@ -4,6 +4,8 @@ import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
|||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||||
import {EventPayload} from "@/features/notifications/types";
|
import {EventPayload} from "@/features/notifications/types";
|
||||||
|
import {Send} from "lucide-react";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
type NotifierTestChannelButtonProps = {
|
type NotifierTestChannelButtonProps = {
|
||||||
notificationChannel: NotificationChannel;
|
notificationChannel: NotificationChannel;
|
||||||
@@ -13,30 +15,51 @@ export const NotifierTestChannelButton = ({notificationChannel}: NotifierTestCha
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
||||||
|
// const payload: EventPayload = {
|
||||||
|
// title: 'Database Down',
|
||||||
|
// message: 'Primary DB instance is unreachable',
|
||||||
|
// level: 'critical',
|
||||||
|
// data: {host: 'db-prod-01', error: 'connection timeout'},
|
||||||
|
// };
|
||||||
|
|
||||||
const payload: EventPayload = {
|
const payload: EventPayload = {
|
||||||
title: 'Database Down',
|
title: 'Test Channel',
|
||||||
message: 'Primary DB instance is unreachable',
|
message: `We are testing channel ${notificationChannel.name}`,
|
||||||
level: 'critical',
|
level: 'info',
|
||||||
data: { host: 'db-prod-01', error: 'connection timeout' },
|
// data: {host: 'db-prod-01', error: 'connection timeout'},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await dispatchNotification(payload, undefined, notificationChannel.id);
|
const result = await dispatchNotification(payload, undefined, notificationChannel.id);
|
||||||
console.log(result);
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
} else {
|
||||||
|
toast.error("An error occurred while testing the notification channel");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="default"
|
||||||
onClick={async () => {
|
onClick={() => mutation.mutateAsync()}
|
||||||
await mutation.mutateAsync()
|
disabled={mutation.isPending}
|
||||||
}}
|
className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"
|
||||||
|
|
||||||
>
|
>
|
||||||
Test
|
{mutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white"/>
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="mr-2 h-4 w-4"/>
|
||||||
|
Test Channel
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import {Form, FormControl, FormField, FormItem, FormMessage, useZodForm} from "@/components/ui/form";
|
||||||
|
import {Plus, Trash2} from "lucide-react";
|
||||||
|
import {useFieldArray} from "react-hook-form";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertPoliciesSchema, AlertPoliciesType, AlertPolicySchema, AlertPolicyType,
|
||||||
|
EVENT_KIND_OPTIONS
|
||||||
|
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.schema";
|
||||||
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
import {AlertPolicy} from "@/db/schema/10_alert-policy";
|
||||||
|
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||||
|
import {Label} from "@/components/ui/label";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||||
|
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {deleteBackupAction} from "@/features/dashboard/restore/restore.action";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {
|
||||||
|
createAlertPoliciesAction, deleteAlertPoliciesAction,
|
||||||
|
updateAlertPoliciesAction
|
||||||
|
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.action";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
|
||||||
|
type AlertPolicyFormProps = {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
notificationChannels: NotificationChannel[];
|
||||||
|
organizationId: string;
|
||||||
|
database: DatabaseWith;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AlertPolicyForm = ({database, notificationChannels, organizationId, onSuccess}: AlertPolicyFormProps) => {
|
||||||
|
const router = useRouter()
|
||||||
|
console.log("database",database)
|
||||||
|
const organizationNotificationChannels = notificationChannels.map(channel => channel.id) ?? [];
|
||||||
|
|
||||||
|
const formattedAlertPoliciesList = (alertPolicies: AlertPolicy[]) => {
|
||||||
|
return alertPolicies.map((alertPolicy) => ({
|
||||||
|
notificationChannelId: alertPolicy.notificationChannelId,
|
||||||
|
eventKinds: alertPolicy.eventKinds
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useZodForm({
|
||||||
|
schema: AlertPoliciesSchema,
|
||||||
|
defaultValues: {
|
||||||
|
alertPolicies: database.alertPolicies && database.alertPolicies.length > 0 ? formattedAlertPoliciesList(database.alertPolicies) : [{
|
||||||
|
notificationChannelId: "",
|
||||||
|
eventKinds: [],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {fields, append, remove} = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "alertPolicies",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const addAlertPolicy = () => {
|
||||||
|
append({id: "", eventKinds: []})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAlertPolicy = (index: number) => {
|
||||||
|
remove(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
form.reset()
|
||||||
|
onSuccess?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async ({alertPolicies}: AlertPoliciesType) => {
|
||||||
|
|
||||||
|
console.log(alertPolicies)
|
||||||
|
|
||||||
|
const defaultFormatedAlertPolicies = formattedAlertPoliciesList(database?.alertPolicies ?? []);
|
||||||
|
|
||||||
|
const alertPoliciesToAdd = alertPolicies?.filter(
|
||||||
|
(alertPolicy) => !defaultFormatedAlertPolicies.some((a) => a.notificationChannelId == alertPolicy.notificationChannelId)
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const alertPoliciesToRemove = defaultFormatedAlertPolicies.filter(
|
||||||
|
(alertPolicy) => !alertPolicies?.some((v) => v.notificationChannelId === alertPolicy.notificationChannelId)
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const alertPoliciesToUpdate = alertPolicies?.filter((alertPolicy) => {
|
||||||
|
const existing = defaultFormatedAlertPolicies.find((a) => a.notificationChannelId === alertPolicy.notificationChannelId);
|
||||||
|
return existing &&
|
||||||
|
(existing.eventKinds !== alertPolicy.eventKinds);
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
|
||||||
|
console.log("alertPoliciesToAdd", alertPoliciesToAdd)
|
||||||
|
console.log("alertPoliciesToRemove", alertPoliciesToRemove)
|
||||||
|
console.log("alertPoliciesToUpdate", alertPoliciesToUpdate)
|
||||||
|
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
alertPoliciesToAdd.length > 0
|
||||||
|
? createAlertPoliciesAction({
|
||||||
|
databaseId: database.id,
|
||||||
|
alertPolicies: alertPoliciesToAdd,
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
|
||||||
|
alertPoliciesToUpdate.length > 0
|
||||||
|
? updateAlertPoliciesAction({
|
||||||
|
databaseId: database.id,
|
||||||
|
alertPolicies: alertPoliciesToUpdate,
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
|
||||||
|
alertPoliciesToRemove.length > 0
|
||||||
|
? deleteAlertPoliciesAction({
|
||||||
|
databaseId: database.id,
|
||||||
|
alertPolicies: alertPoliciesToRemove as AlertPolicyType[],
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(results);
|
||||||
|
|
||||||
|
const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||||
|
if (rejected) {
|
||||||
|
throw new Error(rejected.reason?.message || "Network or server error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedActions = results
|
||||||
|
.filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled")
|
||||||
|
.map(r => r.value)
|
||||||
|
.filter((value): value is { data: { success: false; actionError: any } } =>
|
||||||
|
value !== null && typeof value === "object" && value.data.success === false
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
console.log("failedActions",failedActions);
|
||||||
|
if (failedActions.length > 0) {
|
||||||
|
const firstError = failedActions[0].data.actionError;
|
||||||
|
const message = firstError?.message || "One or more operations failed";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {success: true};
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Alert policies saved successfully");
|
||||||
|
onSuccess?.();
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message || "Failed to save alert policies");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
await mutation.mutateAsync(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">Alert Policies</Label>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
fields.length >= notificationChannels.length ||
|
||||||
|
notificationChannels.length === 0
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
variant="outline" size="sm" onClick={addAlertPolicy}>
|
||||||
|
<Plus className="w-4 h-4 mr-2"/>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 w-full">
|
||||||
|
{organizationNotificationChannels.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground text-sm text-center border border-dashed rounded-lg p-4">
|
||||||
|
No alerts policy in organization
|
||||||
|
</div>
|
||||||
|
) : fields.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground text-sm text-center">
|
||||||
|
No alert policy, create one
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
fields.map((field, index) => (
|
||||||
|
<div key={field.id}>
|
||||||
|
<div className="flex w-full gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`alertPolicies.${index}.notificationChannelId`}
|
||||||
|
render={({field}) => {
|
||||||
|
|
||||||
|
|
||||||
|
const selectedIds = form
|
||||||
|
.watch("alertPolicies")
|
||||||
|
.map((a: AlertPolicyType) => a.notificationChannelId)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const availableNotificationChannels = notificationChannels.filter(
|
||||||
|
(channel) =>
|
||||||
|
channel.id.toString() === field.value?.toString() ||
|
||||||
|
!selectedIds.includes(channel.id.toString())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<Select onValueChange={field.onChange}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="h-10 w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder="Select notification channel"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{availableNotificationChannels.map((channel) => (
|
||||||
|
<SelectItem key={channel.id.toString()}
|
||||||
|
value={channel.id.toString()}>
|
||||||
|
{channel.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage className="mb-2"/>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`alertPolicies.${index}.eventKinds`}
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelect
|
||||||
|
options={EVENT_KIND_OPTIONS}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value ?? []}
|
||||||
|
placeholder="Select event kinds"
|
||||||
|
variant="inverted"
|
||||||
|
animation={2}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="button" variant="outline"
|
||||||
|
onClick={() => removeAlertPolicy(index)}>
|
||||||
|
<Trash2 className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{index + 1 < fields.length && <Separator className="mt-3"/>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading variant="outline" type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</ButtonWithLoading>
|
||||||
|
<ButtonWithLoading isPending={false}>
|
||||||
|
Submit
|
||||||
|
</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
import {Megaphone} from "lucide-react";
|
||||||
|
|
||||||
|
import {useState} from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {AlertPolicyForm} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-form";
|
||||||
|
import {Database} from "@/db/schema/07_database";
|
||||||
|
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||||
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
|
||||||
|
type AlertPolicyModalProps = {
|
||||||
|
database: Database;
|
||||||
|
notificationChannels: NotificationChannel[];
|
||||||
|
organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertPolicyModal = ({database, notificationChannels, organizationId}: AlertPolicyModalProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Megaphone/>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Alert policies</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add and manage your database alert policies
|
||||||
|
</DialogDescription>
|
||||||
|
|
||||||
|
<Separator className="mt-3 mb-3"/>
|
||||||
|
<AlertPolicyForm
|
||||||
|
organizationId={organizationId}
|
||||||
|
notificationChannels={notificationChannels}
|
||||||
|
database={database}
|
||||||
|
onSuccess={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"use server"
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, eq, inArray} from "drizzle-orm";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {
|
||||||
|
AlertPolicySchema
|
||||||
|
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.schema";
|
||||||
|
import {AlertPolicy} from "@/db/schema/10_alert-policy";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const createAlertPoliciesAction = userAction
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
databaseId: z.string(),
|
||||||
|
alertPolicies: z.array(AlertPolicySchema),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const valuesToInsert = parsedInput.alertPolicies.map((policy) => ({
|
||||||
|
databaseId: parsedInput.databaseId,
|
||||||
|
notificationChannelId: policy.notificationChannelId,
|
||||||
|
eventKinds: policy.eventKinds,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const insertedPolicies = await db
|
||||||
|
.insert(drizzleDb.schemas.alertPolicy)
|
||||||
|
.values(valuesToInsert)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: insertedPolicies,
|
||||||
|
actionSuccess: {
|
||||||
|
message: `Alert policies successfully added`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to add policies.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const updateAlertPoliciesAction = userAction
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
databaseId: z.string().min(1),
|
||||||
|
alertPolicies: z.array(AlertPolicySchema),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
|
||||||
|
const {databaseId, alertPolicies} = parsedInput;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const updatedPolicies = await db.transaction(async (tx) => {
|
||||||
|
const results: AlertPolicy[] = [];
|
||||||
|
for (const policy of alertPolicies) {
|
||||||
|
const {notificationChannelId, ...updateData} = policy;
|
||||||
|
|
||||||
|
const updated = await tx
|
||||||
|
.update(drizzleDb.schemas.alertPolicy)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
...updateData,
|
||||||
|
}))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelId),
|
||||||
|
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updated[0]) {
|
||||||
|
results.push(updated[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedPolicies.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {message: "No policies were updated."},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: updatedPolicies,
|
||||||
|
actionSuccess: {
|
||||||
|
message: `Successfully updated ${updatedPolicies.length} alert policy(ies).`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update alert policies failed:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to update alert policies.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const deleteAlertPoliciesAction = userAction
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
databaseId: z.string().min(1),
|
||||||
|
alertPolicies: z.array(AlertPolicySchema),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput }): Promise<ServerActionResult<AlertPolicy[]>> => {
|
||||||
|
const { databaseId, alertPolicies } = parsedInput;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const notificationChannelIds = alertPolicies.map((alertPolicy) => alertPolicy.notificationChannelId);
|
||||||
|
|
||||||
|
const policiesToDelete = await db
|
||||||
|
.select()
|
||||||
|
.from(drizzleDb.schemas.alertPolicy)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId),
|
||||||
|
inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (policiesToDelete.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: { message: "No alert policies found to delete." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(drizzleDb.schemas.alertPolicy)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId),
|
||||||
|
inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: policiesToDelete,
|
||||||
|
actionSuccess: {
|
||||||
|
message: `Successfully deleted ${policiesToDelete.length} alert policy(ies).`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete alert policies failed:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to delete alert policies.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
|
||||||
|
export const AlertPolicySchema =
|
||||||
|
z.object({
|
||||||
|
notificationChannelId: z.string().min(1, "Please select a notification channel"),
|
||||||
|
eventKinds: z.enum(['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report']).array().nonempty(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const AlertPoliciesSchema = z.object({
|
||||||
|
alertPolicies: z.array(AlertPolicySchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export type AlertPoliciesType = z.infer<typeof AlertPoliciesSchema>;
|
||||||
|
export type AlertPolicyType = z.infer<typeof AlertPolicySchema>;
|
||||||
|
|
||||||
|
|
||||||
|
export const EVENT_KIND_OPTIONS = [
|
||||||
|
{label: "Error Backup", value: "error_backup"},
|
||||||
|
{label: "Error Restore", value: "error_restore"},
|
||||||
|
{label: "Success Restore", value: "success_restore"},
|
||||||
|
{label: "Success Backup", value: "success_backup"},
|
||||||
|
{label: "Weekly Report", value: "weekly_report"},
|
||||||
|
];
|
||||||
-1
@@ -57,7 +57,6 @@ export const OrganizationNotifiersTab = ({
|
|||||||
}: OrganizationNotifiersTabProps) => {
|
}: OrganizationNotifiersTabProps) => {
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
|
||||||
// const hasNotifiers = 0;
|
|
||||||
const hasNotifiers = notificationChannels.length > 0;
|
const hasNotifiers = notificationChannels.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+5
-1
@@ -10,6 +10,8 @@ import * as agent from "./schema/08_agent";
|
|||||||
import * as database from "./schema/07_database";
|
import * as database from "./schema/07_database";
|
||||||
import * as notificationChannel from "./schema/09_notification-channel";
|
import * as notificationChannel from "./schema/09_notification-channel";
|
||||||
import * as organizationNotificationChannel from "./schema/09_notification-channel";
|
import * as organizationNotificationChannel from "./schema/09_notification-channel";
|
||||||
|
import * as alertPolicy from "./schema/10_alert-policy";
|
||||||
|
import * as notificationLog from "./schema/11_notification-log";
|
||||||
|
|
||||||
|
|
||||||
import {Pool} from "pg";
|
import {Pool} from "pg";
|
||||||
@@ -36,7 +38,9 @@ export const schemas = {
|
|||||||
...agent,
|
...agent,
|
||||||
...database,
|
...database,
|
||||||
...notificationChannel,
|
...notificationChannel,
|
||||||
...organizationNotificationChannel
|
...organizationNotificationChannel,
|
||||||
|
...alertPolicy,
|
||||||
|
...notificationLog
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle({
|
export const db = drizzle({
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
CREATE TYPE "public"."event_kind" AS ENUM('error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."level" AS ENUM('critical', 'warning', 'info');--> statement-breakpoint
|
||||||
|
CREATE TABLE "alert_policy" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"notification_channel_id" uuid NOT NULL,
|
||||||
|
"event_kind" "event_kind"[] NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"database_id" uuid NOT NULL,
|
||||||
|
"updated_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "notification_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"channel_id" uuid NOT NULL,
|
||||||
|
"policy_id" uuid,
|
||||||
|
"organization_id" uuid,
|
||||||
|
"title" varchar(255) NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"level" "level" NOT NULL,
|
||||||
|
"payload" jsonb,
|
||||||
|
"success" boolean NOT NULL,
|
||||||
|
"error" text,
|
||||||
|
"provider_response" jsonb,
|
||||||
|
"sent_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "alert_policy" ADD CONSTRAINT "alert_policy_notification_channel_id_notification_channel_id_fk" FOREIGN KEY ("notification_channel_id") REFERENCES "public"."notification_channel"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "alert_policy" ADD CONSTRAINT "alert_policy_database_id_databases_id_fk" FOREIGN KEY ("database_id") REFERENCES "public"."databases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_log" ADD CONSTRAINT "notification_log_channel_id_notification_channel_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."notification_channel"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_log" ADD CONSTRAINT "notification_log_policy_id_alert_policy_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."alert_policy"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_log" ADD CONSTRAINT "notification_log_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1762685571404,
|
"when": 1762685571404,
|
||||||
"tag": "0007_last_umar",
|
"tag": "0007_last_umar",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763914207236,
|
||||||
|
"tag": "0008_aberrant_scorpion",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { pgTable, text, uuid } from "drizzle-orm/pg-core";
|
import {pgTable, text, uuid} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
import {relations} from "drizzle-orm";
|
||||||
import { project } from "./06_project";
|
import {Project, project} from "./06_project";
|
||||||
import { createSelectSchema } from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import { z } from "zod";
|
import {z} from "zod";
|
||||||
import {invitation, OrganizationInvitation} from "@/db/schema/05_invitation";
|
import {invitation, OrganizationInvitation} from "@/db/schema/05_invitation";
|
||||||
import {member, OrganizationMember} from "@/db/schema/04_member";
|
import {member, OrganizationMember} from "@/db/schema/04_member";
|
||||||
import {User} from "@/db/schema/02_user";
|
import {User} from "@/db/schema/02_user";
|
||||||
import {timestamps} from "@/db/schema/00_common";
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
import {organizationNotificationChannel} from "@/db/schema/09_notification-channel";
|
import {NotificationChannel, organizationNotificationChannel} from "@/db/schema/09_notification-channel";
|
||||||
|
import {Agent} from "@/db/schema/08_agent";
|
||||||
|
import {AlertPolicy} from "@/db/schema/10_alert-policy";
|
||||||
|
import {Backup, Database, Restoration, RetentionPolicy} from "@/db/schema/07_database";
|
||||||
|
|
||||||
export const organization = pgTable("organization", {
|
export const organization = pgTable("organization", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
@@ -19,7 +22,7 @@ export const organization = pgTable("organization", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
export const organizationRelations = relations(organization, ({many}) => ({
|
||||||
members: many(member),
|
members: many(member),
|
||||||
invitations: many(invitation),
|
invitations: many(invitation),
|
||||||
projects: many(project),
|
projects: many(project),
|
||||||
@@ -27,7 +30,6 @@ export const organizationRelations = relations(organization, ({ many }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const organizationSchema = createSelectSchema(organization);
|
export const organizationSchema = createSelectSchema(organization);
|
||||||
export type Organization = z.infer<typeof organizationSchema>;
|
export type Organization = z.infer<typeof organizationSchema>;
|
||||||
|
|
||||||
@@ -42,4 +44,12 @@ export type MemberWithUser = OrganizationMember & {
|
|||||||
export type OrganizationWithMembersAndUsers = Organization & {
|
export type OrganizationWithMembersAndUsers = Organization & {
|
||||||
members: MemberWithUser[];
|
members: MemberWithUser[];
|
||||||
invitations: OrganizationInvitation[];
|
invitations: OrganizationInvitation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrganizationWith = Organization & {
|
||||||
|
user?: User | null;
|
||||||
|
members?: MemberWithUser[] | null;
|
||||||
|
invitations?: OrganizationInvitation[] | null;
|
||||||
|
notificationChannels?: NotificationChannel[] | null;
|
||||||
|
projects?: Project[] | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {dbmsEnum, statusEnum} from "./types";
|
|||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {timestamps} from "@/db/schema/00_common";
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
|
import {member} from "@/db/schema/04_member";
|
||||||
|
import {invitation} from "@/db/schema/05_invitation";
|
||||||
|
import {organizationNotificationChannel} from "@/db/schema/09_notification-channel";
|
||||||
|
import {organization} from "@/db/schema/03_organization";
|
||||||
|
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
|
||||||
|
|
||||||
export const database = pgTable("databases", {
|
export const database = pgTable("databases", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -20,13 +25,13 @@ export const database = pgTable("databases", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => agent.id, {onDelete: "cascade"}),
|
.references(() => agent.id, {onDelete: "cascade"}),
|
||||||
lastContact: timestamp("last_contact"),
|
lastContact: timestamp("last_contact"),
|
||||||
|
|
||||||
projectId: uuid("project_id")
|
projectId: uuid("project_id")
|
||||||
.references(() => project.id),
|
.references(() => project.id),
|
||||||
...timestamps
|
...timestamps
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const backup = pgTable(
|
export const backup = pgTable(
|
||||||
"backups",
|
"backups",
|
||||||
{
|
{
|
||||||
@@ -77,6 +82,7 @@ export const databaseRelations = relations(database, ({one, many}) => ({
|
|||||||
project: one(project, {fields: [database.projectId], references: [project.id]}),
|
project: one(project, {fields: [database.projectId], references: [project.id]}),
|
||||||
backups: many(backup),
|
backups: many(backup),
|
||||||
restorations: many(restoration),
|
restorations: many(restoration),
|
||||||
|
alertPolicies: many(alertPolicy),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const backupRelations = relations(backup, ({one, many}) => ({
|
export const backupRelations = relations(backup, ({one, many}) => ({
|
||||||
@@ -116,5 +122,6 @@ export type DatabaseWith = Database & {
|
|||||||
backups?: Backup[] | null;
|
backups?: Backup[] | null;
|
||||||
restorations?: Restoration[] | null;
|
restorations?: Restoration[] | null;
|
||||||
retentionPolicy?: RetentionPolicy | null;
|
retentionPolicy?: RetentionPolicy | null;
|
||||||
|
alertPolicies?: AlertPolicy[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {boolean, pgEnum, pgTable, uuid} from "drizzle-orm/pg-core";
|
||||||
|
import {notificationChannel} from "@/db/schema/09_notification-channel";
|
||||||
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
|
import {relations} from "drizzle-orm";
|
||||||
|
import {database, retentionPolicy} from "@/db/schema/07_database";
|
||||||
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const eventKindEnum = pgEnum('event_kind', ['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report']);
|
||||||
|
|
||||||
|
export const alertPolicy = pgTable('alert_policy', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
notificationChannelId: uuid('notification_channel_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => notificationChannel.id, {onDelete: 'restrict'}),
|
||||||
|
eventKinds: eventKindEnum("event_kind").array().notNull(),
|
||||||
|
enabled: boolean('enabled').default(true).notNull(),
|
||||||
|
databaseId: uuid('database_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => database.id, { onDelete: 'cascade' }),
|
||||||
|
...timestamps
|
||||||
|
});
|
||||||
|
|
||||||
|
export const alertPolicyRelations = relations(alertPolicy, ({one}) => ({
|
||||||
|
notificationChannel: one(notificationChannel, {
|
||||||
|
fields: [alertPolicy.notificationChannelId],
|
||||||
|
references: [notificationChannel.id],
|
||||||
|
}),
|
||||||
|
database: one(database, {
|
||||||
|
fields: [alertPolicy.databaseId],
|
||||||
|
references: [database.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const notificationChannelsToAlertPoliciesRelations = relations(notificationChannel, ({many}) => ({
|
||||||
|
alertPolicies: many(alertPolicy),
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
export const alertPolicySchema = createSelectSchema(alertPolicy);
|
||||||
|
export type AlertPolicy = z.infer<typeof alertPolicySchema>;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {pgTable, uuid, timestamp, jsonb, varchar, boolean, text, pgEnum} from 'drizzle-orm/pg-core';
|
||||||
|
import {notificationChannel, providerKindEnum} from "@/db/schema/09_notification-channel";
|
||||||
|
import {alertPolicy} from "@/db/schema/10_alert-policy";
|
||||||
|
import {organization} from "@/db/schema/03_organization";
|
||||||
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
|
|
||||||
|
export const levelEnum = pgEnum('level', ['critical', 'warning', 'info']);
|
||||||
|
|
||||||
|
|
||||||
|
export const notificationLog = pgTable('notification_log', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
|
||||||
|
channelId: uuid('channel_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => notificationChannel.id, { onDelete: 'restrict' }),
|
||||||
|
policyId: uuid('policy_id')
|
||||||
|
.references(() => alertPolicy.id, { onDelete: 'restrict' }),
|
||||||
|
organizationId: uuid('organization_id')
|
||||||
|
.references(() => organization.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
title: varchar('title', { length: 255 }).notNull(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
level: levelEnum('level').notNull(),
|
||||||
|
payload: jsonb('payload'),
|
||||||
|
|
||||||
|
success: boolean('success').notNull(),
|
||||||
|
error: text('error'),
|
||||||
|
providerResponse: jsonb('provider_response'),
|
||||||
|
sentAt: timestamp('sent_at').defaultNow().notNull(),
|
||||||
|
|
||||||
|
...timestamps
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {desc, eq, and, gte, lte} from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
notificationLog
|
||||||
|
} from "@/db/schema/11_notification-log";
|
||||||
|
import {
|
||||||
|
notificationChannel,
|
||||||
|
} from "@/db/schema/09_notification-channel";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {alertPolicy} from "@/db/schema/10_alert-policy";
|
||||||
|
|
||||||
|
|
||||||
|
export async function getNotificationHistory(filters?: {
|
||||||
|
channelId?: string;
|
||||||
|
policyId?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
level?: string;
|
||||||
|
success?: boolean;
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const where = [];
|
||||||
|
if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId));
|
||||||
|
if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId));
|
||||||
|
if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId));
|
||||||
|
if (filters?.level) where.push(eq(notificationLog.level, filters.level));
|
||||||
|
if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success));
|
||||||
|
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
|
||||||
|
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: notificationLog.id,
|
||||||
|
title: notificationLog.title,
|
||||||
|
level: notificationLog.level,
|
||||||
|
success: notificationLog.success,
|
||||||
|
error: notificationLog.error,
|
||||||
|
sentAt: notificationLog.sentAt,
|
||||||
|
channel: {
|
||||||
|
id: notificationLog.id,
|
||||||
|
name: notificationChannel.name,
|
||||||
|
provider: notificationChannel.provider,
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
id: alertPolicy.id,
|
||||||
|
eventKind: alertPolicy.eventKind,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(notificationLog)
|
||||||
|
.leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id))
|
||||||
|
.leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
|
||||||
|
.where(and(...where))
|
||||||
|
.orderBy(desc(notificationLog.sentAt))
|
||||||
|
.limit(filters?.limit || 100);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use server"
|
"use server"
|
||||||
// src/notifications/dispatch.ts
|
// src/notifications/dispatch.ts
|
||||||
import { eq } from 'drizzle-orm';
|
import {eq} from 'drizzle-orm';
|
||||||
import { dispatchViaProvider } from './providers';
|
import {dispatchViaProvider} from './providers';
|
||||||
import type { EventPayload, DispatchResult } from './types';
|
import type {EventPayload, DispatchResult} from './types';
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
|
import {notificationLog} from "@/db/schema/11_notification-log";
|
||||||
|
|
||||||
export async function dispatchNotification(
|
export async function dispatchNotification(
|
||||||
payload: EventPayload,
|
payload: EventPayload,
|
||||||
@@ -44,30 +45,47 @@ export async function dispatchNotification(
|
|||||||
// };
|
// };
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if (channelId){
|
if (channelId) {
|
||||||
const channel = await db.query.notificationChannel.findFirst({
|
const channel = await db.query.notificationChannel.findFirst({
|
||||||
where: eq(drizzleDb.schemas.notificationChannel.id, channelId),
|
where: eq(drizzleDb.schemas.notificationChannel.id, channelId),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (channel){
|
if (channel) {
|
||||||
const config = channel.config;
|
const config = channel.config;
|
||||||
|
|
||||||
const result = await dispatchViaProvider(
|
const result = await dispatchViaProvider(
|
||||||
channel.provider as any,
|
channel.provider as any,
|
||||||
config,
|
config,
|
||||||
{ ...payload, timestamp: payload.timestamp || new Date() },
|
{...payload, timestamp: payload.timestamp || new Date()},
|
||||||
channel.id
|
channel.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const [log] = await db
|
||||||
|
.insert(notificationLog)
|
||||||
|
.values({
|
||||||
|
channelId: channel.id,
|
||||||
|
// policyId: policy.id,
|
||||||
|
// organizationId: organizationId || null,
|
||||||
|
title: payload.title,
|
||||||
|
message: payload.message,
|
||||||
|
level: payload.level,
|
||||||
|
payload: payload.data || null,
|
||||||
|
success: result.success,
|
||||||
|
error: result.success ? null : result.error,
|
||||||
|
providerResponse: result.response || null,
|
||||||
|
})
|
||||||
|
.returning({id: notificationLog.id});
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
channelId,
|
channelId,
|
||||||
@@ -76,5 +94,4 @@ export async function dispatchNotification(
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,8 @@ export async function sendSmtp(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
provider: 'smtp',
|
provider: 'smtp',
|
||||||
message: `Email sent: ${info.messageId}`,
|
// message: `Email sent: ${info.messageId}`,
|
||||||
|
message: `Email sent: ${config.to}`,
|
||||||
response: info,
|
response: info,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user