mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Working on notifications channels.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// import * as React from "react";
|
||||
// import EmailLayout from "./email-layout";
|
||||
// import {Text, Section, Button} from "@react-email/components";
|
||||
// import type {EventPayload} from "@/features/notifications/types";
|
||||
//
|
||||
// export interface EmailNotificationProps {
|
||||
// payload: EventPayload
|
||||
// }
|
||||
//
|
||||
// export const EmailNotification = ({payload}: EmailNotificationProps) => {
|
||||
// return (
|
||||
// <EmailLayout preview={payload.title}>
|
||||
// <Text className="text-base text-green-800 font-bold ">${payload.title}</Text>
|
||||
// <Text className="text-base text-green-800 font-bold "><strong>Level:</strong> ${payload.level}</Text>
|
||||
//
|
||||
//
|
||||
// <Text className="text-base font-light text-green-800 ">
|
||||
// ${payload.message.replace(/\n/g, '<br>')}
|
||||
// </Text>{" "}
|
||||
//
|
||||
//
|
||||
// <Section className="mt-[32px] mb-[32px] text-center">
|
||||
//
|
||||
// </Section>
|
||||
//
|
||||
// {payload.data && (
|
||||
// <Section className="mt-[16px] text-sm text-gray-700">
|
||||
// <pre>{JSON.stringify(payload.data, null, 2)}</pre>
|
||||
// </Section>
|
||||
// )}
|
||||
//
|
||||
// <Text className="text-base font-light text-green-800 ">Regards,<br/>Portabase</Text>
|
||||
// </EmailLayout>
|
||||
// );
|
||||
// };
|
||||
//
|
||||
// export default EmailNotification;
|
||||
|
||||
|
||||
// const html = `
|
||||
// <h2>${payload.title}</h2>
|
||||
// <p><strong>Level:</strong> ${payload.level}</p>
|
||||
// <p>${payload.message.replace(/\n/g, '<br>')}</p>
|
||||
// ${payload.data ? `<pre>${JSON.stringify(payload.data, null, 2)}</pre>` : ''}
|
||||
// `;
|
||||
//
|
||||
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Text, Section, Button} from "@react-email/components";
|
||||
import type {EventPayload} from "@/features/notifications/types";
|
||||
|
||||
export interface EmailNotificationProps {
|
||||
payload: EventPayload;
|
||||
}
|
||||
|
||||
export const EmailNotification = ({payload}: EmailNotificationProps) => {
|
||||
return (
|
||||
<EmailLayout preview={payload.message}>
|
||||
<Section>
|
||||
<Text className="text-xl font-bold ">
|
||||
{payload.title}
|
||||
</Text>
|
||||
<Text className=" font-semibold ">
|
||||
Level: {payload.level}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{payload.message && (
|
||||
<Section className="mb-2">
|
||||
<Text
|
||||
className="text-base font-light"
|
||||
dangerouslySetInnerHTML={{__html: payload.message.replace(/\n/g, "<br>")}}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{payload.data && (
|
||||
<Section className="mb-6 p-4 bg-gray-100 rounded text-sm text-gray-700">
|
||||
<Text>
|
||||
<pre>{JSON.stringify(payload.data, null, 2)}</pre>
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section className="mt-8">
|
||||
<Text className="text-base font-light ">
|
||||
Regards,
|
||||
<br/>
|
||||
Portabase
|
||||
</Text>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailNotification;
|
||||
@@ -9,8 +9,8 @@ export interface EmailResetPasswordProps {
|
||||
export const EmailResetPassword = ({url}: EmailResetPasswordProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Email for password reset of your Portabase account">
|
||||
<Text className="text-base text-green-800 font-bold ">Hello !</Text>
|
||||
<Text className="text-base font-light text-green-800 ">You are receiving this email because we
|
||||
<Text className="text-base font-bold ">Hello !</Text>
|
||||
<Text className="text-base font-light ">You are receiving this email because we
|
||||
received a password reset request for your account.</Text>{" "}
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
@@ -20,9 +20,9 @@ export const EmailResetPassword = ({url}: EmailResetPasswordProps) => {
|
||||
Reset Password
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-base font-light text-green-800 ">If you did not request a password reset, no
|
||||
<Text className="text-base font-light ">If you did not request a password reset, no
|
||||
further action is required.</Text>
|
||||
<Text className="text-base font-light text-green-800 ">Regards,<br/>Portabase</Text>
|
||||
<Text className="text-base font-light ">Regards,<br/>Portabase</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,11 +5,11 @@ import EmailLayout from "./email-layout";
|
||||
export const EmailSettingsTest = () => {
|
||||
return (
|
||||
<EmailLayout preview="Email Setup">
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Hi, your email settings are setup !</Text>
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Best regard,</Text>{" "}
|
||||
<Text className="text-base font-light leading-8 text-green-800 ">Portabase</Text>
|
||||
<Text className="text-base font-light leading-8 ">Hi, your email settings are setup !</Text>
|
||||
<Text className="text-base font-light leading-8 ">Best regard,</Text>{" "}
|
||||
<Text className="text-base font-light leading-8 ">Portabase</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
)
|
||||
};
|
||||
|
||||
export default EmailSettingsTest;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "notification_channel" ALTER COLUMN "provider" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."provider_kind";--> statement-breakpoint
|
||||
CREATE TYPE "public"."provider_kind" AS ENUM('slack', 'smtp');--> statement-breakpoint
|
||||
ALTER TABLE "notification_channel" ALTER COLUMN "provider" SET DATA TYPE "public"."provider_kind" USING "provider"::"public"."provider_kind";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
||||
"when": 1764364481615,
|
||||
"tag": "0009_lucky_edwin_jarvis",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1764497732581,
|
||||
"tag": "0010_past_trauma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
|
||||
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['curl', 'slack', 'smtp', 'webhook']);
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp']);
|
||||
|
||||
export const notificationChannel = pgTable('notification_channel', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
@@ -1,100 +1,114 @@
|
||||
"use server"
|
||||
// src/notifications/dispatch.ts
|
||||
import {eq} from 'drizzle-orm';
|
||||
import {dispatchViaProvider} from './providers';
|
||||
import type {EventPayload, DispatchResult} from './types';
|
||||
"use server";
|
||||
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";
|
||||
import { db } from "@/db";
|
||||
import { notificationLog } from "@/db/schema/11_notification-log";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {Json} from "drizzle-zod";
|
||||
|
||||
export async function dispatchNotification(
|
||||
payload: EventPayload,
|
||||
policyId?: string,
|
||||
channelId?: string,
|
||||
organizationId?: string,
|
||||
organizationId?: string
|
||||
): Promise<DispatchResult> {
|
||||
try {
|
||||
let channel: NotificationChannel | null = null;
|
||||
|
||||
if (policyId) {
|
||||
const policy = await db.query.alertPolicy.findFirst({
|
||||
where: eq(drizzleDb.schemas.alertPolicy.id, policyId),
|
||||
with: {
|
||||
notificationChannel: true
|
||||
},
|
||||
});
|
||||
|
||||
// // 1. Get policy + channel
|
||||
// const policy = await db
|
||||
// .select({
|
||||
// policy: alertPolicies,
|
||||
// channel: notificationChannels,
|
||||
// })
|
||||
// .from(alertPolicies)
|
||||
// .innerJoin(
|
||||
// notificationChannels,
|
||||
// eq(alertPolicies.notificationChannelId, notificationChannels.id)
|
||||
// )
|
||||
// .where(eq(alertPolicies.id, policyId))
|
||||
// .then((rows) => rows[0]);
|
||||
//
|
||||
// if (!policy) {
|
||||
// return {
|
||||
// success: false,
|
||||
// channelId: '',
|
||||
// provider: 'unknown' as any,
|
||||
// error: 'Policy or channel not found',
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// if (!policy.policy.enabled || !policy.channel.enabled) {
|
||||
// return {
|
||||
// success: false,
|
||||
// channelId: policy.channel.id,
|
||||
// provider: policy.channel.provider as any,
|
||||
// error: 'Policy or channel is disabled',
|
||||
// };
|
||||
// }
|
||||
if (!policy || !policy.notificationChannel) {
|
||||
return {
|
||||
success: false,
|
||||
channelId: "",
|
||||
provider: null,
|
||||
error: "Policy or associated channel not found",
|
||||
};
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
const channel = await db.query.notificationChannel.findFirst({
|
||||
where: eq(drizzleDb.schemas.notificationChannel.id, channelId),
|
||||
})
|
||||
if (!policy.enabled || !policy.notificationChannel.enabled) {
|
||||
return {
|
||||
success: false,
|
||||
channelId: policy.notificationChannel.id,
|
||||
provider: policy.notificationChannel.provider as any,
|
||||
error: "Policy or channel is disabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
const config = channel.config;
|
||||
|
||||
const result = await dispatchViaProvider(
|
||||
channel.provider as any,
|
||||
config,
|
||||
{...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,
|
||||
channel = {
|
||||
...policy.notificationChannel,
|
||||
config : policy.notificationChannel.config as Json,
|
||||
};
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
const fetchedChannel = await db.query.notificationChannel.findFirst({
|
||||
where: eq(drizzleDb.schemas.notificationChannel.id, channelId),
|
||||
});
|
||||
|
||||
if (!fetchedChannel) {
|
||||
return {
|
||||
success: false,
|
||||
channelId: channelId,
|
||||
provider: null,
|
||||
error: "Channel not found",
|
||||
};
|
||||
}
|
||||
|
||||
channel = {
|
||||
...fetchedChannel,
|
||||
config : fetchedChannel.config as Json,
|
||||
};
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
return {
|
||||
success: false,
|
||||
channelId: channelId || "",
|
||||
provider: null,
|
||||
error: "No valid channel to dispatch notification",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await dispatchViaProvider(
|
||||
channel.provider,
|
||||
channel.config,
|
||||
{ ...payload, timestamp: payload.timestamp || new Date() },
|
||||
channel.id
|
||||
);
|
||||
|
||||
const [log] = await db
|
||||
.insert(notificationLog)
|
||||
.values({
|
||||
channelId: channel.id,
|
||||
policyId: policyId || null,
|
||||
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 };
|
||||
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
channelId: channelId || "",
|
||||
provider: null,
|
||||
error: err?.message || "Unexpected error during dispatch",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: false,
|
||||
channelId,
|
||||
provider: "smtp",
|
||||
error: 'Unknown error',
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import {EventPayload} from "@/features/notifications/types";
|
||||
import {dispatchNotification} from "@/features/notifications/dispatch";
|
||||
|
||||
|
||||
type EventKind = ("error_backup" | "error_restore" | "success_restore" | "success_backup" | "weekly_report")
|
||||
|
||||
export async function sendNotificationsBackupRestore(database: DatabaseWith, event: EventKind) {
|
||||
|
||||
if (database.alertPolicies && database.alertPolicies.length > 0) {
|
||||
for (const alertPolicy of database.alertPolicies) {
|
||||
if (alertPolicy.enabled) {
|
||||
|
||||
const eventKinds = alertPolicy.eventKinds
|
||||
|
||||
if (eventKinds.includes(event)) {
|
||||
|
||||
const date = new Date()
|
||||
|
||||
let level: "info" | "critical" = "info";
|
||||
let message = "";
|
||||
let error: string | null = null;
|
||||
|
||||
switch (event) {
|
||||
case "error_backup":
|
||||
case "error_restore":
|
||||
level = "critical";
|
||||
message = `An error occurred during ${event.includes("backup") ? "backup" : "restore"} on ${date.toISOString()}.`;
|
||||
error = "Check database connection or agent";
|
||||
break;
|
||||
case "success_backup":
|
||||
case "success_restore":
|
||||
level = "info";
|
||||
message = `${event.includes("backup") ? "Backup" : "Restore"} completed successfully at ${date.toISOString()}.`;
|
||||
break;
|
||||
case "weekly_report":
|
||||
level = "info";
|
||||
message = `Weekly report generated at ${date.toISOString()}.`;
|
||||
break;
|
||||
}
|
||||
|
||||
const titleMap: Record<EventKind, string> = {
|
||||
error_backup: `Backup Notification`,
|
||||
error_restore: `Restore Notification`,
|
||||
success_backup: `Backup Notification`,
|
||||
success_restore: `Restore Notification`,
|
||||
weekly_report: `Weekly Report Notification`,
|
||||
};
|
||||
|
||||
|
||||
const payload: EventPayload = {
|
||||
title: titleMap[event],
|
||||
message,
|
||||
level: level,
|
||||
data: {
|
||||
host: database.name,
|
||||
id: database.id,
|
||||
agentDatabaseId: database.agentDatabaseId,
|
||||
error,
|
||||
},
|
||||
};
|
||||
return await dispatchNotification(payload, alertPolicy.id, undefined, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,11 +6,11 @@ export async function sendSlack(
|
||||
): Promise<DispatchResult> {
|
||||
const {slackWebhook: webhookUrl} = config;
|
||||
|
||||
const text = `*${payload.title}*\n${payload.message}`;
|
||||
const text = `*[${payload.level}] ${payload.title}*\n${payload.message}`;
|
||||
const blocks = [
|
||||
{
|
||||
type: 'section',
|
||||
text: {type: 'mrkdwn', text: `*${payload.title}*`},
|
||||
text: {type: 'mrkdwn', text: `*[${payload.level.toUpperCase()}] ${payload.title}*`},
|
||||
},
|
||||
{type: 'section', text: {type: 'mrkdwn', text: payload.message}},
|
||||
payload.data
|
||||
|
||||
@@ -1,7 +1,70 @@
|
||||
"use server"
|
||||
import type {EventPayload, DispatchResult} from '../types';
|
||||
import nodemailer from 'nodemailer';
|
||||
import TestEmailSettings from "@/components/emails/email-settings-test"
|
||||
// "use server"
|
||||
// import type {EventPayload, DispatchResult} from '../types';
|
||||
// import nodemailer from 'nodemailer';
|
||||
// import TestEmailSettings from "@/components/emails/email-settings-test"
|
||||
//
|
||||
// export async function sendSmtp(
|
||||
// config: {
|
||||
// host: string;
|
||||
// port: number;
|
||||
// secure: boolean;
|
||||
// user: string;
|
||||
// password: string;
|
||||
// from: string;
|
||||
// to: string | string[];
|
||||
// },
|
||||
// payload: EventPayload
|
||||
// ): Promise<DispatchResult> {
|
||||
// console.log(config)
|
||||
// const transporter = nodemailer.createTransport({
|
||||
// pool: true,
|
||||
// host: config.host,
|
||||
// port: config.port,
|
||||
// // secure: config.secure,
|
||||
// secure: true,
|
||||
// auth: {user: config.user, pass: config.password},
|
||||
// });
|
||||
//
|
||||
//
|
||||
//
|
||||
// const result = await transporter.verify();
|
||||
// console.log(result);
|
||||
//
|
||||
// const html = `
|
||||
// <h2>${payload.title}</h2>
|
||||
// <p><strong>Level:</strong> ${payload.level}</p>
|
||||
// <p>${payload.message.replace(/\n/g, '<br>')}</p>
|
||||
// ${payload.data ? `<pre>${JSON.stringify(payload.data, null, 2)}</pre>` : ''}
|
||||
// `;
|
||||
//
|
||||
// const info = await transporter.sendMail({
|
||||
// from: config.from,
|
||||
// to: Array.isArray(config.to) ? config.to.join(', ') : config.to,
|
||||
// // to: config.from,
|
||||
// subject: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
// html,
|
||||
// // subject: "Portabase",
|
||||
// // html: await render(TestEmailSettings(), {}),
|
||||
// });
|
||||
//
|
||||
// console.log(info);
|
||||
//
|
||||
// return {
|
||||
// success: true,
|
||||
// provider: 'smtp',
|
||||
// // message: `Email sent: ${info.messageId}`,
|
||||
// message: `Email sent: ${config.to}`,
|
||||
// response: info,
|
||||
// };
|
||||
// }
|
||||
|
||||
|
||||
"use server";
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
import type { EventPayload, DispatchResult } from '../types';
|
||||
import EmailNotification from "@/components/emails/email-notification";
|
||||
import EmailResetPassword from "@/components/emails/email-reset-password";
|
||||
|
||||
export async function sendSmtp(
|
||||
config: {
|
||||
@@ -15,45 +78,31 @@ export async function sendSmtp(
|
||||
},
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
console.log(config)
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
pool: true,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
// secure: config.secure,
|
||||
secure: true,
|
||||
auth: {user: config.user, pass: config.password},
|
||||
secure: config.secure,
|
||||
auth: { user: config.user, pass: config.password },
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
|
||||
const result = await transporter.verify();
|
||||
console.log(result);
|
||||
|
||||
const html = `
|
||||
<h2>${payload.title}</h2>
|
||||
<p><strong>Level:</strong> ${payload.level}</p>
|
||||
<p>${payload.message.replace(/\n/g, '<br>')}</p>
|
||||
${payload.data ? `<pre>${JSON.stringify(payload.data, null, 2)}</pre>` : ''}
|
||||
`;
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: config.from,
|
||||
to: Array.isArray(config.to) ? config.to.join(', ') : config.to,
|
||||
// to: config.from,
|
||||
to: Array.isArray(config.to) ? config.to.join(", ") : config.to,
|
||||
subject: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
html,
|
||||
// subject: "Portabase",
|
||||
// html: await render(TestEmailSettings(), {}),
|
||||
html: await render(EmailNotification({
|
||||
payload: payload
|
||||
})),
|
||||
});
|
||||
|
||||
console.log(info);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'smtp',
|
||||
// message: `Email sent: ${info.messageId}`,
|
||||
provider: "smtp",
|
||||
message: `Email sent: ${config.to}`,
|
||||
response: info,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export type ProviderKind = 'slack' | 'smtp';
|
||||
export interface DispatchResult {
|
||||
success: boolean;
|
||||
channelId?: string;
|
||||
provider: ProviderKind;
|
||||
provider: ProviderKind | null;
|
||||
message?: string;
|
||||
error?: string;
|
||||
response?: any;
|
||||
|
||||
Reference in New Issue
Block a user