mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Working on notifier system.
This commit is contained in:
@@ -7,10 +7,9 @@ import {
|
||||
DeleteOrganizationButton
|
||||
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
|
||||
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
|
||||
import {
|
||||
SettingsOrganizationMembersTable
|
||||
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
|
||||
import {Metadata} from "next";
|
||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
@@ -25,6 +24,11 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
|
||||
console.log("notificationChannels", notificationChannels);
|
||||
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
const isOwner = activeMember?.role === "owner";
|
||||
|
||||
@@ -44,7 +48,10 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<SettingsOrganizationMembersTable organization={organization}/>
|
||||
<OrganizationTabs
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -8,32 +8,32 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
@@ -50,7 +50,7 @@ function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
@@ -58,6 +58,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"max-h-[90vh] overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -98,7 +99,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
@@ -111,7 +112,7 @@ function DialogTitle({
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
|
||||
@@ -9,15 +9,16 @@ interface CardsWithPaginationProps<T> {
|
||||
className?: string;
|
||||
data: any[];
|
||||
organizationSlug?: string;
|
||||
cardItem: ComponentType<{ data: T; organizationSlug?: string; extendedProps?: any }>;
|
||||
cardItem: ComponentType<{ data: T } & Record<string, any>>;
|
||||
cardsPerPage?: number;
|
||||
numberOfColumns?: number;
|
||||
maxVisiblePages?: number;
|
||||
extendedProps?: any;
|
||||
// extendedProps?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
|
||||
const { className, organizationSlug, data, cardItem, cardsPerPage = 5, numberOfColumns = 1, maxVisiblePages = 3 } = props;
|
||||
const { className, organizationSlug, data, cardItem, cardsPerPage = 5, numberOfColumns = 1, maxVisiblePages = 3, ...rest } = props;
|
||||
|
||||
const CardItem = cardItem;
|
||||
|
||||
@@ -44,7 +45,7 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
|
||||
<div className={cn("flex flex-col h-full justify-between", className)}>
|
||||
<div className={cn(`grid h-max auto-rows-min gap-4 md:grid-cols-${numberOfColumns}`)}>
|
||||
{currentCards.map((card, key) => (
|
||||
<CardItem key={key} data={card} organizationSlug={organizationSlug} extendedProps={props.extendedProps} />
|
||||
<CardItem key={key} data={card} organizationSlug={organizationSlug} {...rest} />
|
||||
))}
|
||||
</div>
|
||||
<PaginationNavigation
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react";
|
||||
import {Pencil, Plus} from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotifierForm} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
|
||||
type OrganizationNotifierAddModalProps = {
|
||||
notificationChannel?: NotificationChannel
|
||||
organization?: OrganizationWithMembers;
|
||||
}
|
||||
|
||||
|
||||
export const NotifierAddEditModal = ({organization, notificationChannel}: OrganizationNotifierAddModalProps) => {
|
||||
|
||||
const isCreate = !Boolean(notificationChannel);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{isCreate ?
|
||||
<Button>
|
||||
<Plus/> Add notification channel
|
||||
</Button>
|
||||
:
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4"/>
|
||||
</Button>
|
||||
}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle> {isCreate ? "Add" : "Edit"} Notification Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your notification channel preferences and event triggers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<NotifierForm
|
||||
defaultValues={notificationChannel}
|
||||
organization={organization}
|
||||
onSuccessAction={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {
|
||||
removeNotificationChannelAction,
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export type DeleteNotifierButtonProps = {
|
||||
notificationChannelId: string;
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export const DeleteNotifierButton = ({notificationChannelId, organizationId}: DeleteNotifierButtonProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await removeNotificationChannelAction({organizationId, notificationChannelId})
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
title="Delete Notifier"
|
||||
description="Are you sure you want to remove this notifier? This action cannot be undone."
|
||||
button={{
|
||||
main: {
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {
|
||||
updateNotificationChannelAction
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export type EditNotifierButtonProps = {
|
||||
notificationChannel: NotificationChannel;
|
||||
};
|
||||
|
||||
export const EditNotifierButton = ({notificationChannel}: EditNotifierButtonProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (value: boolean) => {
|
||||
|
||||
const payload = {
|
||||
data: {
|
||||
name: notificationChannel.name,
|
||||
provider: notificationChannel.provider,
|
||||
config: notificationChannel.config as Record<string, any>,
|
||||
enabled: value
|
||||
},
|
||||
notificationChannelId: notificationChannel.id
|
||||
};
|
||||
|
||||
const result = await updateNotificationChannelAction(payload);
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch checked={notificationChannel.enabled} onCheckedChange={async () => {
|
||||
await mutation.mutateAsync(!notificationChannel.enabled)
|
||||
}}
|
||||
/>
|
||||
<NotifierAddEditModal
|
||||
notificationChannel={notificationChannel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {Mail, MessageSquare, Pencil, Trash2, Webhook} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {
|
||||
DeleteNotifierButton
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier";
|
||||
import {EditNotifierButton} from "@/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
|
||||
export type NotifierCardProps = {
|
||||
data: NotificationChannel;
|
||||
organization?: Organization;
|
||||
};
|
||||
|
||||
export const NotifierCard = (props: NotifierCardProps) => {
|
||||
const {data, organization} = props;
|
||||
|
||||
return (
|
||||
<div className="block transition-all duration-200 rounded-xl">
|
||||
<Card className="flex flex-row justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
|
||||
{getIcon(data.provider)}
|
||||
</div>
|
||||
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start w-full">
|
||||
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
|
||||
<h3 className="font-medium text-foreground">{data.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{data.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EditNotifierButton notificationChannel={data}/>
|
||||
<DeleteNotifierButton
|
||||
organizationId={organization?.id}
|
||||
notificationChannelId={data.id}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const Icon = notificationTypes.find((t) => t.value === type)?.icon
|
||||
return Icon ? <Icon className="h-4 w-4"/> : null
|
||||
}
|
||||
|
||||
|
||||
const notificationTypes = [
|
||||
{value: "smtp", label: "Email", icon: Mail},
|
||||
{value: "slack", label: "Slack", icon: MessageSquare},
|
||||
{value: "webhook", label: "Webhook", icon: Webhook},
|
||||
]
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
"use server";
|
||||
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {db} from "@/db";
|
||||
import {
|
||||
NotificationChannelFormSchema
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
|
||||
export const addNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
data: NotificationChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {organizationId, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.insert(drizzleDb.schemas.notificationChannel)
|
||||
.values({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (organizationId) {
|
||||
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
|
||||
organizationId,
|
||||
notificationChannelId: channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully created.",
|
||||
messageParams: {notificationChannelId: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to create notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const removeNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
notificationChannelId: z.string(),
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {organizationId, notificationChannelId} = parsedInput;
|
||||
|
||||
try {
|
||||
if (organizationId) {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.organizationNotificationChannel)
|
||||
.where(
|
||||
and(
|
||||
eq(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationId),
|
||||
eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, notificationChannelId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [deletedChannel] = await db
|
||||
.delete(drizzleDb.schemas.notificationChannel)
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId))
|
||||
.returning();
|
||||
|
||||
if (!deletedChannel) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Notification channel not found.",
|
||||
status: 404,
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...deletedChannel,
|
||||
config: deletedChannel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully removed.",
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to remove notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: notificationChannelId},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const updateNotificationChannelAction = userAction.schema(
|
||||
z.object({
|
||||
notificationChannelId: z.string(),
|
||||
data: NotificationChannelFormSchema
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<NotificationChannel>> => {
|
||||
const {notificationChannelId, data} = parsedInput;
|
||||
|
||||
try {
|
||||
const [channel] = await db
|
||||
.update(drizzleDb.schemas.notificationChannel)
|
||||
.set(withUpdatedAt({
|
||||
provider: data.provider,
|
||||
name: data.name,
|
||||
config: data.config,
|
||||
enabled: data.enabled ?? true,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {
|
||||
...channel,
|
||||
config: channel.config as JSON
|
||||
},
|
||||
actionSuccess: {
|
||||
message: "Notification channel has been successfully updated.",
|
||||
messageParams: {notificationChannelId: channel.id},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update notification channel.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {notificationChannelId: ""},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import {z} from "zod";
|
||||
|
||||
|
||||
export const NotificationChannelFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(5, "Name must be at least 5 characters long")
|
||||
.max(40, "Name must be at most 40 characters long"),
|
||||
|
||||
provider: z.enum(["curl", "slack", "smtp", "webhook"], {
|
||||
required_error: "Provider is required",
|
||||
}),
|
||||
|
||||
config: z.record(z.string()).optional(),
|
||||
|
||||
|
||||
enabled: z.boolean().default(true),
|
||||
|
||||
});
|
||||
|
||||
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {
|
||||
NotificationChannelFormSchema, NotificationChannelFormType
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {
|
||||
addNotificationChannelAction, updateNotificationChannelAction
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action";
|
||||
import {toast} from "sonner";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {
|
||||
NotifierSmtpForm
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form";
|
||||
import {
|
||||
NotifierSlackForm
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {
|
||||
NotifierTestChannelButton
|
||||
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button";
|
||||
|
||||
type NotifierFormProps = {
|
||||
onSuccessAction?: () => void;
|
||||
organization?: OrganizationWithMembers;
|
||||
defaultValues?: NotificationChannel
|
||||
};
|
||||
|
||||
export const NotifierForm = ({onSuccessAction, organization, defaultValues}: NotifierFormProps) => {
|
||||
|
||||
const isCreate = !Boolean(defaultValues);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: NotificationChannelFormSchema,
|
||||
// @ts-ignore
|
||||
defaultValues: {...defaultValues},
|
||||
|
||||
});
|
||||
|
||||
|
||||
const mutationCreateOrganisation = useMutation({
|
||||
mutationFn: async (values: NotificationChannelFormType) => {
|
||||
|
||||
const payload = {
|
||||
data: values,
|
||||
...(organization && {organizationId: organization.id}),
|
||||
...((defaultValues && {notificationChannelId: defaultValues.id}))
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
// onSuccessAction?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
// onSuccessAction?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const provider = form.watch("provider");
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationCreateOrganisation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g., Primary email, Team Slack" value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
form.setValue("config", {});
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value || ""}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="smtp">SMTP (Email)</SelectItem>
|
||||
<SelectItem value="slack">Slack</SelectItem>
|
||||
<SelectItem value="curl">Curl</SelectItem>
|
||||
<SelectItem value="webhook">Webhook</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{provider === "smtp" && (
|
||||
<NotifierSmtpForm form={form}/>
|
||||
)}
|
||||
|
||||
{provider === "slack" && (
|
||||
<NotifierSlackForm form={form}/>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex gap-4 justify-end">
|
||||
{defaultValues && (
|
||||
<NotifierTestChannelButton notificationChannel={defaultValues} />
|
||||
)}
|
||||
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSuccessAction?.();
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>
|
||||
Add Channel
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
import {Button} from "@/components/ui/button";
|
||||
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";
|
||||
|
||||
type NotifierTestChannelButtonProps = {
|
||||
notificationChannel: NotificationChannel;
|
||||
}
|
||||
|
||||
export const NotifierTestChannelButton = ({notificationChannel}: NotifierTestChannelButtonProps) => {
|
||||
|
||||
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 result = await dispatchNotification(payload, undefined, notificationChannel.id);
|
||||
console.log(result);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync()
|
||||
}}
|
||||
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => {
|
||||
return(
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.slackWebhook"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://hooks.slack.com/services/..."/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
|
||||
|
||||
type NotifierSmtpFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
|
||||
|
||||
export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-4"/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.host"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="smtp.gmail.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.port"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="456" type="number" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.user"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="contact@exemple.com" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.from"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={`"Portabase" <exemple@portabase.io>`} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.to"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>To Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"contact@portabase.io, contact2@portabase.io"} {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {Bell} from "lucide-react";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {NotifierCard} from "@/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card";
|
||||
import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal";
|
||||
|
||||
|
||||
export type OrganizationNotifiersTabProps = {
|
||||
organization: OrganizationWithMembers;
|
||||
notificationChannels: NotificationChannel[];
|
||||
};
|
||||
|
||||
export const OrganizationNotifiersTab = ({organization ,notificationChannels}: OrganizationNotifiersTabProps) => {
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 h-full py-4">
|
||||
<div className="flex flex-col gap-y-4 ">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-card border border-border">
|
||||
<Bell className="h-5 w-5 text-foreground"/>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-balance">Notification Settings</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Configure how and when you receive alerts about your services and infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NotifierAddEditModal organization={organization}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" h-full">
|
||||
<CardsWithPagination data={notificationChannels} cardItem={NotifierCard} cardsPerPage={8} numberOfColumns={2} organization={organization}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {
|
||||
SettingsOrganizationMembersTable
|
||||
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
|
||||
import {
|
||||
OrganizationNotifiersTab
|
||||
} from "@/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
|
||||
export type OrganizationTabsProps = {
|
||||
organization: OrganizationWithMembers;
|
||||
notificationChannels: NotificationChannel[];
|
||||
};
|
||||
|
||||
export const OrganizationTabs = ({organization, notificationChannels}: OrganizationTabsProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users");
|
||||
|
||||
useEffect(() => {
|
||||
const newTab = searchParams.get("tab") ?? "users";
|
||||
setTab(newTab);
|
||||
}, [searchParams]);
|
||||
|
||||
const handleChangeTab = (value: string) => {
|
||||
router.push(`?tab=${value}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-full" value="users">
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value="notifications">
|
||||
Notifiers
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-full" value="users">
|
||||
<SettingsOrganizationMembersTable organization={organization}/>
|
||||
</TabsContent>
|
||||
<TabsContent className="h-full" value="notifications">
|
||||
<OrganizationNotifiersTab
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {MemberWithUser, Organization, OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {organizationMemberColumns} from "@/components/wrappers/dashboard/settings/columns-organization-members";
|
||||
|
||||
|
||||
interface SettingsOrganizationMembersTableProps {
|
||||
organization: OrganizationWithMembers
|
||||
}
|
||||
|
||||
+5
-1
@@ -8,6 +8,8 @@ import * as member from "./schema/05_invitation";
|
||||
import * as project from "./schema/06_project";
|
||||
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 {Pool} from "pg";
|
||||
@@ -32,7 +34,9 @@ export const schemas = {
|
||||
...member,
|
||||
...project,
|
||||
...agent,
|
||||
...database
|
||||
...database,
|
||||
...notificationChannel,
|
||||
...organizationNotificationChannel
|
||||
};
|
||||
|
||||
export const db = drizzle({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TYPE "public"."provider_kind" AS ENUM('curl', 'slack', 'smtp', 'webhook');--> statement-breakpoint
|
||||
CREATE TABLE "notification_channel" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"provider" "provider_kind" NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"config" jsonb NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_notification_channels" (
|
||||
"organization_id" uuid NOT NULL,
|
||||
"notification_channel_id" uuid NOT NULL,
|
||||
CONSTRAINT "organization_notification_channels_organization_id_notification_channel_id_unique" UNIQUE("organization_id","notification_channel_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "organization_notification_channels" ADD CONSTRAINT "organization_notification_channels_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_notification_channels" ADD CONSTRAINT "organization_notification_channels_notification_channel_id_notification_channel_id_fk" FOREIGN KEY ("notification_channel_id") REFERENCES "public"."notification_channel"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1762028639833,
|
||||
"tag": "0006_moaning_pete_wisdom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1762685571404,
|
||||
"tag": "0007_last_umar",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { pgTable, text, uuid } from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { project } from "./06_project";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
|
||||
export const organization = pgTable("organization", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
@@ -22,6 +23,7 @@ export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
projects: many(project),
|
||||
notificationChannels: many(organizationNotificationChannel),
|
||||
}));
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {boolean, jsonb, pgEnum, pgTable, primaryKey, unique, uuid, varchar} from "drizzle-orm/pg-core";
|
||||
import {timestamps} from "@/db/schema/00_common";
|
||||
import {organization} from "@/db/schema/03_organization";
|
||||
import {relations} from "drizzle-orm";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
|
||||
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['curl', 'slack', 'smtp', 'webhook']);
|
||||
|
||||
export const notificationChannel = pgTable('notification_channel', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
provider: providerKindEnum('provider').notNull(),
|
||||
name: varchar('name', {length: 255}).notNull(),
|
||||
config: jsonb('config').notNull(),
|
||||
enabled: boolean('enabled').default(false).notNull(),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
export const organizationNotificationChannel = pgTable(
|
||||
"organization_notification_channels",
|
||||
{
|
||||
organizationId: uuid('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, {onDelete: 'cascade'}),
|
||||
notificationChannelId: uuid('notification_channel_id')
|
||||
.notNull()
|
||||
.references(() => notificationChannel.id, {onDelete: 'cascade'}),
|
||||
},
|
||||
(t) => [unique().on(t.organizationId, t.notificationChannelId)]
|
||||
);
|
||||
|
||||
|
||||
export const notificationChannelRelations = relations(notificationChannel, ({many}) => ({
|
||||
organizations: many(organizationNotificationChannel),
|
||||
}));
|
||||
|
||||
export const organizationNotificationChannelRelations = relations(organizationNotificationChannel, ({one}) => ({
|
||||
organization: one(organization, {
|
||||
fields: [organizationNotificationChannel.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
notificationChannel: one(notificationChannel, {
|
||||
fields: [organizationNotificationChannel.notificationChannelId],
|
||||
references: [notificationChannel.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const notificationChannelSchema = createSelectSchema(notificationChannel);
|
||||
export type NotificationChannel = z.infer<typeof notificationChannelSchema>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import {desc, eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import {
|
||||
NotificationChannel,
|
||||
notificationChannel,
|
||||
organizationNotificationChannel
|
||||
} from "@/db/schema/09_notification-channel";
|
||||
|
||||
export async function getOrganizationChannels(organizationId: string) {
|
||||
return await db
|
||||
.select({
|
||||
id: notificationChannel.id,
|
||||
name: notificationChannel.name,
|
||||
provider: notificationChannel.provider,
|
||||
config: notificationChannel.config,
|
||||
enabled: notificationChannel.enabled,
|
||||
updatedAt: notificationChannel.updatedAt,
|
||||
createdAt: notificationChannel.createdAt,
|
||||
deletedAt: notificationChannel.deletedAt,
|
||||
})
|
||||
.from(organizationNotificationChannel)
|
||||
.innerJoin(
|
||||
notificationChannel,
|
||||
eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id)
|
||||
)
|
||||
.orderBy(desc(notificationChannel.createdAt))
|
||||
.where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use server"
|
||||
// src/notifications/dispatch.ts
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { dispatchViaProvider } from './providers';
|
||||
import type { EventPayload, DispatchResult } from './types';
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
|
||||
export async function dispatchNotification(
|
||||
payload: EventPayload,
|
||||
policyId?: string,
|
||||
channelId?: string,
|
||||
): Promise<DispatchResult> {
|
||||
|
||||
// // 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 (channelId){
|
||||
const channel = await db.query.notificationChannel.findFirst({
|
||||
where: eq(drizzleDb.schemas.notificationChannel.id, channelId),
|
||||
})
|
||||
|
||||
if (channel){
|
||||
const config = channel.config;
|
||||
|
||||
const result = await dispatchViaProvider(
|
||||
channel.provider as any,
|
||||
config,
|
||||
{ ...payload, timestamp: payload.timestamp || new Date() },
|
||||
channel.id
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
channelId: channel.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
channelId,
|
||||
provider: "smtp",
|
||||
error: 'Unknown error',
|
||||
};
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use server"
|
||||
import type { ProviderKind, EventPayload, DispatchResult } from '../types';
|
||||
// import { sendSlack } from './slack';
|
||||
import { sendSmtp } from './smtp';
|
||||
|
||||
const handlers: Record<
|
||||
ProviderKind,
|
||||
(config: any, payload: EventPayload) => Promise<DispatchResult>
|
||||
> = {
|
||||
// slack: sendSlack,
|
||||
smtp: sendSmtp,
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
kind: ProviderKind,
|
||||
config: any,
|
||||
payload: EventPayload,
|
||||
channelId: string
|
||||
): Promise<DispatchResult> {
|
||||
const handler = handlers[kind];
|
||||
if (!handler) {
|
||||
return {
|
||||
success: false,
|
||||
channelId,
|
||||
provider: kind,
|
||||
error: `Unsupported provider: ${kind}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await handler(config, payload);
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
channelId,
|
||||
provider: kind,
|
||||
error: err.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use server"
|
||||
import type {EventPayload, DispatchResult} from '../types';
|
||||
import nodemailer from 'nodemailer';
|
||||
import {render} from "@react-email/render";
|
||||
import TestEmailSettings from "../../../../emails/TestEmailSettings";
|
||||
|
||||
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}`,
|
||||
response: info,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// export type ProviderKind = 'slack' | 'smtp';
|
||||
export type ProviderKind = 'smtp';
|
||||
|
||||
export interface DispatchResult {
|
||||
success: boolean;
|
||||
channelId?: string;
|
||||
provider: ProviderKind;
|
||||
message?: string;
|
||||
error?: string;
|
||||
response?: any;
|
||||
}
|
||||
|
||||
export interface EventPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
level: 'critical' | 'warning' | 'info';
|
||||
timestamp?: Date;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
Reference in New Issue
Block a user