Working on notifier system.

This commit is contained in:
charlesgauthereau
2025-11-09 16:47:58 +01:00
parent 411e09f452
commit 7dfd9f02e8
27 changed files with 2755 additions and 81 deletions
@@ -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>
) )
+1
View File
@@ -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>
)
}
@@ -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}
/>
);
};
@@ -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},
]
@@ -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: ""},
},
};
}
});
@@ -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>
);
};
@@ -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>
)
}
@@ -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>
)}
/>
</>
)
}
@@ -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>
)}
/>
</>
)
}
@@ -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
View File
@@ -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({
+20
View File
@@ -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
+7
View File
@@ -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
} }
] ]
} }
+3 -1
View File
@@ -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),
})); }));
+50
View File
@@ -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>;
+28
View File
@@ -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[];
}
+80
View File
@@ -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,
};
}
+19
View File
@@ -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>;
}