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
|
DeleteOrganizationButton
|
||||||
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
|
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
|
||||||
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
|
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 {Metadata} from "next";
|
||||||
|
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||||
|
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
@@ -25,6 +24,11 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||||
|
|
||||||
|
console.log("notificationChannels", notificationChannels);
|
||||||
|
|
||||||
|
|
||||||
const isMember = activeMember?.role === "member";
|
const isMember = activeMember?.role === "member";
|
||||||
const isOwner = activeMember?.role === "owner";
|
const isOwner = activeMember?.role === "owner";
|
||||||
|
|
||||||
@@ -44,7 +48,10 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
</PageActions>
|
</PageActions>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<SettingsOrganizationMembersTable organization={organization}/>
|
<OrganizationTabs
|
||||||
|
organization={organization}
|
||||||
|
notificationChannels={notificationChannels}
|
||||||
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ interface CardsWithPaginationProps<T> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
data: any[];
|
data: any[];
|
||||||
organizationSlug?: string;
|
organizationSlug?: string;
|
||||||
cardItem: ComponentType<{ data: T; organizationSlug?: string; extendedProps?: any }>;
|
cardItem: ComponentType<{ data: T } & Record<string, any>>;
|
||||||
cardsPerPage?: number;
|
cardsPerPage?: number;
|
||||||
numberOfColumns?: number;
|
numberOfColumns?: number;
|
||||||
maxVisiblePages?: number;
|
maxVisiblePages?: number;
|
||||||
extendedProps?: any;
|
// extendedProps?: any;
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
|
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;
|
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("flex flex-col h-full justify-between", className)}>
|
||||||
<div className={cn(`grid h-max auto-rows-min gap-4 md:grid-cols-${numberOfColumns}`)}>
|
<div className={cn(`grid h-max auto-rows-min gap-4 md:grid-cols-${numberOfColumns}`)}>
|
||||||
{currentCards.map((card, key) => (
|
{currentCards.map((card, key) => (
|
||||||
<CardItem key={key} data={card} organizationSlug={organizationSlug} extendedProps={props.extendedProps} />
|
<CardItem key={key} data={card} organizationSlug={organizationSlug} {...rest} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<PaginationNavigation
|
<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 {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";
|
import {organizationMemberColumns} from "@/components/wrappers/dashboard/settings/columns-organization-members";
|
||||||
|
|
||||||
|
|
||||||
interface SettingsOrganizationMembersTableProps {
|
interface SettingsOrganizationMembersTableProps {
|
||||||
organization: OrganizationWithMembers
|
organization: OrganizationWithMembers
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -8,6 +8,8 @@ import * as member from "./schema/05_invitation";
|
|||||||
import * as project from "./schema/06_project";
|
import * as project from "./schema/06_project";
|
||||||
import * as agent from "./schema/08_agent";
|
import * as agent from "./schema/08_agent";
|
||||||
import * as database from "./schema/07_database";
|
import * as database from "./schema/07_database";
|
||||||
|
import * as notificationChannel from "./schema/09_notification-channel";
|
||||||
|
import * as organizationNotificationChannel from "./schema/09_notification-channel";
|
||||||
|
|
||||||
|
|
||||||
import {Pool} from "pg";
|
import {Pool} from "pg";
|
||||||
@@ -32,7 +34,9 @@ export const schemas = {
|
|||||||
...member,
|
...member,
|
||||||
...project,
|
...project,
|
||||||
...agent,
|
...agent,
|
||||||
...database
|
...database,
|
||||||
|
...notificationChannel,
|
||||||
|
...organizationNotificationChannel
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle({
|
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,
|
"when": 1762028639833,
|
||||||
"tag": "0006_moaning_pete_wisdom",
|
"tag": "0006_moaning_pete_wisdom",
|
||||||
"breakpoints": true
|
"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 { relations } from "drizzle-orm";
|
||||||
import { project } from "./06_project";
|
import { project } from "./06_project";
|
||||||
import { createSelectSchema } from "drizzle-zod";
|
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 {member, OrganizationMember} from "@/db/schema/04_member";
|
||||||
import {User} from "@/db/schema/02_user";
|
import {User} from "@/db/schema/02_user";
|
||||||
import {timestamps} from "@/db/schema/00_common";
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
|
import {organizationNotificationChannel} from "@/db/schema/09_notification-channel";
|
||||||
|
|
||||||
export const organization = pgTable("organization", {
|
export const organization = pgTable("organization", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
@@ -22,6 +23,7 @@ export const organizationRelations = relations(organization, ({ many }) => ({
|
|||||||
members: many(member),
|
members: many(member),
|
||||||
invitations: many(invitation),
|
invitations: many(invitation),
|
||||||
projects: many(project),
|
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