Working on notification system.

This commit is contained in:
charlesgauthereau
2025-12-06 23:14:59 +01:00
parent 9458b12d69
commit fde7688485
64 changed files with 792 additions and 551 deletions
@@ -44,7 +44,7 @@ export function BreadCrumbsWrapper() {
)
}
const FORBIDDEN_LINKS = ["organization", "dashboard", "database"];
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "settings", "notifications"];
export function BreadCrumbs({}: BreadCrumbsProps) {
@@ -13,7 +13,6 @@ interface CardsWithPaginationProps<T> {
cardsPerPage?: number;
numberOfColumns?: number;
maxVisiblePages?: number;
// extendedProps?: any;
[key: string]: any;
}
@@ -1,67 +0,0 @@
"use client";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/settings-email-tab";
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/settings-storage-tab";
import {User, UserWithAccounts} from "@/db/schema/02_user";
import {Setting} from "@/db/schema/01_setting";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/admin-user-table";
import {
AdminOrganizationsTable
} from "@/components/wrappers/dashboard/admin/tabs/admin-organizations-tab/admin-organizations-table";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type AdminTabsProps = {
users: UserWithAccounts[];
settings: Setting;
organizations: OrganizationWithMembers[];
};
export const AdminTabs = ({users, settings, organizations}: AdminTabsProps) => {
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="organizations">
Organizations
</TabsTrigger>
<TabsTrigger className="w-full" value="email">
Email
</TabsTrigger>
<TabsTrigger className="w-full" value="storage">
Storage
</TabsTrigger>
</TabsList>
<TabsContent className="h-full" value="users">
<AdminUsersTable users={users}/>
</TabsContent>
<TabsContent className="h-full" value="organizations">
<AdminOrganizationsTable organizations={organizations}/>
</TabsContent>
<TabsContent value="email">
<SettingsEmailTab settings={settings}/>
</TabsContent>
<TabsContent value="storage">
<SettingsStorageTab settings={settings}/>
</TabsContent>
</Tabs>
);
};
@@ -0,0 +1,45 @@
"use client"
import {NotificationChannel, NotificationChannelWith} 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 {useState} from "react";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type NotificationChannelsSectionProps = {
notificationChannels: NotificationChannelWith[]
organizations: OrganizationWithMembers[]
}
export const NotificationChannelsSection = ({
organizations,
notificationChannels
}: NotificationChannelsSectionProps) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const hasNotifiers = notificationChannels.length > 0;
return (
<div className="h-full">
{hasNotifiers ? (
<div className="h-full">
<CardsWithPagination
data={notificationChannels}
cardItem={NotifierCard}
cardsPerPage={8}
numberOfColumns={2}
adminView={true}
organizations={organizations}
/>
</div>
) : (
<EmptyStatePlaceholder
text="No notification channels configured yet"
onClick={() => setIsAddModalOpen(true)}
className="h-full"
/>
)}
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {
NotificationChannelsOrganizationSchema,
NotificationChannelsOrganizationType
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {
updateNotificationChannelsOrganizationAction
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action";
import {toast} from "sonner";
type NotifierChannelOrganisationFormProps = {
organizations?: OrganizationWithMembers[];
defaultValues?: NotificationChannelWith
};
export const NotifierChannelOrganisationForm = ({
organizations,
defaultValues,
}: NotifierChannelOrganisationFormProps) => {
const router = useRouter();
console.log("defaultValues", defaultValues);
console.log("organizations", organizations);
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
const form = useZodForm({
schema: NotificationChannelsOrganizationSchema,
// @ts-ignore
defaultValues: {
organizations: defaultOrganizationIds
},
});
const formatOrganizationsList = (organizations: OrganizationWithMembers[]) => {
return organizations
.map((organization) => ({
value: organization.id,
label: `${organization.name}`,
}));
};
const mutationUpdateNotificationChannelOrganizations = useMutation({
mutationFn: async (values: NotificationChannelsOrganizationType) => {
const payload = {
data: values.organizations,
notificationChannelId: defaultValues?.id ?? ""
};
const result = await updateNotificationChannelsOrganizationAction(payload)
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
}
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationUpdateNotificationChannelOrganizations.mutateAsync(values);
}}
>
<FormField
control={form.control}
name={`organizations`}
render={({field}) => (
<FormItem>
<FormControl>
<MultiSelect
options={formatOrganizationsList(organizations ?? [])}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select organization(s)"
variant="inverted"
animation={0}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end">
<div className="flex gap-2 justify-end">
<ButtonWithLoading isPending={mutationUpdateNotificationChannelOrganizations.isPending}>
Save
</ButtonWithLoading>
</div>
</div>
</Form>
);
};
@@ -0,0 +1,79 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
export const updateNotificationChannelsOrganizationAction = userAction
.schema(
z.object({
data: z.array(z.string()),
notificationChannelId: z.string(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const notificationChannel = await db.query.notificationChannel.findFirst({
where: eq(drizzleDb.schemas.notificationChannel.id, parsedInput.notificationChannelId),
with: {
organizations: true,
}
}) as NotificationChannelWith;
if (!notificationChannel) {
return {
success: false,
actionError: {
message: "Notification channel not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = notificationChannel.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationNotificationChannel).values({
organizationId: organizationToAdd,
notificationChannelId: parsedInput.notificationChannelId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationNotificationChannel).where(and(inArray(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId, parsedInput.notificationChannelId))).execute();
}
return {
success: true,
value: null,
actionSuccess: {
message: "Notification channel organizations has been successfully updated.",
messageParams: {notificationChannelId: parsedInput.notificationChannelId},
},
};
} catch (error) {
console.error("Error updating notification channel:", error);
return {
success: false,
actionError: {
message: "Failed to update notification channel.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the notification channel"},
},
};
}
});
@@ -0,0 +1,7 @@
import {z} from "zod";
export const NotificationChannelsOrganizationSchema = z.object({
organizations: z.array(z.string())
});
export type NotificationChannelsOrganizationType = z.infer<typeof NotificationChannelsOrganizationSchema>;
@@ -0,0 +1,13 @@
import {Mail, MessageSquare, Webhook} from "lucide-react";
export const getNotificationChannelIcon = (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,105 @@
"use client";
import {ColumnDef} from "@tanstack/react-table";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {humanReadableDate} from "@/utils/date-formatting";
import {CheckCircle2, XCircle} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {NotificationLogModal} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-log-modal";
export function notificationLogsColumns(): ColumnDef<NotificationLogWithRelations>[] {
return [
{
accessorKey: "success",
header: "Status",
cell: ({row}) => {
const status = row.original.success ? "delivered" : "failed";
return(
<Badge variant="outline" className={`gap-1.5 ${getStatusColor(status)}`}>
{getStatusIcon(row.original.success)}
<span className="capitalize">{status}</span>
</Badge>
)
}
},
{
accessorKey: "channel",
header: "Channel",
cell: ({row}) => {
const channel = row.original.channel
return (
<div className="flex items-center gap-2">
<div
className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary border border-border">
{getNotificationChannelIcon(channel?.provider ?? "")}
</div>
{channel?.name}
</div>
)
}
},
{
accessorKey: "sentAt",
header: "Sent At",
cell: ({row}) => {
const sentAt = humanReadableDate(row.original.sentAt)
return (
<div>
{sentAt}
</div>
)
}
},
{
accessorKey: "policy",
header: "Event Kinds",
cell: ({row}) => {
const eventKinds = row.original.policy?.eventKinds ?? [];
return (
<div>
{eventKinds.map((eventKind, idx) => (
<div key={idx} className="flex items-center gap-2">
<Badge>{eventKind}</Badge>
</div>
))}
</div>
);
}
},
{
accessorKey: "details",
header: "Details",
cell: ({row}) => {
return (
<div>
<NotificationLogModal notificationLog={row.original} />
</div>
);
}
}
];
}
const getStatusIcon = (status: boolean) => {
switch (status) {
case true:
return <CheckCircle2 className="h-4 w-4"/>
case false:
return <XCircle className="h-4 w-4"/>
}
}
const getStatusColor = (status: string) => {
switch (status) {
case "delivered":
return "bg-green-100 dark:bg-green-100/10"
case "failed":
return "bg-red-100 dark:bg-red-100/10"
case "pending":
return "bg-orange-100 dark:bg-orange-100/10"
}
}
@@ -0,0 +1,62 @@
"use client"
import {Eye} from "lucide-react";
import {useState} from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
type NotificationLogModalProps = {
notificationLog: NotificationLogWithRelations;
}
export const NotificationLogModal = ({notificationLog}: NotificationLogModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)} className="relative">
<Eye/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Notification details</DialogTitle>
<DialogDescription>
Details of the notification sent
</DialogDescription>
<Separator className="mt-3 mb-3"/>
<div className="space-y-2 mb-4">
<div>
<span className="font-semibold">Title:</span>{" "}
<span>{notificationLog.content.title}</span>
</div>
<div>
<span className="font-semibold">Message:</span>{" "}
<span>{notificationLog.content.message}</span>
</div>
</div>
{notificationLog.payload && (
<>
<Separator className="mb-3" />
<div className="text-sm font-mono bg-gray-100 dark:bg-gray-800 dark:text-gray-200 p-4 rounded-md overflow-auto">
{JSON.stringify(notificationLog.payload, null, 2)}
</div>
</>
)}
</DialogHeader>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,25 @@
"use client"
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {useRouter} from "next/navigation";
import {
notificationLogsColumns,
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {NotificationLogWithRelations} from "@/db/services/notification-log";
type NotificationsLogsListProps = {
notificationLogs: NotificationLogWithRelations[]
}
export const NotificationLogsList = (props: NotificationsLogsListProps) => {
const router = useRouter();
return (
<DataTable
enableSelect={false}
columns={notificationLogsColumns()}
data={props.notificationLogs}
enablePagination
/>
)
}
@@ -1,7 +1,7 @@
"use client"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
export type AdminOrganizationsTableProps = {
organizations: OrganizationWithMembers[];
@@ -12,7 +12,6 @@ import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with
import {UserWithAccounts} from "@/db/schema/02_user";
import {authClient, useSession} from "@/lib/auth/auth-client";
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
import {Organization} from "@/db/schema/03_organization";
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
@@ -6,7 +6,7 @@ import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { OrganizationSchema } from "@/components/wrappers/dashboard/admin/organization/organization.schema";
import { OrganizationSchema } from "@/components/wrappers/dashboard/admin/organizations/organization/organization.schema";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/auth-client";
@@ -1,8 +1,8 @@
"use client";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/organization/admin-organization-add-modal";
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type AdminOrganizationSectionProps = {
@@ -1,6 +1,6 @@
"use client"
import { DataTable } from "@/components/wrappers/common/table/data-table";
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/organization/table-colums";
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/organizations/organization/table-colums";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type AdminOrganizationListProps = {
@@ -7,11 +7,11 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodFo
import {
AddMemberSchema,
AddMemberSchemaType
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
} from "@/components/wrappers/dashboard/admin/organizations/organization/organization.schema";
import {SearchInput} from "@/components/ui/search-input";
import {
addMemberOrganizationAction
} from "@/components/wrappers/dashboard/admin/organization/details/add-member.action";
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
import {toast} from "sonner";
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
@@ -1,11 +1,20 @@
"use client";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { OrganizationAddMemberForm } from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-form";
import { useState } from "react";
import { UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {
OrganizationAddMemberForm
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-add-member-form";
import {useState} from "react";
import {UserPlus} from "lucide-react";
import {Button} from "@/components/ui/button";
import { OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
type OrganizationAddMemberModalProps = {
@@ -13,13 +22,13 @@ type OrganizationAddMemberModalProps = {
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationAddMemberModal = ({ users, organization }: OrganizationAddMemberModalProps) => {
export const OrganizationAddMemberModal = ({users, organization}: OrganizationAddMemberModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="w-4 h-4 mr-2" />
<UserPlus className="w-4 h-4 mr-2"/>
Add member
</Button>
</DialogTrigger>
@@ -28,7 +37,8 @@ export const OrganizationAddMemberModal = ({ users, organization }: Organization
<DialogTitle>Add member to your organization</DialogTitle>
<DialogDescription>Select a user to add to your organization</DialogDescription>
</DialogHeader>
<OrganizationAddMemberForm users={users} organization={organization} onSuccessAction={() => setOpen(!open)} />
<OrganizationAddMemberForm users={users} organization={organization}
onSuccessAction={() => setOpen(!open)}/>
</DialogContent>
</Dialog>
);
@@ -12,12 +12,12 @@ import {Button} from "@/components/ui/button";
import {MoreHorizontal, Settings, Trash2} from "lucide-react";
import {
OrganizationDeleteMemberModal
} from "@/components/wrappers/dashboard/admin/organization/details/organization-delete-member-modal";
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-delete-member-modal";
import {useState} from "react";
import {authClient} from "@/lib/auth/auth-client";
import {
OrganizationMemberChangeRoleModal
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-change-role";
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
type OrganizationMemberCardProps = {
@@ -22,7 +22,7 @@ import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/u
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {
updateMemberRoleAdminAction
} from "@/components/wrappers/dashboard/admin/organization/details/role-member.action";
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action";
type OrganizationMemberChangeRoleModalProps = {
open: boolean;
@@ -7,7 +7,7 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodFo
import {
UpdateOrganizationSchema,
UpdateOrganizationSchemaType
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
} from "@/components/wrappers/dashboard/admin/organizations/organization/organization.schema";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Input} from "@/components/ui/input";
import {authClient} from "@/lib/auth/auth-client";
@@ -6,17 +6,17 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {
UpdateOrganizationForm
} from "@/components/wrappers/dashboard/admin/organization/details/update-organization-form";
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/update-organization-form";
import {
OrganizationMemberCard
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-card";
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-card";
import {useRouter, useSearchParams} from "next/navigation";
import {useEffect, useState} from "react";
import {capitalizeFirstLetter} from "@/utils/text";
import {User} from "@/db/schema/02_user";
import {
OrganizationAddMemberModal
} from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-modal";
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/organization-add-member-modal";
import {cn} from "@/lib/utils";
type OrganizationManagementProps = {
@@ -1,6 +1,6 @@
"use client";
import {ColumnDef} from "@tanstack/react-table";
import {ButtonDeleteOrganization} from "@/components/wrappers/dashboard/admin/organization/button-delete-organization";
import {ButtonDeleteOrganization} from "@/components/wrappers/dashboard/admin/organizations/organization/button-delete-organization";
import Link from "next/link";
import {Settings} from "lucide-react";
import {buttonVariants} from "@/components/ui/button";
@@ -1,6 +1,6 @@
"use server";
import { z } from "zod";
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.schema";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import * as drizzleDb from "@/db";
@@ -19,11 +19,11 @@ import {TooltipProvider} from "@/components/ui/tooltip";
import {
EmailFormSchema,
EmailFormType
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.schema";
import {PasswordInput} from "@/components/ui/password-input";
import {
updateEmailSettingsAction
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.action";
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
@@ -1,7 +1,7 @@
"use client"
import {EmailForm} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form";
import {EmailForm} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form";
import {Setting} from "@/db/schema/01_setting";
import {EmailFormType} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
import {EmailFormType} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.schema";
export type SettingsEmailSectionProps = {
@@ -6,7 +6,7 @@ import {toast} from "sonner";
export type AdminSettingsTabProps = {};
export const AdminSettingsTab = (props: AdminSettingsTabProps) => {
export const AdminSettingsSection = (props: AdminSettingsTabProps) => {
const handleDownloadKey = async () => {
@@ -1,8 +1,9 @@
"use client"
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {Info, ShieldCheck} from "lucide-react";
import {Switch} from "@/components/ui/switch";
import {Label} from "@/components/ui/label";
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/storage-s3-form";
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/storage-s3-form";
import {useState} from "react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
@@ -11,15 +12,15 @@ import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {
updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.action";
import {Setting} from "@/db/schema/01_setting";
import {S3FormType} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
import {S3FormType} from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema";
export type SettingsStorageTabProps = {
export type SettingsStorageSectionProps = {
settings: Setting;
};
export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
export const SettingsStorageSection = (props: SettingsStorageSectionProps) => {
const router = useRouter();
const mutation = useMutation({
@@ -62,8 +63,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
};
return (
<div className="flex flex-col h-full py-4">
<h1>Settings for Portabase storage</h1>
<div className="flex flex-col h-full">
<Alert className="mt-3">
<Info className="h-4 w-4"/>
<AlertTitle>Informations</AlertTitle>
@@ -2,7 +2,7 @@
import { z } from "zod";
import { db } from "@/db";
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
@@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.schema";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/settings/storage/storage-s3/s3-form.action";
import {PasswordInput} from "@/components/ui/password-input";
export type S3FormProps = {
@@ -1,30 +0,0 @@
"use server";
import { z } from "zod";
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
export const updateEmailSettingsAction = userAction
.schema(
z.object({
name: z.string(),
data: EmailFormSchema,
})
)
.action(async ({ parsedInput }) => {
const { name, data } = parsedInput;
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
...data,
})
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return {
data: updatedSettings,
};
});
@@ -1,11 +0,0 @@
import { z } from "zod";
export const EmailFormSchema = z.object({
smtpPassword: z.string(),
smtpFrom: z.string(),
smtpHost: z.string(),
smtpPort: z.string(),
smtpUser: z.string(),
});
export type EmailFormType = z.infer<typeof EmailFormSchema>;
@@ -1,208 +0,0 @@
"use client";
import {Card, CardContent} from "@/components/ui/card";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {
EmailFormSchema,
EmailFormType
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
import {PasswordInput} from "@/components/ui/password-input";
import {
updateEmailSettingsAction
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Send} from "lucide-react";
import {sendEmail} from "@/lib/email/email-helper";
import {render} from "@react-email/render";
import TestEmailSettings from "@/components/emails/email-settings-test";
import {cn} from "@/lib/utils";
export type EmailFormProps = {
defaultValues?: EmailFormType;
};
export const EmailForm = (props: EmailFormProps) => {
const router = useRouter();
const form = useZodForm({
schema: EmailFormSchema,
defaultValues: props.defaultValues,
});
const isDirty = form.formState.isDirty;
const mutation = useMutation({
mutationFn: async (values: EmailFormType) => {
const updateEmailSettings = await updateEmailSettingsAction({name: "system", data: values});
const data = updateEmailSettings?.data?.data;
if (updateEmailSettings?.serverError || !data) {
toast.error(updateEmailSettings?.serverError);
return;
}
toast.success(`Success updating email informations`);
router.refresh();
form.reset(data)
},
});
const mutationSendEmailTest = useMutation({
mutationFn: async () => {
if (!props.defaultValues?.smtpUser || !props.defaultValues?.smtpFrom) {
toast.error("SMTP is not configured");
return;
}
const email = await sendEmail({
to: props.defaultValues.smtpUser,
subject: "Portabase",
html: await render(TestEmailSettings(), {}),
from: props.defaultValues.smtpFrom,
});
if (email.response) {
toast.success("Test Email Successfully sent !");
}
},
});
return (
<TooltipProvider>
<Card>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="smtpFrom"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>From Email *</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email from where the email will be send"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpHost"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Server Host *</FormLabel>
<FormControl>
<Input placeholder={"ssl0.ovh.net"} {...field} />
</FormControl>
<FormDescription>{"Your email server host"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Server Port *</FormLabel>
<FormControl>
<Input placeholder={"465"} {...field} />
</FormControl>
<FormDescription>{"Your email server port (send)"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPassword"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="Password" {...field} />
</FormControl>
<FormDescription>{"Your email server password"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpUser"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>User Email *</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email server user"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex justify-between gap-4 mt-5">
{props.defaultValues?.smtpFrom && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ButtonWithLoading
type="button"
disabled={isDirty || mutationSendEmailTest.isPending}
isPending={mutationSendEmailTest.isPending}
onClick={async () => {
await mutationSendEmailTest.mutateAsync();
}}
icon={<Send/>}
size="default"
>
Send email test
</ButtonWithLoading>
</div>
</TooltipTrigger>
{isDirty && (
<TooltipContent className={cn(!isDirty && "hidden")}>
You must save changes before testing the email settings.
</TooltipContent>
)}
</Tooltip>
)}
<Button>Save</Button>
</div>
</Form>
</CardContent>
</Card>
</TooltipProvider>
);
};
@@ -1,61 +0,0 @@
import {EmailForm} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form";
import {Send} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {sendEmail} from "@/lib/email/email-helper";
import {render} from "@react-email/render";
import {toast} from "sonner";
import {Setting} from "@/db/schema/01_setting";
import {EmailFormType} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
import TestEmailSettings from "@/components/emails/email-settings-test"
export type SettingsEmailTabProps = {
settings: Setting;
};
export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
const mutation = useMutation({
mutationFn: async () => {
if (!props.settings.smtpUser || !props.settings.smtpFrom) {
toast.error("SMTP is not configured");
return;
}
const email = await sendEmail({
to: props.settings.smtpUser,
subject: "Portabase",
html: await render(TestEmailSettings(), {}),
from: props.settings.smtpFrom,
});
if (email.response) {
toast.success("Test Email Successfully sent !");
}
},
});
const handleSendMailTest = async () => {
await mutation.mutateAsync();
};
return (
<div className="flex flex-col h-full py-4">
<div className="flex gap-4 h-fit justify-between">
<h1>Settings for Portabase email setup</h1>
{props.settings.smtpFrom && (
<ButtonWithLoading
isPending={mutation.isPending}
onClick={async () => {
await handleSendMailTest();
}}
icon={<Send/>}
size="default"
>Send email test</ButtonWithLoading>
)}
</div>
<div className="mt-5">
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined}/>
</div>
</div>
);
};
@@ -1,7 +1,7 @@
import {UserWithAccounts} from "@/db/schema/02_user";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/columns-users";
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/users/columns-users";
export type AdminUsersTableProps = {
users: UserWithAccounts[];
@@ -9,7 +9,7 @@ import {useState} from "react";
import {UserWithAccounts} from "@/db/schema/02_user";
import {authClient, useSession} from "@/lib/auth/auth-client";
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/users/button-delete-use";
import {formatLocalizedDate} from "@/utils/date-formatting";
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
@@ -8,33 +8,47 @@ import {
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";
import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {useIsMobile} from "@/hooks/use-mobile";
import {useState} from "react";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {
NotifierChannelOrganisationForm
} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form";
type OrganizationNotifierAddModalProps = {
notificationChannel?: NotificationChannel
notificationChannel?: NotificationChannelWith
organization?: OrganizationWithMembers;
open: boolean;
onOpenChangeAction: (open: boolean) => void;
open?: boolean;
onOpenChangeAction?: (open: boolean) => void;
adminView?: boolean;
organizations?: OrganizationWithMembers[]
}
export const NotifierAddEditModal = ({
organization,
notificationChannel,
open,
onOpenChangeAction
open = false,
onOpenChangeAction,
adminView,
organizations
}: OrganizationNotifierAddModalProps) => {
const isMobile = useIsMobile();
const [openInternal, setOpen] = useState(open);
const isCreate = !Boolean(notificationChannel);
return (
<Dialog open={open} onOpenChange={onOpenChangeAction}>
<Dialog open={openInternal} onOpenChange={(state) => {
onOpenChangeAction?.(state);
setOpen(state);
}}>
<DialogTrigger asChild>
{isCreate ?
<Button>
<Plus/>{!isMobile && `Add notification channel` }
<Plus/>{!isMobile && `Add notification channel`}
</Button>
:
<Button
@@ -52,11 +66,52 @@ export const NotifierAddEditModal = ({
Configure your notification channel preferences and event triggers.
</DialogDescription>
</DialogHeader>
<NotifierForm
defaultValues={notificationChannel}
organization={organization}
onSuccessAction={() => onOpenChangeAction(false)}
/>
<div>
{adminView ?
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="configuration">Configuration</TabsTrigger>
<TabsTrigger value="organizations">Organizations</TabsTrigger>
</TabsList>
<TabsContent className="h-full justify-between" value="configuration">
<NotifierForm
adminView={adminView}
defaultValues={notificationChannel}
organization={organization}
onSuccessAction={() => {
onOpenChangeAction?.(false)
setOpen(false);
}}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="organizations">
<NotifierChannelOrganisationForm
defaultValues={notificationChannel}
organizations={organizations}
/>
</TabsContent>
</Tabs>
:
<NotifierForm
adminView={adminView}
defaultValues={notificationChannel}
organization={organization}
onSuccessAction={() => {
onOpenChangeAction?.(false)
setOpen(false);
}}
/>
}
</div>
</DialogContent>
</Dialog>
)
@@ -2,7 +2,7 @@
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 {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {Switch} from "@/components/ui/switch";
import {
updateNotificationChannelAction
@@ -12,11 +12,18 @@ import {useState} from "react";
import {Organization, OrganizationWithMembers} from "@/db/schema/03_organization";
export type EditNotifierButtonProps = {
notificationChannel: NotificationChannel;
organization? : OrganizationWithMembers;
notificationChannel: NotificationChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
};
export const EditNotifierButton = ({notificationChannel, organization}: EditNotifierButtonProps) => {
export const EditNotifierButton = ({
organizations,
adminView = false,
notificationChannel,
organization
}: EditNotifierButtonProps) => {
const router = useRouter();
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
@@ -53,6 +60,8 @@ export const EditNotifierButton = ({notificationChannel, organization}: EditNoti
}}
/>
<NotifierAddEditModal
organizations={organizations}
adminView={adminView}
organization={organization}
notificationChannel={notificationChannel}
open={isAddModalOpen}
@@ -1,19 +1,20 @@
"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 {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
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, OrganizationWithMembers} from "@/db/schema/03_organization";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
export type NotifierCardProps = {
data: NotificationChannel;
data: NotificationChannelWith;
organization?: OrganizationWithMembers;
organizations?: OrganizationWithMembers[];
adminView?: boolean;
};
export const NotifierCard = (props: NotifierCardProps) => {
@@ -25,7 +26,7 @@ export const NotifierCard = (props: NotifierCardProps) => {
<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)}
{getNotificationChannelIcon(data.provider)}
</div>
<div className={`h-2 w-2 rounded-full ${data.enabled ? "bg-green-600" : "bg-muted"}`}/>
</div>
@@ -41,6 +42,8 @@ export const NotifierCard = (props: NotifierCardProps) => {
<div className="flex items-center gap-2">
<EditNotifierButton
organizations={props.organizations}
adminView={props.adminView}
organization={organization}
notificationChannel={data}/>
<DeleteNotifierButton
@@ -54,14 +57,3 @@ export const NotifierCard = (props: NotifierCardProps) => {
};
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},
]
@@ -7,7 +7,7 @@ export const NotificationChannelFormSchema = z.object({
.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"], {
provider: z.enum(["slack", "smtp"], {
required_error: "Provider is required",
}),
@@ -21,7 +21,7 @@ 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 {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {
NotifierTestChannelButton
} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button";
@@ -30,13 +30,16 @@ import {useEffect} from "react";
type NotifierFormProps = {
onSuccessAction?: () => void;
organization?: OrganizationWithMembers;
defaultValues?: NotificationChannel
defaultValues?: NotificationChannelWith
adminView?: boolean
};
export const NotifierForm = ({onSuccessAction, organization, defaultValues}: NotifierFormProps) => {
export const NotifierForm = ({onSuccessAction, organization, defaultValues, adminView}: NotifierFormProps) => {
const isCreate = !Boolean(defaultValues);
const router = useRouter();
const isCreate = !Boolean(defaultValues);
const form = useZodForm({
schema: NotificationChannelFormSchema,
// @ts-ignore
@@ -47,7 +50,7 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
form.reset(defaultValues ? {...defaultValues} : {});
}, [defaultValues]);
const mutationCreateOrganisation = useMutation({
const mutationAddNotificationChannel = useMutation({
mutationFn: async (values: NotificationChannelFormType) => {
const payload = {
@@ -73,12 +76,14 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
const provider = form.watch("provider");
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationCreateOrganisation.mutateAsync(values);
await mutationAddNotificationChannel.mutateAsync(values);
}}
>
<FormField
@@ -136,7 +141,8 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
<div className="flex justify-between">
<div>
{defaultValues && (
<NotifierTestChannelButton organizationId={organization?.id} notificationChannel={defaultValues}/>
<NotifierTestChannelButton organizationId={organization?.id}
notificationChannel={defaultValues}/>
)}
</div>
<div className="flex gap-2">
@@ -150,7 +156,7 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
>
Cancel
</Button>
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>
<ButtonWithLoading isPending={mutationAddNotificationChannel.isPending}>
{isCreate ? "Add" : "Save"} Channel
</ButtonWithLoading>
</div>
@@ -159,7 +165,3 @@ export const NotifierForm = ({onSuccessAction, organization, defaultValues}: Not
</Form>
);
};
@@ -7,7 +7,7 @@ import {
Layers,
ChartArea,
ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks
} from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient, useSession} from "@/lib/auth/auth-client";
@@ -69,6 +69,17 @@ export const SidebarMenuCustomMain = () => {
details: true,
type: "item"
},
{
title: "Notifications",
url: "/notifications",
icon: Megaphone,
details: true,
type: "collapse",
submenu: [
{ title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item" },
{ title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item" },
],
},
{
title: "Access management",
url: "/admin",
@@ -12,8 +12,8 @@ import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile/us
import { toast } from "sonner";
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/sessions/table-columns";
import {accountsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/accounts/table-columns";
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/users/sessions/table-columns";
import {accountsColumns} from "@/components/wrappers/dashboard/admin/users/accounts/table-columns";
import {Account, Session} from "better-auth";
export type UserFormProps = {
+11 -2
View File
@@ -1,9 +1,10 @@
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 {MemberWithUser, Organization, organization} from "@/db/schema/03_organization";
import {relations} from "drizzle-orm";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {OrganizationInvitation} from "@/db/schema/05_invitation";
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp']);
@@ -47,4 +48,12 @@ export const organizationNotificationChannelRelations = relations(organizationNo
}));
export const notificationChannelSchema = createSelectSchema(notificationChannel);
export type NotificationChannel = z.infer<typeof notificationChannelSchema>;
export type NotificationChannel = z.infer<typeof notificationChannelSchema>;
export type NotificationChannelWith = NotificationChannel & {
organizations: {
organizationId: string;
notificationChannelId: string;
}[];
};
+15 -6
View File
@@ -1,8 +1,11 @@
import {pgTable, uuid, timestamp, jsonb, varchar, boolean, text, pgEnum} from 'drizzle-orm/pg-core';
import {notificationChannel, providerKindEnum} from "@/db/schema/09_notification-channel";
import {notificationChannel} from "@/db/schema/09_notification-channel";
import {alertPolicy} from "@/db/schema/10_alert-policy";
import {organization} from "@/db/schema/03_organization";
import {timestamps} from "@/db/schema/00_common";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const levelEnum = pgEnum('level', ['critical', 'warning', 'info']);
@@ -12,21 +15,27 @@ export const notificationLog = pgTable('notification_log', {
channelId: uuid('channel_id')
.notNull()
.references(() => notificationChannel.id, { onDelete: 'restrict' }),
.references(() => notificationChannel.id, {onDelete: 'restrict'}),
policyId: uuid('policy_id')
.references(() => alertPolicy.id, { onDelete: 'restrict' }),
.references(() => alertPolicy.id, {onDelete: 'restrict'}),
organizationId: uuid('organization_id')
.references(() => organization.id, { onDelete: 'set null' }),
.references(() => organization.id, {onDelete: 'set null'}),
title: varchar('title', { length: 255 }).notNull(),
title: varchar('title', {length: 255}).notNull(),
message: text('message').notNull(),
level: levelEnum('level').notNull(),
payload: jsonb('payload'),
success: boolean('success').notNull(),
error: text('error'),
providerResponse: jsonb('provider_response'),
sentAt: timestamp('sent_at').defaultNow().notNull(),
...timestamps
});
});
export const notificationLogSchema = createSelectSchema(notificationLog);
export type NotificationLog = z.infer<typeof notificationLogSchema>;
export type NotificationLevel = (typeof levelEnum.enumValues)[number];
+52 -21
View File
@@ -1,24 +1,45 @@
import {desc, eq, and, gte, lte} from 'drizzle-orm';
import {
notificationLog
} from "@/db/schema/11_notification-log";
import {
notificationChannel,
} from "@/db/schema/09_notification-channel";
import {and, desc, eq, gte, lte} from 'drizzle-orm';
import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log";
import {notificationChannel} from "@/db/schema/09_notification-channel";
import {db} from "@/db";
import {alertPolicy} from "@/db/schema/10_alert-policy";
import {Json} from "drizzle-zod";
export type NotificationLogWithRelations = {
id: string;
title: string;
level: NotificationLevel;
success: boolean;
error: string | null;
sentAt: Date;
payload: Json | null;
content: {
title: string;
message: string;
},
channel: {
id: string;
name: string;
provider: string;
} | null;
policy: {
id: string;
eventKinds: string[];
} | null;
};
export async function getNotificationHistory(filters?: {
channelId?: string;
policyId?: string;
organizationId?: string;
level?: string;
success?: boolean;
from?: Date;
to?: Date;
limit?: number;
}) {
export async function getNotificationHistory(
filters?: {
channelId?: string;
policyId?: string;
organizationId?: string;
level?: NotificationLevel;
success?: boolean;
from?: Date;
to?: Date;
limit?: number;
}
): Promise<NotificationLogWithRelations[]> {
const where = [];
if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId));
@@ -28,7 +49,7 @@ export async function getNotificationHistory(filters?: {
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
return await db
const rows = await db
.select({
id: notificationLog.id,
title: notificationLog.title,
@@ -36,14 +57,19 @@ export async function getNotificationHistory(filters?: {
success: notificationLog.success,
error: notificationLog.error,
sentAt: notificationLog.sentAt,
payload: notificationLog.payload,
content: {
title: notificationLog.title,
message: notificationLog.message,
},
channel: {
id: notificationLog.id,
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
},
policy: {
id: alertPolicy.id,
eventKind: alertPolicy.eventKind,
eventKinds: alertPolicy.eventKinds,
},
})
.from(notificationLog)
@@ -52,4 +78,9 @@ export async function getNotificationHistory(filters?: {
.where(and(...where))
.orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100);
}
return rows.map(row => ({
...row,
payload: row.payload as Json,
}));
}