Working on implementation of alert policies.

This commit is contained in:
charlesgauthereau
2025-11-23 17:13:07 +01:00
parent eb471c7cdb
commit d968aa9c16
20 changed files with 2598 additions and 56 deletions
@@ -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>
); );
@@ -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"},
];
@@ -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
View File
@@ -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
+7
View File
@@ -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
} }
] ]
} }
+18 -8
View File
@@ -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>;
@@ -43,3 +45,11 @@ 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;
};
+8 -1
View File
@@ -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;
}; };
+41
View File
@@ -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>;
+32
View File
@@ -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
});
+55
View File
@@ -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);
}
+26 -9
View File
@@ -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(
}; };
} }
+2 -1
View File
@@ -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,
}; };
} }