From d968aa9c1663f331ca424cb9390f1d1bbc73e8c6 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Sun, 23 Nov 2025 17:13:07 +0100 Subject: [PATCH] Working on implementation of alert policies. --- .../database/[databaseId]/page.tsx | 14 +- .../notifier/notifier-add-edit-modal.tsx | 1 - .../notifier/notifier-form/notifier-form.tsx | 39 +- .../notifier-test-channel-button.tsx | 47 +- .../alert-policy/alert-policy-form.tsx | 287 +++ .../alert-policy/alert-policy-modal.tsx | 53 + .../alert-policy/alert-policy.action.ts | 184 ++ .../alert-policy/alert-policy.schema.ts | 26 + .../organization-notifiers-tab.tsx | 1 - src/db/index.ts | 6 +- src/db/migrations/0008_aberrant_scorpion.sql | 36 + src/db/migrations/meta/0008_snapshot.json | 1750 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema/03_organization.ts | 28 +- src/db/schema/07_database.ts | 9 +- src/db/schema/10_alert-policy.ts | 41 + src/db/schema/11_notification-log.ts | 32 + src/db/services/notification-log.ts | 55 + src/features/notifications/dispatch.ts | 35 +- src/features/notifications/providers/smtp.ts | 3 +- 20 files changed, 2598 insertions(+), 56 deletions(-) create mode 100644 src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx create mode 100644 src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx create mode 100644 src/components/wrappers/dashboard/database/alert-policy/alert-policy.action.ts create mode 100644 src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts create mode 100644 src/db/migrations/0008_aberrant_scorpion.sql create mode 100644 src/db/migrations/meta/0008_snapshot.json create mode 100644 src/db/schema/10_alert-policy.ts create mode 100644 src/db/schema/11_notification-log.ts create mode 100644 src/db/services/notification-log.ts diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index 2c9a8d6e..0dc5f61b 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -14,6 +14,8 @@ import {getOrganizationProjectDatabases} from "@/lib/services"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet"; 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<{ 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)), with: { project: true, - retentionPolicy: true + retentionPolicy: true, + alertPolicies: true } }); @@ -82,6 +85,9 @@ export default async function RoutePage(props: PageParams<{ notFound(); } + const organizationChannels = await getOrganizationChannels(organization.id); + console.log(organizationChannels); + const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const isMember = activeMember?.role === "member"; @@ -101,6 +107,7 @@ export default async function RoutePage(props: PageParams<{ {/**/} +
@@ -114,9 +121,10 @@ export default async function RoutePage(props: PageParams<{ {dbItem.description} )} - - diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx index 7d8a218d..2677f044 100644 --- a/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx +++ b/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx @@ -1,6 +1,5 @@ "use client" -import {useState} from "react"; import {Pencil, Plus} from "lucide-react"; import { diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx index da281eef..f57728c2 100644 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx +++ b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx @@ -131,25 +131,28 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not )} -
- {defaultValues && ( - - )} +
+
+ {defaultValues && ( + + )} +
+
+ + + Add Channel + +
- - - - Add Channel -
); diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx index 7fee0497..dec23a4d 100644 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx +++ b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx @@ -4,6 +4,8 @@ import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {useMutation} from "@tanstack/react-query"; import {dispatchNotification} from "@/features/notifications/dispatch"; import {EventPayload} from "@/features/notifications/types"; +import {Send} from "lucide-react"; +import {toast} from "sonner"; type NotifierTestChannelButtonProps = { notificationChannel: NotificationChannel; @@ -13,30 +15,51 @@ export const NotifierTestChannelButton = ({notificationChannel}: NotifierTestCha const mutation = useMutation({ 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 = { - title: 'Database Down', - message: 'Primary DB instance is unreachable', - level: 'critical', - data: { host: 'db-prod-01', error: 'connection timeout' }, + title: 'Test Channel', + message: `We are testing channel ${notificationChannel.name}`, + level: 'info', + // data: {host: 'db-prod-01', error: 'connection timeout'}, }; 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 ( ) } \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx b/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx new file mode 100644 index 00000000..577465b1 --- /dev/null +++ b/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx @@ -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 => 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 ( +
{ + await mutation.mutateAsync(values); + }} + > +
+
+ + +
+ +
+ {organizationNotificationChannels.length === 0 ? ( +
+ No alerts policy in organization +
+ ) : fields.length === 0 ? ( +
+ No alert policy, create one +
+ ) : ( + fields.map((field, index) => ( +
+
+
+ { + + + 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 ( + + + + + ); + }} + /> + + ( + + + + + + )} + /> +
+ +
+ +
+
+ + {index + 1 < fields.length && } +
+ )) + )} +
+
+ +
+ + Cancel + + + Submit + +
+
+ ); +}; diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx b/src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx new file mode 100644 index 00000000..064a40b2 --- /dev/null +++ b/src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx @@ -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 ( + + + + + + + Alert policies + + Add and manage your database alert policies + + + + setOpen(false)} + /> + + + + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy.action.ts b/src/components/wrappers/dashboard/database/alert-policy/alert-policy.action.ts new file mode 100644 index 00000000..c363b4cf --- /dev/null +++ b/src/components/wrappers/dashboard/database/alert-policy/alert-policy.action.ts @@ -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> => { + 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> => { + 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> => { + 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", + }, + }; + } + }); \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts b/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts new file mode 100644 index 00000000..230f9e53 --- /dev/null +++ b/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts @@ -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; +export type AlertPolicyType = z.infer; + + +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"}, +]; \ No newline at end of file diff --git a/src/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab.tsx b/src/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab.tsx index e1eab8fc..facf57c5 100644 --- a/src/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab.tsx +++ b/src/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab.tsx @@ -57,7 +57,6 @@ export const OrganizationNotifiersTab = ({ }: OrganizationNotifiersTabProps) => { const [isAddModalOpen, setIsAddModalOpen] = useState(false); - // const hasNotifiers = 0; const hasNotifiers = notificationChannels.length > 0; return ( diff --git a/src/db/index.ts b/src/db/index.ts index 7be0f903..2715ab6e 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -10,6 +10,8 @@ import * as agent from "./schema/08_agent"; import * as database from "./schema/07_database"; import * as notificationChannel 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"; @@ -36,7 +38,9 @@ export const schemas = { ...agent, ...database, ...notificationChannel, - ...organizationNotificationChannel + ...organizationNotificationChannel, + ...alertPolicy, + ...notificationLog }; export const db = drizzle({ diff --git a/src/db/migrations/0008_aberrant_scorpion.sql b/src/db/migrations/0008_aberrant_scorpion.sql new file mode 100644 index 00000000..fdf5a7ca --- /dev/null +++ b/src/db/migrations/0008_aberrant_scorpion.sql @@ -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; \ No newline at end of file diff --git a/src/db/migrations/meta/0008_snapshot.json b/src/db/migrations/meta/0008_snapshot.json new file mode 100644 index 00000000..12a2cd8f --- /dev/null +++ b/src/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,1750 @@ +{ + "id": "4bc1b8d8-deda-4cd1-a30d-b6166fb636d5", + "prevId": "ddb3770e-4785-4c3e-b20a-202ac01999ad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage": { + "name": "storage", + "type": "type_storage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "s3_endpoint_url": { + "name": "s3_endpoint_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_access_key_id": { + "name": "s3_access_key_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_secret_access_key": { + "name": "s3_secret_access_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_bucket_name": { + "name": "s3_bucket_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_password": { + "name": "smtp_password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_from": { + "name": "smtp_from", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_host": { + "name": "smtp_host", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_user": { + "name": "smtp_user", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_name_unique": { + "name": "settings_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backups": { + "name": "backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "backups_database_id_databases_id_fk": { + "name": "backups_database_id_databases_id_fk", + "tableFrom": "backups", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.databases": { + "name": "databases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_database_id": { + "name": "agent_database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dbms": { + "name": "dbms", + "type": "dbms_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backup_policy": { + "name": "backup_policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_waiting_for_backup": { + "name": "is_waiting_for_backup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "backup_to_restore": { + "name": "backup_to_restore", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "databases_agent_id_agents_id_fk": { + "name": "databases_agent_id_agents_id_fk", + "tableFrom": "databases", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "databases_project_id_projects_id_fk": { + "name": "databases_project_id_projects_id_fk", + "tableFrom": "databases", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restorations": { + "name": "restorations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "restorations_backup_id_backups_id_fk": { + "name": "restorations_backup_id_backups_id_fk", + "tableFrom": "restorations", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restorations_database_id_databases_id_fk": { + "name": "restorations_database_id_databases_id_fk", + "tableFrom": "restorations", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "retention_policy_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "days": { + "name": "days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "gfs_daily": { + "name": "gfs_daily", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "gfs_weekly": { + "name": "gfs_weekly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 4 + }, + "gfs_monthly": { + "name": "gfs_monthly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 12 + }, + "gfs_yearly": { + "name": "gfs_yearly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "retention_policies_database_id_databases_id_fk": { + "name": "retention_policies_database_id_databases_id_fk", + "tableFrom": "retention_policies", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "provider_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_channels": { + "name": "organization_notification_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_notification_channels_organization_id_organization_id_fk": { + "name": "organization_notification_channels_organization_id_organization_id_fk", + "tableFrom": "organization_notification_channels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_notification_channels_notification_channel_id_notification_channel_id_fk": { + "name": "organization_notification_channels_notification_channel_id_notification_channel_id_fk", + "tableFrom": "organization_notification_channels", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_notification_channels_organization_id_notification_channel_id_unique": { + "name": "organization_notification_channels_organization_id_notification_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "notification_channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_policy": { + "name": "alert_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "event_kind[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_policy_notification_channel_id_notification_channel_id_fk": { + "name": "alert_policy_notification_channel_id_notification_channel_id_fk", + "tableFrom": "alert_policy", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "alert_policy_database_id_databases_id_fk": { + "name": "alert_policy_database_id_databases_id_fk", + "tableFrom": "alert_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_log": { + "name": "notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_response": { + "name": "provider_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notification_log_channel_id_notification_channel_id_fk": { + "name": "notification_log_channel_id_notification_channel_id_fk", + "tableFrom": "notification_log", + "tableTo": "notification_channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "notification_log_policy_id_alert_policy_id_fk": { + "name": "notification_log_policy_id_alert_policy_id_fk", + "tableFrom": "notification_log", + "tableTo": "alert_policy", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "notification_log_organization_id_organization_id_fk": { + "name": "notification_log_organization_id_organization_id_fk", + "tableFrom": "notification_log", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_policy_type": { + "name": "retention_policy_type", + "schema": "public", + "values": [ + "count", + "days", + "gfs" + ] + }, + "public.provider_kind": { + "name": "provider_kind", + "schema": "public", + "values": [ + "curl", + "slack", + "smtp", + "webhook" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.dbms_status": { + "name": "dbms_status", + "schema": "public", + "values": [ + "postgresql", + "mysql" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "waiting", + "ongoing", + "failed", + "success" + ] + }, + "public.type_storage": { + "name": "type_storage", + "schema": "public", + "values": [ + "local", + "s3" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index ea2d1c86..440db620 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1762685571404, "tag": "0007_last_umar", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1763914207236, + "tag": "0008_aberrant_scorpion", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/03_organization.ts b/src/db/schema/03_organization.ts index 7e52fb7a..3012394a 100644 --- a/src/db/schema/03_organization.ts +++ b/src/db/schema/03_organization.ts @@ -1,13 +1,16 @@ -import { pgTable, text, uuid } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; -import { project } from "./06_project"; -import { createSelectSchema } from "drizzle-zod"; -import { z } from "zod"; +import {pgTable, text, uuid} from "drizzle-orm/pg-core"; +import {relations} from "drizzle-orm"; +import {Project, project} from "./06_project"; +import {createSelectSchema} from "drizzle-zod"; +import {z} from "zod"; import {invitation, OrganizationInvitation} from "@/db/schema/05_invitation"; import {member, OrganizationMember} from "@/db/schema/04_member"; import {User} from "@/db/schema/02_user"; 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", { 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), invitations: many(invitation), projects: many(project), @@ -27,7 +30,6 @@ export const organizationRelations = relations(organization, ({ many }) => ({ })); - export const organizationSchema = createSelectSchema(organization); export type Organization = z.infer; @@ -42,4 +44,12 @@ export type MemberWithUser = OrganizationMember & { export type OrganizationWithMembersAndUsers = Organization & { members: MemberWithUser[]; invitations: OrganizationInvitation[]; -}; \ No newline at end of file +}; + +export type OrganizationWith = Organization & { + user?: User | null; + members?: MemberWithUser[] | null; + invitations?: OrganizationInvitation[] | null; + notificationChannels?: NotificationChannel[] | null; + projects?: Project[] | null; +}; diff --git a/src/db/schema/07_database.ts b/src/db/schema/07_database.ts index c23addbe..e4f7cde9 100644 --- a/src/db/schema/07_database.ts +++ b/src/db/schema/07_database.ts @@ -6,6 +6,11 @@ import {dbmsEnum, statusEnum} from "./types"; import {createSelectSchema} from "drizzle-zod"; import {z} from "zod"; 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", { id: uuid("id").primaryKey().defaultRandom(), @@ -20,13 +25,13 @@ export const database = pgTable("databases", { .notNull() .references(() => agent.id, {onDelete: "cascade"}), lastContact: timestamp("last_contact"), - projectId: uuid("project_id") .references(() => project.id), ...timestamps }); + export const backup = pgTable( "backups", { @@ -77,6 +82,7 @@ export const databaseRelations = relations(database, ({one, many}) => ({ project: one(project, {fields: [database.projectId], references: [project.id]}), backups: many(backup), restorations: many(restoration), + alertPolicies: many(alertPolicy), })); export const backupRelations = relations(backup, ({one, many}) => ({ @@ -116,5 +122,6 @@ export type DatabaseWith = Database & { backups?: Backup[] | null; restorations?: Restoration[] | null; retentionPolicy?: RetentionPolicy | null; + alertPolicies?: AlertPolicy[] | null; }; diff --git a/src/db/schema/10_alert-policy.ts b/src/db/schema/10_alert-policy.ts new file mode 100644 index 00000000..bd11abcf --- /dev/null +++ b/src/db/schema/10_alert-policy.ts @@ -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; diff --git a/src/db/schema/11_notification-log.ts b/src/db/schema/11_notification-log.ts new file mode 100644 index 00000000..685c4860 --- /dev/null +++ b/src/db/schema/11_notification-log.ts @@ -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 +}); \ No newline at end of file diff --git a/src/db/services/notification-log.ts b/src/db/services/notification-log.ts new file mode 100644 index 00000000..f831431a --- /dev/null +++ b/src/db/services/notification-log.ts @@ -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); +} \ No newline at end of file diff --git a/src/features/notifications/dispatch.ts b/src/features/notifications/dispatch.ts index c2a2a773..10e67401 100644 --- a/src/features/notifications/dispatch.ts +++ b/src/features/notifications/dispatch.ts @@ -1,10 +1,11 @@ "use server" // src/notifications/dispatch.ts -import { eq } from 'drizzle-orm'; -import { dispatchViaProvider } from './providers'; -import type { EventPayload, DispatchResult } from './types'; +import {eq} from 'drizzle-orm'; +import {dispatchViaProvider} from './providers'; +import type {EventPayload, DispatchResult} from './types'; import * as drizzleDb from "@/db"; import {db} from "@/db"; +import {notificationLog} from "@/db/schema/11_notification-log"; export async function dispatchNotification( payload: EventPayload, @@ -44,30 +45,47 @@ export async function dispatchNotification( // }; // } - if (channelId){ + if (channelId) { const channel = await db.query.notificationChannel.findFirst({ where: eq(drizzleDb.schemas.notificationChannel.id, channelId), }) - if (channel){ + if (channel) { const config = channel.config; const result = await dispatchViaProvider( channel.provider as any, config, - { ...payload, timestamp: payload.timestamp || new Date() }, + {...payload, timestamp: payload.timestamp || new Date()}, 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 { ...result, channelId: channel.id, }; } - - } + return { success: false, channelId, @@ -76,5 +94,4 @@ export async function dispatchNotification( }; - } \ No newline at end of file diff --git a/src/features/notifications/providers/smtp.ts b/src/features/notifications/providers/smtp.ts index 0f9051dc..483fb232 100644 --- a/src/features/notifications/providers/smtp.ts +++ b/src/features/notifications/providers/smtp.ts @@ -52,7 +52,8 @@ export async function sendSmtp( return { success: true, provider: 'smtp', - message: `Email sent: ${info.messageId}`, + // message: `Email sent: ${info.messageId}`, + message: `Email sent: ${config.to}`, response: info, }; } \ No newline at end of file