diff --git a/app/(customer)/dashboard/(organization)/settings/page.tsx b/app/(customer)/dashboard/(organization)/settings/page.tsx index fad42c3a..4afde9ff 100644 --- a/app/(customer)/dashboard/(organization)/settings/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/page.tsx @@ -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 }>) { - + ) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 7d7a9d31..473b84ca 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -7,117 +7,118 @@ import { XIcon } from "lucide-react" import { cn } from "@/lib/utils" function Dialog({ - ...props -}: React.ComponentProps) { + ...props + }: React.ComponentProps) { return } function DialogTrigger({ - ...props -}: React.ComponentProps) { + ...props + }: React.ComponentProps) { return } function DialogPortal({ - ...props -}: React.ComponentProps) { + ...props + }: React.ComponentProps) { return } function DialogClose({ - ...props -}: React.ComponentProps) { + ...props + }: React.ComponentProps) { return } function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { + className, + ...props + }: React.ComponentProps) { return ( - + ) } function DialogContent({ - className, - children, - ...props -}: React.ComponentProps) { + className, + children, + ...props + }: React.ComponentProps) { return ( - - - - {children} - - - Close - - - + + + + {children} + + + Close + + + ) } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
) } function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
) } function DialogTitle({ - className, - ...props -}: React.ComponentProps) { + className, + ...props + }: React.ComponentProps) { return ( - + ) } function DialogDescription({ - className, - ...props -}: React.ComponentProps) { + className, + ...props + }: React.ComponentProps) { return ( - + ) } diff --git a/src/components/wrappers/common/cards-with-pagination.tsx b/src/components/wrappers/common/cards-with-pagination.tsx index 1b239ea6..70979bf4 100644 --- a/src/components/wrappers/common/cards-with-pagination.tsx +++ b/src/components/wrappers/common/cards-with-pagination.tsx @@ -9,15 +9,16 @@ interface CardsWithPaginationProps { className?: string; data: any[]; organizationSlug?: string; - cardItem: ComponentType<{ data: T; organizationSlug?: string; extendedProps?: any }>; + cardItem: ComponentType<{ data: T } & Record>; cardsPerPage?: number; numberOfColumns?: number; maxVisiblePages?: number; - extendedProps?: any; + // extendedProps?: any; + [key: string]: any; } export function CardsWithPagination(props: CardsWithPaginationProps) { - 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(props: CardsWithPaginationProps) {
{currentCards.map((card, key) => ( - + ))}
{ + + const isCreate = !Boolean(notificationChannel); + + const [open, setOpen] = useState(false); + + return ( + + + {isCreate ? + + : + + } + + + + {isCreate ? "Add" : "Edit"} Notification Channel + + Configure your notification channel preferences and event triggers. + + + setOpen(false)} + /> + + + ) +} diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier.tsx new file mode 100644 index 00000000..8bd51b2d --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-card/button-delete-notifier.tsx @@ -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 ( + , + }, + confirm: { + className: "w-full", + text: "Delete", + icon: , + variant: "destructive", + onClick: () => { + mutation.mutate(); + }, + }, + cancel: { + className: "w-full", + text: "Cancel", + icon: , + variant: "outline", + }, + }} + isPending={mutation.isPending} + /> + ); +}; diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier.tsx new file mode 100644 index 00000000..84c9b394 --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier.tsx @@ -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, + 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 ( + <> + { + await mutation.mutateAsync(!notificationChannel.enabled) + }} + /> + + + ); +}; diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card.tsx new file mode 100644 index 00000000..00143709 --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-card/notifier-card.tsx @@ -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 ( +
+ +
+
+ {getIcon(data.provider)} +
+
+
+ +
+
+

{data.name}

+ + {data.provider} + +
+
+ +
+ + +
+ +
+ ); +}; + + +const getIcon = (type: string) => { + const Icon = notificationTypes.find((t) => t.value === type)?.icon + return Icon ? : null +} + + +const notificationTypes = [ + {value: "smtp", label: "Email", icon: Mail}, + {value: "slack", label: "Slack", icon: MessageSquare}, + {value: "webhook", label: "Webhook", icon: Webhook}, +] \ No newline at end of file diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action.ts b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action.ts new file mode 100644 index 00000000..ec5e634f --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action.ts @@ -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> => { + 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> => { + 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> => { + 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: ""}, + }, + }; + } +}); diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts new file mode 100644 index 00000000..5c731f83 --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts @@ -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; diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx new file mode 100644 index 00000000..052f5e8c --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.tsx @@ -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 ( +
{ + await mutationCreateOrganisation.mutateAsync(values); + }} + > + ( + + Channel Name + + + + + + )} + /> + + ( + + Provider + + + + + + )} + /> + + {provider === "smtp" && ( + + )} + + {provider === "slack" && ( + + )} + + +
+ {defaultValues && ( + + )} + + + + + Add Channel + +
+ + ); +}; + + + + diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx new file mode 100644 index 00000000..7fee0497 --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form.tsx new file mode 100644 index 00000000..377b3476 --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form.tsx @@ -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 +} + +export const NotifierSlackForm = ({form}: NotifierSmtpFormProps) => { + return( + <> + ( + + Slack Webhook URL + + + + + + )} + /> + + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form.tsx b/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form.tsx new file mode 100644 index 00000000..7bdb7c89 --- /dev/null +++ b/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form.tsx @@ -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 +} + + +export const NotifierSmtpForm = ({form}: NotifierSmtpFormProps) => { + return ( + <> + + ( + + SMTP Host + + + + + + )} + /> + ( + + SMTP Port + + + + + + )} + /> + ( + + Username + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + ( + + From Email + + `} {...field} + value={field.value ?? ""}/> + + + + )} + /> + + ( + + To Email + + + + + + )} + /> + + + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab.tsx b/src/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab.tsx new file mode 100644 index 00000000..cf0c70b8 --- /dev/null +++ b/src/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-notifiers-tab.tsx @@ -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 ( +
+
+
+
+
+ +
+

Notification Settings

+
+

+ Configure how and when you receive alerts about your services and infrastructure. +

+
+
+ +
+
+
+ +
+
+ ); +}; diff --git a/src/components/wrappers/dashboard/organization/tabs/organization-tabs.tsx b/src/components/wrappers/dashboard/organization/tabs/organization-tabs.tsx new file mode 100644 index 00000000..93cb53bb --- /dev/null +++ b/src/components/wrappers/dashboard/organization/tabs/organization-tabs.tsx @@ -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(() => searchParams.get("tab") ?? "users"); + + useEffect(() => { + const newTab = searchParams.get("tab") ?? "users"; + setTab(newTab); + }, [searchParams]); + + const handleChangeTab = (value: string) => { + router.push(`?tab=${value}`); + }; + + return ( + + + + Users + + + Notifiers + + + + + + + + + + ); +}; diff --git a/src/components/wrappers/dashboard/settings/settings-organization-members-table.tsx b/src/components/wrappers/dashboard/settings/settings-organization-members-table.tsx index b2a75c97..1214b8e9 100644 --- a/src/components/wrappers/dashboard/settings/settings-organization-members-table.tsx +++ b/src/components/wrappers/dashboard/settings/settings-organization-members-table.tsx @@ -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 } diff --git a/src/db/index.ts b/src/db/index.ts index 1ebdb3dc..7be0f903 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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({ diff --git a/src/db/migrations/0007_last_umar.sql b/src/db/migrations/0007_last_umar.sql new file mode 100644 index 00000000..d0fbff48 --- /dev/null +++ b/src/db/migrations/0007_last_umar.sql @@ -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; \ No newline at end of file diff --git a/src/db/migrations/meta/0007_snapshot.json b/src/db/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..762d2826 --- /dev/null +++ b/src/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,1491 @@ +{ + "id": "ddb3770e-4785-4c3e-b20a-202ac01999ad", + "prevId": "8a29a2f2-5ed1-4b3f-99af-80d57573e919", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage": { + "name": "storage", + "type": "type_storage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "s3_endpoint_url": { + "name": "s3_endpoint_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_access_key_id": { + "name": "s3_access_key_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_secret_access_key": { + "name": "s3_secret_access_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_bucket_name": { + "name": "s3_bucket_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_password": { + "name": "smtp_password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_from": { + "name": "smtp_from", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_host": { + "name": "smtp_host", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_user": { + "name": "smtp_user", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_name_unique": { + "name": "settings_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backups": { + "name": "backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "backups_database_id_databases_id_fk": { + "name": "backups_database_id_databases_id_fk", + "tableFrom": "backups", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.databases": { + "name": "databases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_database_id": { + "name": "agent_database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dbms": { + "name": "dbms", + "type": "dbms_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backup_policy": { + "name": "backup_policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_waiting_for_backup": { + "name": "is_waiting_for_backup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "backup_to_restore": { + "name": "backup_to_restore", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "databases_agent_id_agents_id_fk": { + "name": "databases_agent_id_agents_id_fk", + "tableFrom": "databases", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "databases_project_id_projects_id_fk": { + "name": "databases_project_id_projects_id_fk", + "tableFrom": "databases", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restorations": { + "name": "restorations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "restorations_backup_id_backups_id_fk": { + "name": "restorations_backup_id_backups_id_fk", + "tableFrom": "restorations", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restorations_database_id_databases_id_fk": { + "name": "restorations_database_id_databases_id_fk", + "tableFrom": "restorations", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "retention_policy_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "days": { + "name": "days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "gfs_daily": { + "name": "gfs_daily", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "gfs_weekly": { + "name": "gfs_weekly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 4 + }, + "gfs_monthly": { + "name": "gfs_monthly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 12 + }, + "gfs_yearly": { + "name": "gfs_yearly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "retention_policies_database_id_databases_id_fk": { + "name": "retention_policies_database_id_databases_id_fk", + "tableFrom": "retention_policies", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "provider_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_channels": { + "name": "organization_notification_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_notification_channels_organization_id_organization_id_fk": { + "name": "organization_notification_channels_organization_id_organization_id_fk", + "tableFrom": "organization_notification_channels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_notification_channels_notification_channel_id_notification_channel_id_fk": { + "name": "organization_notification_channels_notification_channel_id_notification_channel_id_fk", + "tableFrom": "organization_notification_channels", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_notification_channels_organization_id_notification_channel_id_unique": { + "name": "organization_notification_channels_organization_id_notification_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "notification_channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_policy_type": { + "name": "retention_policy_type", + "schema": "public", + "values": [ + "count", + "days", + "gfs" + ] + }, + "public.provider_kind": { + "name": "provider_kind", + "schema": "public", + "values": [ + "curl", + "slack", + "smtp", + "webhook" + ] + }, + "public.dbms_status": { + "name": "dbms_status", + "schema": "public", + "values": [ + "postgresql", + "mysql" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "waiting", + "ongoing", + "failed", + "success" + ] + }, + "public.type_storage": { + "name": "type_storage", + "schema": "public", + "values": [ + "local", + "s3" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 8fc0c22f..ea2d1c86 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/db/schema/03_organization.ts b/src/db/schema/03_organization.ts index 2197cef4..7e52fb7a 100644 --- a/src/db/schema/03_organization.ts +++ b/src/db/schema/03_organization.ts @@ -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), })); diff --git a/src/db/schema/09_notification-channel.ts b/src/db/schema/09_notification-channel.ts new file mode 100644 index 00000000..6bb130a5 --- /dev/null +++ b/src/db/schema/09_notification-channel.ts @@ -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; \ No newline at end of file diff --git a/src/db/services/notification-channel.ts b/src/db/services/notification-channel.ts new file mode 100644 index 00000000..58dd0760 --- /dev/null +++ b/src/db/services/notification-channel.ts @@ -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[]; +} diff --git a/src/features/notifications/dispatch.ts b/src/features/notifications/dispatch.ts new file mode 100644 index 00000000..c2a2a773 --- /dev/null +++ b/src/features/notifications/dispatch.ts @@ -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 { + + // // 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', + }; + + + +} \ No newline at end of file diff --git a/src/features/notifications/providers/index.ts b/src/features/notifications/providers/index.ts new file mode 100644 index 00000000..d5a39eb4 --- /dev/null +++ b/src/features/notifications/providers/index.ts @@ -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 +> = { + // slack: sendSlack, + smtp: sendSmtp, +}; + +export async function dispatchViaProvider( + kind: ProviderKind, + config: any, + payload: EventPayload, + channelId: string +): Promise { + 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', + }; + } +} \ No newline at end of file diff --git a/src/features/notifications/providers/smtp.ts b/src/features/notifications/providers/smtp.ts new file mode 100644 index 00000000..c9547d55 --- /dev/null +++ b/src/features/notifications/providers/smtp.ts @@ -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 { + 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 = ` +

${payload.title}

+

Level: ${payload.level}

+

${payload.message.replace(/\n/g, '
')}

+ ${payload.data ? `
${JSON.stringify(payload.data, null, 2)}
` : ''} + `; + + 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, + }; +} \ No newline at end of file diff --git a/src/features/notifications/types.ts b/src/features/notifications/types.ts new file mode 100644 index 00000000..162a366d --- /dev/null +++ b/src/features/notifications/types.ts @@ -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; +} \ No newline at end of file