From faaa4033bd4b71e50d8de8e88aeba48c5b53d2d9 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Mon, 12 Jan 2026 21:16:52 +0100 Subject: [PATCH 01/24] feat: backend storage --- .../(admin)/notifications/channels/page.tsx | 9 +- .../(admin)/storages/channels/page.tsx | 46 ++ .../(organization)/settings/page.tsx | 7 - .../wrappers/auth/auth-logo-section.tsx | 2 +- .../channel/channel-add-edit-modal.tsx} | 84 +-- .../channel-card/button-delete-channel.tsx} | 25 +- .../channel-card/button-edit-channel.tsx | 91 ++++ .../channel/channel-card/channel-card.tsx} | 56 +- .../channel-form/channel-form.schema.ts | 27 + .../channel/channel-form/channel-form.tsx} | 129 ++--- .../channel-form/channel-test-button.tsx | 76 +++ .../providers/notifications/action.ts} | 17 +- .../notifications/forms/discord.form.tsx} | 0 .../notifications/forms/gotify.form.tsx} | 0 .../notifications/forms/ntfy.form.tsx} | 0 .../notifications/forms/slack.form.tsx} | 0 .../notifications/forms/smtp.form.tsx} | 0 .../notifications/forms/telegram.form.tsx} | 0 .../notifications/forms/webhook.form.tsx} | 0 .../channel-form/providers/storages/action.ts | 172 +++++++ .../admin/channels/channels-section.tsx | 57 ++ .../admin/channels/helpers/common.tsx | 88 ++++ .../admin/channels/helpers/notification.tsx | 483 +++++++++++++++++ .../admin/channels/helpers/storage.tsx | 6 + .../channels-organization-form.tsx} | 44 +- .../channels-organization.action.ts | 154 ++++++ .../channels-organization.schema.ts | 7 + .../notification-channels-section.tsx | 50 -- ...tification-channels-organization.action.ts | 79 --- ...tification-channels-organization.schema.ts | 7 - .../dashboard/admin/notifications/helpers.tsx | 487 ------------------ .../admin/notifications/logs/columns.tsx | 4 +- .../notifier-card/button-edit-notifier.tsx | 72 --- .../notifier-form/notifier-form.schema.ts | 21 - .../notifier-test-channel-button.tsx | 61 --- .../common/sidebar/menu-sidebar-main.tsx | 12 +- .../organization-notifiers-tab.tsx | 17 +- src/db/index.ts | 4 +- src/db/migrations/meta/_journal.json | 7 + src/db/schema/03_organization.ts | 3 + src/db/schema/12_storage-channel.ts | 58 +++ src/features/storages/dispatch.ts | 129 +++++ 42 files changed, 1623 insertions(+), 968 deletions(-) create mode 100644 app/(customer)/dashboard/(admin)/storages/channels/page.tsx rename src/components/wrappers/dashboard/{common/notifier/notifier-add-edit-modal.tsx => admin/channels/channel/channel-add-edit-modal.tsx} (56%) rename src/components/wrappers/dashboard/{common/notifier/notifier-card/button-delete-notifier.tsx => admin/channels/channel/channel-card/button-delete-channel.tsx} (57%) create mode 100644 src/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel.tsx rename src/components/wrappers/dashboard/{common/notifier/notifier-card/notifier-card.tsx => admin/channels/channel/channel-card/channel-card.tsx} (50%) create mode 100644 src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema.ts rename src/components/wrappers/dashboard/{common/notifier/notifier-form/notifier-form.tsx => admin/channels/channel/channel-form/channel-form.tsx} (65%) create mode 100644 src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button.tsx rename src/components/wrappers/dashboard/{common/notifier/notifier-form/notifier-form.action.ts => admin/channels/channel/channel-form/providers/notifications/action.ts} (94%) rename src/components/wrappers/dashboard/{common/notifier/notifier-form/providers/notifier-discord.form.tsx => admin/channels/channel/channel-form/providers/notifications/forms/discord.form.tsx} (100%) rename src/components/wrappers/dashboard/{common/notifier/notifier-form/providers/notifier-gotify.form.tsx => admin/channels/channel/channel-form/providers/notifications/forms/gotify.form.tsx} (100%) rename src/components/wrappers/dashboard/{common/notifier/notifier-form/providers/notifier-ntfy.form.tsx => admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form.tsx} (100%) rename src/components/wrappers/dashboard/{common/notifier/notifier-form/providers/notifier-slack.form.tsx => admin/channels/channel/channel-form/providers/notifications/forms/slack.form.tsx} (100%) rename src/components/wrappers/dashboard/{common/notifier/notifier-form/providers/notifier-smtp.form.tsx => admin/channels/channel/channel-form/providers/notifications/forms/smtp.form.tsx} (100%) rename src/components/wrappers/dashboard/{common/notifier/notifier-form/providers/notifier-telegram.form.tsx => admin/channels/channel/channel-form/providers/notifications/forms/telegram.form.tsx} (100%) rename src/components/wrappers/dashboard/{common/notifier/notifier-form/providers/notifier-webhook.form.tsx => admin/channels/channel/channel-form/providers/notifications/forms/webhook.form.tsx} (100%) create mode 100644 src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action.ts create mode 100644 src/components/wrappers/dashboard/admin/channels/channels-section.tsx create mode 100644 src/components/wrappers/dashboard/admin/channels/helpers/common.tsx create mode 100644 src/components/wrappers/dashboard/admin/channels/helpers/notification.tsx create mode 100644 src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx rename src/components/wrappers/dashboard/admin/{notifications/channels/organization/notification-channels-organization-form.tsx => channels/organization/channels-organization-form.tsx} (66%) create mode 100644 src/components/wrappers/dashboard/admin/channels/organization/channels-organization.action.ts create mode 100644 src/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema.ts delete mode 100644 src/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section.tsx delete mode 100644 src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action.ts delete mode 100644 src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema.ts delete mode 100644 src/components/wrappers/dashboard/admin/notifications/helpers.tsx delete mode 100644 src/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier.tsx delete mode 100644 src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts delete mode 100644 src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx create mode 100644 src/db/schema/12_storage-channel.ts create mode 100644 src/features/storages/dispatch.ts diff --git a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx index a4bf48d3..00f2e2e9 100644 --- a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx +++ b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx @@ -8,6 +8,8 @@ import {db} from "@/db"; import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal"; import {desc, isNull} from "drizzle-orm"; +import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section"; +import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; export const metadata: Metadata = { title: "Notification Channels", @@ -35,11 +37,14 @@ export default async function RoutePage(props: PageParams<{}>) { Notification channels - + {/**/} + - + {/**/} + ); diff --git a/app/(customer)/dashboard/(admin)/storages/channels/page.tsx b/app/(customer)/dashboard/(admin)/storages/channels/page.tsx new file mode 100644 index 00000000..898f1131 --- /dev/null +++ b/app/(customer)/dashboard/(admin)/storages/channels/page.tsx @@ -0,0 +1,46 @@ +import {PageParams} from "@/types/next"; +import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {Metadata} from "next"; +import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal"; +import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section"; +import {db} from "@/db"; +import {desc, isNull} from "drizzle-orm"; +import * as drizzleDb from "@/db"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; +import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; + +export const metadata: Metadata = { + title: "Storage Channels", +}; + +export default async function RoutePage(props: PageParams<{}>) { + + const storageChannels = await db.query.storageChannel.findMany({ + with: { + organizations: true + }, + orderBy: desc(drizzleDb.schemas.storageChannel.createdAt) + }) as StorageChannelWith[] + + const organizations = await db.query.organization.findMany({ + where: (fields) => isNull(fields.deletedAt), + with: { + members: true, + }, + }); + + + return ( + + + Storage channels + + + + + + + + + ); +} diff --git a/app/(customer)/dashboard/(organization)/settings/page.tsx b/app/(customer)/dashboard/(organization)/settings/page.tsx index 131e2d62..6974a927 100644 --- a/app/(customer)/dashboard/(organization)/settings/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/page.tsx @@ -11,13 +11,6 @@ import {Metadata} from "next"; import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; import {getOrganizationChannels} from "@/db/services/notification-channel"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; -import {capitalizeFirstLetter} from "@/utils/text"; -import Link from "next/link"; -import {buttonVariants} from "@/components/ui/button"; -import {GearIcon} from "@radix-ui/react-icons"; -import { - ButtonDeleteProject -} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project"; export const metadata: Metadata = { title: "Settings", diff --git a/src/components/wrappers/auth/auth-logo-section.tsx b/src/components/wrappers/auth/auth-logo-section.tsx index 81d1a0e5..11d3d30c 100644 --- a/src/components/wrappers/auth/auth-logo-section.tsx +++ b/src/components/wrappers/auth/auth-logo-section.tsx @@ -41,7 +41,7 @@ export const AuthLogoSection = () => { /> )} - + v{env.NEXT_PUBLIC_PROJECT_VERSION} diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx similarity index 56% rename from src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx index d0033ad3..8e63dcb8 100644 --- a/src/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx @@ -6,44 +6,51 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import {Button} from "@/components/ui/button"; -import {NotifierForm} from "@/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form"; import {OrganizationWithMembers} from "@/db/schema/03_organization"; -import {NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; +import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {useIsMobile} from "@/hooks/use-mobile"; import {useEffect, useState} from "react"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; +import {ChannelForm} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form"; +import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; import { - NotifierChannelOrganisationForm -} from "@/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form"; + ChannelOrganisationForm +} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization-form"; -type OrganizationNotifierAddModalProps = { - notificationChannel?: NotificationChannelWith +type ChannelAddModalProps = { + channel?: NotificationChannelWith | StorageChannelWith organization?: OrganizationWithMembers; open?: boolean; onOpenChangeAction?: (open: boolean) => void; adminView?: boolean; organizations?: OrganizationWithMembers[] trigger?: boolean; + kind: ChannelKind; } -export const NotifierAddEditModal = ({ - organization, - notificationChannel, - open = false, - onOpenChangeAction, - adminView, - organizations , - trigger= true - }: OrganizationNotifierAddModalProps) => { +export const ChannelAddEditModal = ({ + organization, + channel, + open = false, + onOpenChangeAction, + adminView, + organizations, + trigger = true, + kind + }: ChannelAddModalProps) => { const isMobile = useIsMobile(); const [openInternal, setOpen] = useState(open); - const isCreate = !Boolean(notificationChannel); + const isCreate = !Boolean(channel); useEffect(() => { setOpen(open); - },[open]) + }, [open]) + + + const channelText = getChannelTextBasedOnKind(kind) return ( @@ -55,7 +62,7 @@ export const NotifierAddEditModal = ({ {isCreate ? : + ) +} \ 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/admin/channels/channel/channel-form/providers/notifications/action.ts similarity index 94% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action.ts rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action.ts index 5f773f4f..e47d60ad 100644 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.action.ts +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/action.ts @@ -6,11 +6,11 @@ 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"; +import { + NotificationChannelFormSchema +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema"; export const addNotificationChannelAction = userAction.schema( @@ -20,7 +20,6 @@ export const addNotificationChannelAction = userAction.schema( }) ).action(async ({parsedInput}): Promise> => { const {organizationId, data} = parsedInput; - try { const [channel] = await db .insert(drizzleDb.schemas.notificationChannel) @@ -128,11 +127,11 @@ export const removeNotificationChannelAction = userAction.schema( export const updateNotificationChannelAction = userAction.schema( z.object({ - notificationChannelId: z.string(), + id: z.string(), data: NotificationChannelFormSchema }) ).action(async ({parsedInput}): Promise> => { - const {notificationChannelId, data} = parsedInput; + const {id, data} = parsedInput; try { const [channel] = await db @@ -143,7 +142,7 @@ export const updateNotificationChannelAction = userAction.schema( config: data.config, enabled: data.enabled ?? true, })) - .where(eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId)) + .where(eq(drizzleDb.schemas.notificationChannel.id, id)) .returning(); return { @@ -154,7 +153,7 @@ export const updateNotificationChannelAction = userAction.schema( }, actionSuccess: { message: `Notification channel "${channel.name}" has been successfully updated.`, - messageParams: {notificationChannelId: channel.id}, + messageParams: {id: channel.id}, }, }; } catch (error) { @@ -165,7 +164,7 @@ export const updateNotificationChannelAction = userAction.schema( message: "Failed to update notification channel.", status: 500, cause: error instanceof Error ? error.message : "Unknown error", - messageParams: {notificationChannelId: ""}, + messageParams: {id: ""}, }, }; } diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-discord.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/discord.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-discord.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/discord.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-gotify.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/gotify.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-gotify.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/gotify.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-ntfy.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-ntfy.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/slack.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-slack.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/slack.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/smtp.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-smtp.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/smtp.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-telegram.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/telegram.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-telegram.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/telegram.form.tsx diff --git a/src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-webhook.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form.tsx similarity index 100% rename from src/components/wrappers/dashboard/common/notifier/notifier-form/providers/notifier-webhook.form.tsx rename to src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form.tsx diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action.ts b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/action.ts new file mode 100644 index 00000000..8c19df3a --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/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 {db} from "@/db"; +import {and, eq} from "drizzle-orm"; +import {withUpdatedAt} from "@/db/utils"; +import { + StorageChannelFormSchema +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.schema"; +import {StorageChannel} from "@/db/schema/12_storage-channel"; + + +export const addStorageChannelAction = userAction.schema( + z.object({ + organizationId: z.string().optional(), + data: StorageChannelFormSchema + }) +).action(async ({parsedInput}): Promise> => { + const {organizationId, data} = parsedInput; + + try { + const [channel] = await db + .insert(drizzleDb.schemas.storageChannel) + .values({ + provider: data.provider, + name: data.name, + config: data.config, + enabled: data.enabled ?? true, + }) + .returning(); + + if (organizationId) { + await db.insert(drizzleDb.schemas.organizationStorageChannel).values({ + organizationId, + storageChannelId: channel.id, + }); + } + + return { + success: true, + value: { + ...channel, + config: channel.config as JSON + }, + actionSuccess: { + message: "Storage channel has been successfully created.", + messageParams: {id: channel.id}, + }, + }; + } catch (error) { + console.error("Error:", error); + return { + success: false, + actionError: { + message: "Failed to create storage channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {id: ""}, + }, + }; + } +}); + +export const removeStorageChannelAction = userAction.schema( + z.object({ + organizationId: z.string().optional(), + id: z.string(), + }) +).action(async ({parsedInput}): Promise> => { + const {organizationId, id} = parsedInput; + + try { + if (organizationId) { + await db + .delete(drizzleDb.schemas.organizationNotificationChannel) + .where( + and( + eq(drizzleDb.schemas.organizationStorageChannel.organizationId, organizationId), + eq(drizzleDb.schemas.organizationStorageChannel.storageChannelId, id) + ) + ); + } + + const [deletedChannel] = await db + .delete(drizzleDb.schemas.storageChannel) + .where(eq(drizzleDb.schemas.storageChannel.id, id)) + .returning(); + + if (!deletedChannel) { + return { + success: false, + actionError: { + message: "Storage channel not found.", + status: 404, + messageParams: {id: id}, + }, + }; + } + + return { + success: true, + value: { + ...deletedChannel, + config: deletedChannel.config as JSON + }, + actionSuccess: { + message: "Storage channel has been successfully removed.", + messageParams: {id: id}, + }, + }; + } catch (error) { + console.error("Error:", error); + return { + success: false, + actionError: { + message: "Failed to remove storage channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {id: id}, + }, + }; + } +}); + + +export const updateStorageChannelAction = userAction.schema( + z.object({ + id: z.string(), + data: StorageChannelFormSchema + }) +).action(async ({parsedInput}): Promise> => { + const {id, data} = parsedInput; + + try { + const [channel] = await db + .update(drizzleDb.schemas.storageChannel) + .set(withUpdatedAt({ + provider: data.provider, + name: data.name, + config: data.config, + enabled: data.enabled ?? true, + })) + .where(eq(drizzleDb.schemas.storageChannel.id, id)) + .returning(); + + return { + success: true, + value: { + ...channel, + config: channel.config as JSON + }, + actionSuccess: { + message: `Storage channel "${channel.name}" has been successfully updated.`, + messageParams: {id: channel.id}, + }, + }; + } catch (error) { + console.error("Error:", error); + return { + success: false, + actionError: { + message: "Failed to update storage channel.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {id: ""}, + }, + }; + } +}); diff --git a/src/components/wrappers/dashboard/admin/channels/channels-section.tsx b/src/components/wrappers/dashboard/admin/channels/channels-section.tsx new file mode 100644 index 00000000..cf1adfb2 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channels-section.tsx @@ -0,0 +1,57 @@ +"use client" +import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; +import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; +import {useState} from "react"; +import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; +import {OrganizationWithMembers} from "@/db/schema/03_organization"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; +import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card"; +import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; +import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; + +type ChannelsSectionProps = { + channels: NotificationChannelWith[] | StorageChannelWith[] + organizations: OrganizationWithMembers[] + kind: ChannelKind; +} + +export const ChannelsSection = ({ + organizations, + channels, + kind + }: ChannelsSectionProps) => { + + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const channelText = getChannelTextBasedOnKind(kind) + const hasChannels = channels.length > 0 + + + return ( +
+ + {hasChannels ? ( +
+ +
+ ) : ( + { + setIsAddModalOpen(true) + }} + className="h-full" + /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx b/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx new file mode 100644 index 00000000..e1f607a2 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx @@ -0,0 +1,88 @@ +import {UseFormReturn} from "react-hook-form"; +import { + NotifierSmtpForm +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/smtp.form"; +import { + NotifierSlackForm +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/slack.form"; +import { + NotifierDiscordForm +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/discord.form"; +import { + NotifierTelegramForm +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/telegram.form"; +import { + NotifierGotifyForm +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/gotify.form"; +import { + NotifierNtfyForm +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/ntfy.form"; +import { + NotifierWebhookForm +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form"; +import {notificationTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/notification"; +import {storageTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/storage"; +import {OrganizationWithMembers} from "@/db/schema/03_organization"; +import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; +import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react"; +import {LucideProps} from "lucide-react"; + +export type ChannelKind = "notification" | "storage"; + +export function getChannelTextBasedOnKind(kind: ChannelKind) { + switch (kind) { + case "notification": + return "Notification"; + case "storage": + return "Storage"; + default: + return "Notification"; + } +} + + +type ProviderIconTypes = { + value: string + label: string + icon: ForwardRefExoticComponent & RefAttributes> +} | { + value: string + label: string + icon: (props: SVGProps) => JSX.Element +} + +const providerIcons: ProviderIconTypes[] = [ + ...notificationTypes, + ...storageTypes, +]; + + +export const getChannelIcon = (type: string) => { + const Icon = providerIcons.find((t) => t.value === type)?.icon + return Icon ? : null +} + + +export const renderChannelForm = (provider: string | undefined, form: UseFormReturn) => { + switch (provider) { + case "smtp": + return ; + case "slack": + return ; + case "discord": + return ; + case "telegram": + return ; + case "gotify": + return ; + case "ntfy": + return ; + case "webhook": + return ; + case "local": + return <> + default: + return null; + } +}; \ No newline at end of file diff --git a/src/components/wrappers/dashboard/admin/channels/helpers/notification.tsx b/src/components/wrappers/dashboard/admin/channels/helpers/notification.tsx new file mode 100644 index 00000000..460832d8 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/helpers/notification.tsx @@ -0,0 +1,483 @@ +import {Mail} from "lucide-react"; +import type { SVGProps } from 'react'; + + + +export const notificationTypes = [ + {value: "smtp", label: "Email", icon: Mail}, + {value: "slack", label: "Slack", icon: SlackIcon}, + {value: "discord", label: "Discord", icon: DiscordIcon}, + {value: "telegram", label: "Telegram", icon: TelegramIcon}, + {value: "gotify", label: "Gotify", icon: GotifyIcon}, + {value: "ntfy", label: "ntfy.sh", icon: NtfyIcon}, + {value: "webhook", label: "Webhook", icon: WebhookIcon}, +] + + +export function SlackIcon(props: SVGProps) { + return (); +} + +export function DiscordIcon(props: SVGProps) { + return (); +} + +export function TelegramIcon(props: SVGProps) { + return (); +} + + +export function NtfyIcon(props: SVGProps) { + + return ( + + + + + + + + + + + ) + +} + + +export function GotifyIcon(props: SVGProps) { + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export function WebhookIcon(props: SVGProps) { + return ( + + + + ) +} + diff --git a/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx b/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx new file mode 100644 index 00000000..f1d05f92 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx @@ -0,0 +1,6 @@ +import {Server} from "lucide-react"; + +export const storageTypes = [ + {value: "local", label: "Local", icon: Server}, + {value: "s3", label: "s3", icon: Server}, +] \ No newline at end of file diff --git a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form.tsx b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization-form.tsx similarity index 66% rename from src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form.tsx rename to src/components/wrappers/dashboard/admin/channels/organization/channels-organization-form.tsx index e413171a..3f7a5b80 100644 --- a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization-form.tsx +++ b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization-form.tsx @@ -6,26 +6,30 @@ import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/u 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"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; +import { + ChannelsOrganizationSchema, ChannelsOrganizationType +} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema"; +import { + updateNotificationChannelsOrganizationAction, updateStorageChannelsOrganizationAction +} from "@/components/wrappers/dashboard/admin/channels/organization/channels-organization.action"; +import {ChannelKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; -type NotifierChannelOrganisationFormProps = { +type ChannelOrganisationFormProps = { organizations?: OrganizationWithMembers[]; - defaultValues?: NotificationChannelWith + defaultValues?: NotificationChannelWith | StorageChannelWith + kind: ChannelKind }; -export const NotifierChannelOrganisationForm = ({ - organizations, - defaultValues, - }: NotifierChannelOrganisationFormProps) => { +export const ChannelOrganisationForm = ({ + organizations, + defaultValues, + kind + }: ChannelOrganisationFormProps) => { const router = useRouter(); @@ -33,7 +37,7 @@ export const NotifierChannelOrganisationForm = ({ const form = useZodForm({ - schema: NotificationChannelsOrganizationSchema, + schema: ChannelsOrganizationSchema, // @ts-ignore defaultValues: { organizations: defaultOrganizationIds @@ -49,15 +53,15 @@ export const NotifierChannelOrganisationForm = ({ }; - const mutationUpdateNotificationChannelOrganizations = useMutation({ - mutationFn: async (values: NotificationChannelsOrganizationType) => { + const mutationUpdateChannelOrganizations = useMutation({ + mutationFn: async (values: ChannelsOrganizationType) => { const payload = { data: values.organizations, - notificationChannelId: defaultValues?.id ?? "" + id: defaultValues?.id ?? "" }; - const result = await updateNotificationChannelsOrganizationAction(payload) + const result = kind === "notification" ? await updateNotificationChannelsOrganizationAction(payload) : await updateStorageChannelsOrganizationAction(payload) const inner = result?.data; if (inner?.success) { @@ -76,7 +80,7 @@ export const NotifierChannelOrganisationForm = ({ form={form} className="flex flex-col gap-4" onSubmit={async (values) => { - await mutationUpdateNotificationChannelOrganizations.mutateAsync(values); + await mutationUpdateChannelOrganizations.mutateAsync(values); }} >
- + Save
diff --git a/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.action.ts b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.action.ts new file mode 100644 index 00000000..c8481e93 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.action.ts @@ -0,0 +1,154 @@ +"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"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; + + +export const updateNotificationChannelsOrganizationAction = userAction + .schema( + z.object({ + data: z.array(z.string()), + id: z.string(), + }) + ) + .action(async ({parsedInput , ctx}): Promise> => { + try { + const organizationsIds = parsedInput.data; + const notificationChannelId = parsedInput.id; + + const notificationChannel = await db.query.notificationChannel.findFirst({ + where: eq(drizzleDb.schemas.notificationChannel.id, 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: notificationChannelId + }); + } + } + if (organizationsToRemove.length > 0) { + await db.delete(drizzleDb.schemas.organizationNotificationChannel).where(and(inArray(drizzleDb.schemas.organizationNotificationChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationNotificationChannel.notificationChannelId,notificationChannelId))).execute(); + + } + + return { + success: true, + value: null, + actionSuccess: { + message: "Notification channel organizations has been successfully updated.", + messageParams: {notificationChannelId: 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"}, + }, + }; + } + }); + + +export const updateStorageChannelsOrganizationAction = userAction + .schema( + z.object({ + data: z.array(z.string()), + id: z.string(), + }) + ) + .action(async ({parsedInput, ctx}): Promise> => { + try { + const organizationsIds = parsedInput.data; + const storageChannelId = parsedInput.id; + + const storageChannel = await db.query.storageChannel.findFirst({ + where: eq(drizzleDb.schemas.storageChannel.id, storageChannelId), + with: { + organizations: true, + } + }) as StorageChannelWith; + + + if (!storageChannel) { + return { + success: false, + actionError: { + message: "Storage channel not found.", + status: 404, + cause: "not_found", + }, + }; + } + + const existingItemIds = storageChannel.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.organizationStorageChannel).values({ + organizationId: organizationToAdd, + storageChannelId: storageChannelId + }); + } + } + + if (organizationsToRemove.length > 0) { + await db.delete(drizzleDb.schemas.organizationStorageChannel).where(and(inArray(drizzleDb.schemas.organizationStorageChannel.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationStorageChannel.storageChannelId, storageChannelId))).execute(); + + } + + return { + success: true, + value: null, + actionSuccess: { + message: "Storage channel organizations has been successfully updated.", + messageParams: {storageChannelId: storageChannelId}, + }, + }; + } catch (error) { + console.error("Error updating storage channel:", error); + return { + success: false, + actionError: { + message: "Failed to update storage channel.", + status: 500, + cause: "server_error", + messageParams: {message: "Error updating the storage channel"}, + }, + }; + } + }); diff --git a/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema.ts b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema.ts new file mode 100644 index 00000000..3146c5d7 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/organization/channels-organization.schema.ts @@ -0,0 +1,7 @@ +import {z} from "zod"; + +export const ChannelsOrganizationSchema = z.object({ + organizations: z.array(z.string()) +}); + +export type ChannelsOrganizationType = z.infer; diff --git a/src/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section.tsx b/src/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section.tsx deleted file mode 100644 index f3d6208c..00000000 --- a/src/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"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"; -import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal"; - -type NotificationChannelsSectionProps = { - notificationChannels: NotificationChannelWith[] - organizations: OrganizationWithMembers[] -} - -export const NotificationChannelsSection = ({ - organizations, - notificationChannels - }: NotificationChannelsSectionProps) => { - - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - - const hasNotifiers = notificationChannels.length > 0; - - return ( -
- - {hasNotifiers ? ( -
- - -
- ) : ( - { - setIsAddModalOpen(true) - }} - className="h-full" - /> - )} -
- ); -} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action.ts b/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action.ts deleted file mode 100644 index 395c685e..00000000 --- a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.action.ts +++ /dev/null @@ -1,79 +0,0 @@ -"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> => { - 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"}, - }, - }; - } - }); diff --git a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema.ts b/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema.ts deleted file mode 100644 index efc13c62..00000000 --- a/src/components/wrappers/dashboard/admin/notifications/channels/organization/notification-channels-organization.schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {z} from "zod"; - -export const NotificationChannelsOrganizationSchema = z.object({ - organizations: z.array(z.string()) -}); - -export type NotificationChannelsOrganizationType = z.infer; diff --git a/src/components/wrappers/dashboard/admin/notifications/helpers.tsx b/src/components/wrappers/dashboard/admin/notifications/helpers.tsx deleted file mode 100644 index cdaea432..00000000 --- a/src/components/wrappers/dashboard/admin/notifications/helpers.tsx +++ /dev/null @@ -1,487 +0,0 @@ -import {Mail} from "lucide-react"; -import type { SVGProps } from 'react'; - -export const getNotificationChannelIcon = (type: string) => { - const Icon = notificationTypes.find((t) => t.value === type)?.icon - return Icon ? : null -} - - -export const notificationTypes = [ - {value: "smtp", label: "Email", icon: Mail}, - {value: "slack", label: "Slack", icon: SlackIcon}, - {value: "discord", label: "Discord", icon: DiscordIcon}, - {value: "telegram", label: "Telegram", icon: TelegramIcon}, - {value: "gotify", label: "Gotify", icon: GotifyIcon}, - {value: "ntfy", label: "ntfy.sh", icon: NtfyIcon}, - {value: "webhook", label: "Webhook", icon: WebhookIcon}, -] - - -export function SlackIcon(props: SVGProps) { - return (); -} - -export function DiscordIcon(props: SVGProps) { - return (); -} - -export function TelegramIcon(props: SVGProps) { - return (); -} - - -export function NtfyIcon(props: SVGProps) { - - return ( - - - - - - - - - - - ) - -} - - -export function GotifyIcon(props: SVGProps) { - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} - -export function WebhookIcon(props: SVGProps) { - return ( - - - - ) -} - diff --git a/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx b/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx index 3ae7de36..4f44b7cd 100644 --- a/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx +++ b/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx @@ -2,11 +2,11 @@ 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"; +import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; export function notificationLogsColumns(): ColumnDef[] { @@ -33,7 +33,7 @@ export function notificationLogsColumns(): ColumnDef
- {getNotificationChannelIcon(channel?.provider ?? "")} + {getChannelIcon(channel?.provider ?? "")}
{channel?.name} 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 deleted file mode 100644 index b8bff033..00000000 --- a/src/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"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, NotificationChannelWith} 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"; -import {useState} from "react"; -import {Organization, OrganizationWithMembers} from "@/db/schema/03_organization"; - -export type EditNotifierButtonProps = { - notificationChannel: NotificationChannelWith; - organization?: OrganizationWithMembers; - organizations?: OrganizationWithMembers[]; - adminView?: boolean; -}; - -export const EditNotifierButton = ({ - organizations, - adminView = false, - notificationChannel, - organization - }: EditNotifierButtonProps) => { - const router = useRouter(); - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - - - 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-form/notifier-form.schema.ts b/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts deleted file mode 100644 index d5988ca1..00000000 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-form.schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -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(["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"], { - required_error: "Provider is required", - }), - - config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(), - - - enabled: z.boolean().default(true), - -}); - -export type NotificationChannelFormType = z.infer; 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 deleted file mode 100644 index 0070eb0f..00000000 --- a/src/components/wrappers/dashboard/common/notifier/notifier-form/notifier-test-channel-button.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"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"; -import {Send} from "lucide-react"; -import {toast} from "sonner"; -import {useIsMobile} from "@/hooks/use-mobile"; -import {cn} from "@/lib/utils"; - -type NotifierTestChannelButtonProps = { - notificationChannel: NotificationChannel; - organizationId?: string; -} - -export const NotifierTestChannelButton = ({notificationChannel, organizationId}: NotifierTestChannelButtonProps) => { - - const isMobile = useIsMobile() - const mutation = useMutation({ - mutationFn: async () => { - - const payload: EventPayload = { - title: 'Test Channel', - message: `We are testing channel ${notificationChannel.name}`, - level: 'info', - // data: {host: 'db-prod-01', error: 'connection timeout'}, - }; - - const result = await dispatchNotification(payload, undefined, notificationChannel.id, organizationId); - - if (result.success) { - toast.success(result.message); - } else { - toast.error("An error occurred while testing the notification channel"); - } - }, - }); - - - return ( - - ) -} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx index 6e21a381..be9f7ed4 100644 --- a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx +++ b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx @@ -7,7 +7,7 @@ import { Layers, ChartArea, ShieldHalf, - Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks + Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse } from "lucide-react"; import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar"; import {authClient, useSession} from "@/lib/auth/auth-client"; @@ -80,6 +80,16 @@ export const SidebarMenuCustomMain = () => { { title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item" }, ], }, + { + title: "Storages", + url: "/storages", + icon: Warehouse, + details: true, + type: "collapse", + submenu: [ + { title: "Channels", url: "/storages/channels", icon: Blocks, type: "item" }, + ], + }, { title: "Access management", url: "/admin", 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 index b5654046..db1e3bf2 100644 --- 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 @@ -1,11 +1,11 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization"; 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"; import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import {useState} from "react"; import {cn} from "@/lib/utils"; +import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; +import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card"; export type OrganizationNotifiersTabProps = { organization: OrganizationWithMembers; @@ -19,7 +19,7 @@ export const OrganizationNotifiersTab = ({ const [isAddModalOpen, setIsAddModalOpen] = useState(false); const hasNotifiers = notificationChannels.length > 0; - + const kind="notification" return (
@@ -29,7 +29,13 @@ export const OrganizationNotifiersTab = ({ Notification Settings
- */} +
) : ( diff --git a/src/db/index.ts b/src/db/index.ts index 2715ab6e..9e69f957 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -12,6 +12,7 @@ import * as notificationChannel from "./schema/09_notification-channel"; import * as organizationNotificationChannel from "./schema/09_notification-channel"; import * as alertPolicy from "./schema/10_alert-policy"; import * as notificationLog from "./schema/11_notification-log"; +import * as storageChannel from "./schema/12_storage-channel"; import {Pool} from "pg"; @@ -40,7 +41,8 @@ export const schemas = { ...notificationChannel, ...organizationNotificationChannel, ...alertPolicy, - ...notificationLog + ...notificationLog, + ...storageChannel }; export const db = drizzle({ diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 8a1d250a..e47cf140 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1767782077775, "tag": "0019_overjoyed_butterfly", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1768248015695, + "tag": "0020_thankful_sunspot", + "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 cdca046f..6772e6bc 100644 --- a/src/db/schema/03_organization.ts +++ b/src/db/schema/03_organization.ts @@ -8,6 +8,7 @@ import {member, OrganizationMember} from "@/db/schema/04_member"; import {User} from "@/db/schema/02_user"; import {timestamps} from "@/db/schema/00_common"; import {NotificationChannel, organizationNotificationChannel} from "@/db/schema/09_notification-channel"; +import {organizationStorageChannel, StorageChannel} from "@/db/schema/12_storage-channel"; export const organization = pgTable("organization", { id: uuid("id").defaultRandom().primaryKey(), @@ -24,6 +25,7 @@ export const organizationRelations = relations(organization, ({many}) => ({ invitations: many(invitation), projects: many(project), notificationChannels: many(organizationNotificationChannel), + storageChannels: many(organizationStorageChannel), })); @@ -48,5 +50,6 @@ export type OrganizationWith = Organization & { members?: MemberWithUser[] | null; invitations?: OrganizationInvitation[] | null; notificationChannels?: NotificationChannel[] | null; + storageChannels?: StorageChannel[] | null; projects?: Project[] | null; }; diff --git a/src/db/schema/12_storage-channel.ts b/src/db/schema/12_storage-channel.ts new file mode 100644 index 00000000..1ca971b7 --- /dev/null +++ b/src/db/schema/12_storage-channel.ts @@ -0,0 +1,58 @@ +import {boolean, jsonb, pgEnum, pgTable, 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 providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3']); + +export const storageChannel = pgTable('storage_channel', { + id: uuid("id").defaultRandom().primaryKey(), + provider: providerStorageKindEnum('provider').notNull(), + name: varchar('name', {length: 255}).notNull(), + config: jsonb('config').notNull(), + enabled: boolean('enabled').default(false).notNull(), + ...timestamps +}); + +export const organizationStorageChannel = pgTable( + "organization_storage_channels", + { + organizationId: uuid('organization_id') + .notNull() + .references(() => organization.id, {onDelete: 'cascade'}), + storageChannelId: uuid('storage_channel_id') + .notNull() + .references(() => storageChannel.id, {onDelete: 'cascade'}), + }, + (t) => [unique().on(t.organizationId, t.storageChannelId)] +); + + +export const storageChannelRelations = relations(storageChannel, ({many}) => ({ + organizations: many(organizationStorageChannel), +})); + +export const organizationStorageChannelRelations = relations(organizationStorageChannel, ({one}) => ({ + organization: one(organization, { + fields: [organizationStorageChannel.organizationId], + references: [organization.id], + }), + storageChannel: one(storageChannel, { + fields: [organizationStorageChannel.storageChannelId], + references: [storageChannel.id], + }), +})); + +export const storageChannelSchema = createSelectSchema(storageChannel); +export type StorageChannel = z.infer; + + +export type StorageChannelWith = StorageChannel & { + organizations: { + organizationId: string; + storageChannelId: string; + }[]; +}; \ No newline at end of file diff --git a/src/features/storages/dispatch.ts b/src/features/storages/dispatch.ts new file mode 100644 index 00000000..49e027be --- /dev/null +++ b/src/features/storages/dispatch.ts @@ -0,0 +1,129 @@ +"use server"; +import {eq} from "drizzle-orm"; +import {dispatchViaProvider} from "./providers"; +import type {EventPayload, DispatchResult, EventKind} from "./types"; +import * as drizzleDb from "@/db"; +import {db} from "@/db"; +import {notificationLog} from "@/db/schema/11_notification-log"; +import {NotificationChannel} from "@/db/schema/09_notification-channel"; +import {Json} from "drizzle-zod"; + +export async function dispatchStorage( + payload: EventPayload, + policyId?: string, + channelId?: string, + organizationId?: string +): Promise { + try { + let channel: NotificationChannel | null = null; + + if (policyId) { + const policyDb = await db.query.alertPolicy.findFirst({ + where: eq(drizzleDb.schemas.alertPolicy.id, policyId), + with: { + notificationChannel: true + }, + }); + + if (!policyDb || !policyDb.notificationChannel) { + return { + success: false, + channelId: "", + provider: null, + error: "Policy or associated channel not found", + }; + } + + if (!policyDb.enabled || !policyDb.notificationChannel.enabled) { + return { + success: false, + channelId: policyDb.notificationChannel.id, + provider: policyDb.notificationChannel.provider as any, + error: "Policy or channel is disabled", + }; + } + + channel = { + ...policyDb.notificationChannel, + config: policyDb.notificationChannel.config as Json, + }; + } + + if (channelId) { + const fetchedChannel = await db.query.notificationChannel.findFirst({ + where: eq(drizzleDb.schemas.notificationChannel.id, channelId), + }); + + if (!fetchedChannel) { + return { + success: false, + channelId: channelId, + provider: null, + error: "Channel not found", + }; + } + + channel = { + ...fetchedChannel, + config: fetchedChannel.config as Json, + }; + } + + if (!channel) { + return { + success: false, + channelId: channelId || "", + provider: null, + error: "No valid channel to dispatch notification", + }; + } + + + if (!channel.enabled) { + return { + success: false, + channelId: channelId || "", + provider: null, + error: "Channel not active", + }; + } + + const result = await dispatchViaProvider( + channel.provider, + channel.config, + {...payload, timestamp: payload.timestamp || new Date()}, + channel.id + ); + + const [log] = await db + .insert(notificationLog) + .values({ + channelId: channel.id, + policyId: policyId || null, + organizationId: organizationId || null, + + provider: channel.provider, + providerName: channel.name, + event: payload.event as EventKind, + + title: payload.title, + message: payload.message, + level: payload.level, + payload: payload.data || null, + success: result.success, + error: result.success ? null : result.error, + providerResponse: result.response || null, + }) + .returning({id: notificationLog.id}); + + return {...result, channelId: channel.id}; + + } catch (err: any) { + return { + success: false, + channelId: channelId || "", + provider: null, + error: err?.message || "Unexpected error during dispatch", + }; + } +} From 67a89a12acd337c2195958f3065365690264750a Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Mon, 12 Jan 2026 21:16:57 +0100 Subject: [PATCH 02/24] feat: backend storage --- src/db/migrations/0020_thankful_sunspot.sql | 20 + src/db/migrations/meta/0020_snapshot.json | 2060 +++++++++++++++++++ 2 files changed, 2080 insertions(+) create mode 100644 src/db/migrations/0020_thankful_sunspot.sql create mode 100644 src/db/migrations/meta/0020_snapshot.json diff --git a/src/db/migrations/0020_thankful_sunspot.sql b/src/db/migrations/0020_thankful_sunspot.sql new file mode 100644 index 00000000..4fd1c3e4 --- /dev/null +++ b/src/db/migrations/0020_thankful_sunspot.sql @@ -0,0 +1,20 @@ +CREATE TYPE "public"."provider_storage_kind" AS ENUM('local', 's3');--> statement-breakpoint +CREATE TABLE "organization_storage_channels" ( + "organization_id" uuid NOT NULL, + "storage_channel_id" uuid NOT NULL, + CONSTRAINT "organization_storage_channels_organization_id_storage_channel_id_unique" UNIQUE("organization_id","storage_channel_id") +); +--> statement-breakpoint +CREATE TABLE "storage_channel" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "provider" "provider_storage_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 +ALTER TABLE "organization_storage_channels" ADD CONSTRAINT "organization_storage_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_storage_channels" ADD CONSTRAINT "organization_storage_channels_storage_channel_id_storage_channel_id_fk" FOREIGN KEY ("storage_channel_id") REFERENCES "public"."storage_channel"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/src/db/migrations/meta/0020_snapshot.json b/src/db/migrations/meta/0020_snapshot.json new file mode 100644 index 00000000..92d24f60 --- /dev/null +++ b/src/db/migrations/meta/0020_snapshot.json @@ -0,0 +1,2060 @@ +{ + "id": "b6099494-2995-4a3a-ba81-2fcf1d82551a", + "prevId": "b29ada25-33dd-4a7d-b2b0-9b17743bfe55", + "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.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credentialId": { + "name": "credentialId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backedUp": { + "name": "backedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "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": { + "passkey_userId_user_id_fk": { + "name": "passkey_userId_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "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.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "theme": { + "name": "theme", + "type": "user_themes", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "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 + }, + "lastChangedPasswordAt": { + "name": "lastChangedPasswordAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "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": { + "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 + }, + "file_size": { + "name": "file_size", + "type": "integer", + "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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "public.alert_policy": { + "name": "alert_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "event_kind[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "alert_policy_notification_channel_id_notification_channel_id_fk": { + "name": "alert_policy_notification_channel_id_notification_channel_id_fk", + "tableFrom": "alert_policy", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_policy_database_id_databases_id_fk": { + "name": "alert_policy_database_id_databases_id_fk", + "tableFrom": "alert_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_log": { + "name": "notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_response": { + "name": "provider_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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_storage_channels": { + "name": "organization_storage_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_storage_channels_organization_id_organization_id_fk": { + "name": "organization_storage_channels_organization_id_organization_id_fk", + "tableFrom": "organization_storage_channels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_storage_channels_storage_channel_id_storage_channel_id_fk": { + "name": "organization_storage_channels_storage_channel_id_storage_channel_id_fk", + "tableFrom": "organization_storage_channels", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_storage_channels_organization_id_storage_channel_id_unique": { + "name": "organization_storage_channels_organization_id_storage_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "storage_channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_channel": { + "name": "storage_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "provider_storage_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 + } + }, + "enums": { + "public.user_themes": { + "name": "user_themes", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + }, + "public.retention_policy_type": { + "name": "retention_policy_type", + "schema": "public", + "values": [ + "count", + "days", + "gfs" + ] + }, + "public.provider_kind": { + "name": "provider_kind", + "schema": "public", + "values": [ + "slack", + "smtp", + "discord", + "telegram", + "gotify", + "ntfy", + "webhook" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.provider_storage_kind": { + "name": "provider_storage_kind", + "schema": "public", + "values": [ + "local", + "s3" + ] + }, + "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 From d8e9e2dac3bc7340b9ddc27be44bdeb87788068f Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 13 Jan 2026 22:22:01 +0100 Subject: [PATCH 03/24] feat: Working on backend storage providers. --- .../providers/storages/forms/s3.form.tsx | 70 + .../admin/channels/helpers/common.tsx | 5 + .../admin/channels/helpers/storage.tsx | 15 +- .../alert-policy/alert-policy-form.tsx | 6 +- src/db/migrations/0021_soft_blockbuster.sql | 12 + src/db/migrations/meta/0021_snapshot.json | 2145 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema/07_database.ts | 9 +- src/db/schema/13_storage-policy.ts | 33 + src/features/storages/dispatch.ts | 118 +- src/features/storages/helpers.ts | 10 + src/features/storages/providers/index.ts | 54 + src/features/storages/providers/local.ts | 69 + src/features/storages/types.ts | 39 + src/utils/init.ts | 22 + 15 files changed, 2506 insertions(+), 108 deletions(-) create mode 100644 src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx create mode 100644 src/db/migrations/0021_soft_blockbuster.sql create mode 100644 src/db/migrations/meta/0021_snapshot.json create mode 100644 src/db/schema/13_storage-policy.ts create mode 100644 src/features/storages/helpers.ts create mode 100644 src/features/storages/providers/index.ts create mode 100644 src/features/storages/providers/local.ts create mode 100644 src/features/storages/types.ts diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx new file mode 100644 index 00000000..01406b30 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx @@ -0,0 +1,70 @@ +import {UseFormReturn} from "react-hook-form"; +import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; +import {Input} from "@/components/ui/input"; +import {Separator} from "@/components/ui/separator"; +import {PasswordInput} from "@/components/ui/password-input"; + + +type StorageS3FormProps = { + form: UseFormReturn +} + +export const StorageS3Form = ({form}: StorageS3FormProps) => { + return ( + <> + + ( + + Endpoind URL + + + + + + )} + /> + ( + + Access Key + + + + + + )} + /> + ( + + Secret Key + + + + + + )} + /> + ( + + Bucket name + + + + + + )} + /> + + ) +} diff --git a/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx b/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx index e1f607a2..966c4ad3 100644 --- a/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx +++ b/src/components/wrappers/dashboard/admin/channels/helpers/common.tsx @@ -27,6 +27,9 @@ import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react"; import {LucideProps} from "lucide-react"; +import { + StorageS3Form +} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form"; export type ChannelKind = "notification" | "storage"; @@ -80,6 +83,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet return ; case "webhook": return ; + case "s3": + return case "local": return <> default: diff --git a/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx b/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx index f1d05f92..220b2310 100644 --- a/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx +++ b/src/components/wrappers/dashboard/admin/channels/helpers/storage.tsx @@ -1,6 +1,17 @@ import {Server} from "lucide-react"; +import type {SVGProps} from "react"; export const storageTypes = [ {value: "local", label: "Local", icon: Server}, - {value: "s3", label: "s3", icon: Server}, -] \ No newline at end of file + {value: "s3", label: "s3", icon: S3Icon}, +] + +export function S3Icon(props: SVGProps) { + return ( + + + + ); +} + diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx b/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx index 573e7b1d..8995ac2a 100644 --- a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx +++ b/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx @@ -21,10 +21,10 @@ import { } from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.action"; import {useRouter} from "next/navigation"; import {Switch} from "@/components/ui/switch"; -import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers"; import {Card} from "@/components/ui/card"; import Link from "next/link"; import {useIsMobile} from "@/hooks/use-mobile"; +import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; type AlertPolicyFormProps = { onSuccess?: () => void; @@ -239,7 +239,7 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, {selectedChannel && (
- {getNotificationChannelIcon(selectedChannel.provider)} + {getChannelIcon(selectedChannel.provider)}
{selectedChannel.name} @@ -257,7 +257,7 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
- {getNotificationChannelIcon(channel.provider)} + {getChannelIcon(channel.provider)}
{channel.name} diff --git a/src/db/migrations/0021_soft_blockbuster.sql b/src/db/migrations/0021_soft_blockbuster.sql new file mode 100644 index 00000000..4d2605b0 --- /dev/null +++ b/src/db/migrations/0021_soft_blockbuster.sql @@ -0,0 +1,12 @@ +CREATE TABLE "storage_policy" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "storage_channel_id" uuid NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "database_id" uuid NOT NULL, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "deleted_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "storage_policy" ADD CONSTRAINT "storage_policy_storage_channel_id_storage_channel_id_fk" FOREIGN KEY ("storage_channel_id") REFERENCES "public"."storage_channel"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "storage_policy" ADD CONSTRAINT "storage_policy_database_id_databases_id_fk" FOREIGN KEY ("database_id") REFERENCES "public"."databases"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/src/db/migrations/meta/0021_snapshot.json b/src/db/migrations/meta/0021_snapshot.json new file mode 100644 index 00000000..cc2d4d5b --- /dev/null +++ b/src/db/migrations/meta/0021_snapshot.json @@ -0,0 +1,2145 @@ +{ + "id": "d5d260b2-43f3-4283-9c20-ba40c79e79cd", + "prevId": "b6099494-2995-4a3a-ba81-2fcf1d82551a", + "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.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credentialId": { + "name": "credentialId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backedUp": { + "name": "backedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "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": { + "passkey_userId_user_id_fk": { + "name": "passkey_userId_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "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.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "theme": { + "name": "theme", + "type": "user_themes", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "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 + }, + "lastChangedPasswordAt": { + "name": "lastChangedPasswordAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "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": { + "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 + }, + "file_size": { + "name": "file_size", + "type": "integer", + "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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "public.alert_policy": { + "name": "alert_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "event_kind[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "alert_policy_notification_channel_id_notification_channel_id_fk": { + "name": "alert_policy_notification_channel_id_notification_channel_id_fk", + "tableFrom": "alert_policy", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_policy_database_id_databases_id_fk": { + "name": "alert_policy_database_id_databases_id_fk", + "tableFrom": "alert_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_log": { + "name": "notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_response": { + "name": "provider_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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_storage_channels": { + "name": "organization_storage_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_storage_channels_organization_id_organization_id_fk": { + "name": "organization_storage_channels_organization_id_organization_id_fk", + "tableFrom": "organization_storage_channels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_storage_channels_storage_channel_id_storage_channel_id_fk": { + "name": "organization_storage_channels_storage_channel_id_storage_channel_id_fk", + "tableFrom": "organization_storage_channels", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_storage_channels_organization_id_storage_channel_id_unique": { + "name": "organization_storage_channels_organization_id_storage_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "storage_channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_channel": { + "name": "storage_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "provider_storage_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.storage_policy": { + "name": "storage_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "storage_policy_storage_channel_id_storage_channel_id_fk": { + "name": "storage_policy_storage_channel_id_storage_channel_id_fk", + "tableFrom": "storage_policy", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "storage_policy_database_id_databases_id_fk": { + "name": "storage_policy_database_id_databases_id_fk", + "tableFrom": "storage_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_themes": { + "name": "user_themes", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + }, + "public.retention_policy_type": { + "name": "retention_policy_type", + "schema": "public", + "values": [ + "count", + "days", + "gfs" + ] + }, + "public.provider_kind": { + "name": "provider_kind", + "schema": "public", + "values": [ + "slack", + "smtp", + "discord", + "telegram", + "gotify", + "ntfy", + "webhook" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.provider_storage_kind": { + "name": "provider_storage_kind", + "schema": "public", + "values": [ + "local", + "s3" + ] + }, + "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 e47cf140..b51f73ac 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1768248015695, "tag": "0020_thankful_sunspot", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1768337434929, + "tag": "0021_soft_blockbuster", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/07_database.ts b/src/db/schema/07_database.ts index f8400090..a15e8300 100644 --- a/src/db/schema/07_database.ts +++ b/src/db/schema/07_database.ts @@ -1,4 +1,4 @@ -import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum, uniqueIndex} from "drizzle-orm/pg-core"; +import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum} from "drizzle-orm/pg-core"; import {Agent, agent} from "./08_agent"; import {Project, project} from "./06_project"; import {relations} from "drizzle-orm"; @@ -6,11 +6,8 @@ import {dbmsEnum, statusEnum} from "./types"; import {createSelectSchema} from "drizzle-zod"; import {z} from "zod"; import {timestamps} from "@/db/schema/00_common"; -import {member} from "@/db/schema/04_member"; -import {invitation} from "@/db/schema/05_invitation"; -import {organizationNotificationChannel} from "@/db/schema/09_notification-channel"; -import {organization} from "@/db/schema/03_organization"; import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy"; +import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy"; export const database = pgTable("databases", { id: uuid("id").primaryKey().defaultRandom(), @@ -84,6 +81,7 @@ export const databaseRelations = relations(database, ({one, many}) => ({ backups: many(backup), restorations: many(restoration), alertPolicies: many(alertPolicy), + storagePolicies: many(storagePolicy), })); export const backupRelations = relations(backup, ({one, many}) => ({ @@ -124,5 +122,6 @@ export type DatabaseWith = Database & { restorations?: Restoration[] | null; retentionPolicy?: RetentionPolicy | null; alertPolicies?: AlertPolicy[] | null; + storagePolicies?: StoragePolicy[] | null; }; diff --git a/src/db/schema/13_storage-policy.ts b/src/db/schema/13_storage-policy.ts new file mode 100644 index 00000000..d2fc0f55 --- /dev/null +++ b/src/db/schema/13_storage-policy.ts @@ -0,0 +1,33 @@ +import {boolean, pgTable, uuid} from "drizzle-orm/pg-core"; +import {timestamps} from "@/db/schema/00_common"; +import {relations} from "drizzle-orm"; +import {database} from "@/db/schema/07_database"; +import {createSelectSchema} from "drizzle-zod"; +import {z} from "zod"; +import {storageChannel} from "@/db/schema/12_storage-channel"; + +export const storagePolicy = pgTable('storage_policy', { + id: uuid('id').defaultRandom().primaryKey(), + storageChannelId: uuid('storage_channel_id') + .notNull() + .references(() => storageChannel.id, {onDelete: 'cascade'}), + enabled: boolean('enabled').default(true).notNull(), + databaseId: uuid('database_id') + .notNull() + .references(() => database.id, {onDelete: 'cascade'}), + ...timestamps +}); + +export const storagePolicyRelations = relations(storagePolicy, ({one}) => ({ + storageChannel: one(storageChannel, { + fields: [storagePolicy.storageChannelId], + references: [storageChannel.id], + }), + database: one(database, { + fields: [storagePolicy.databaseId], + references: [database.id], + }), +})); + +export const storagePolicySchema = createSelectSchema(storagePolicy); +export type StoragePolicy = z.infer; diff --git a/src/features/storages/dispatch.ts b/src/features/storages/dispatch.ts index 49e027be..5e58d665 100644 --- a/src/features/storages/dispatch.ts +++ b/src/features/storages/dispatch.ts @@ -1,129 +1,51 @@ "use server"; -import {eq} from "drizzle-orm"; -import {dispatchViaProvider} from "./providers"; -import type {EventPayload, DispatchResult, EventKind} from "./types"; -import * as drizzleDb from "@/db"; -import {db} from "@/db"; -import {notificationLog} from "@/db/schema/11_notification-log"; -import {NotificationChannel} from "@/db/schema/09_notification-channel"; -import {Json} from "drizzle-zod"; + +import {eq} from 'drizzle-orm'; +import * as drizzleDb from '@/db'; +import {db} from '@/db'; +import type {StorageInput, StorageProviderKind, StorageResult,} from './types'; +import {dispatchViaProvider} from "@/features/storages/providers"; export async function dispatchStorage( - payload: EventPayload, + input: StorageInput, policyId?: string, channelId?: string, organizationId?: string -): Promise { +): Promise { try { - let channel: NotificationChannel | null = null; - - if (policyId) { - const policyDb = await db.query.alertPolicy.findFirst({ - where: eq(drizzleDb.schemas.alertPolicy.id, policyId), - with: { - notificationChannel: true - }, - }); - - if (!policyDb || !policyDb.notificationChannel) { - return { - success: false, - channelId: "", - provider: null, - error: "Policy or associated channel not found", - }; - } - - if (!policyDb.enabled || !policyDb.notificationChannel.enabled) { - return { - success: false, - channelId: policyDb.notificationChannel.id, - provider: policyDb.notificationChannel.provider as any, - error: "Policy or channel is disabled", - }; - } - - channel = { - ...policyDb.notificationChannel, - config: policyDb.notificationChannel.config as Json, - }; - } - - if (channelId) { - const fetchedChannel = await db.query.notificationChannel.findFirst({ - where: eq(drizzleDb.schemas.notificationChannel.id, channelId), - }); + if (!channelId) { - if (!fetchedChannel) { - return { - success: false, - channelId: channelId, - provider: null, - error: "Channel not found", - }; - } - channel = { - ...fetchedChannel, - config: fetchedChannel.config as Json, - }; - } - if (!channel) { return { success: false, - channelId: channelId || "", provider: null, - error: "No valid channel to dispatch notification", + error: 'No storage channel provided', }; } + const channel = await db.query.storageChannel.findFirst({ + where: eq(drizzleDb.schemas.storageChannel.id, channelId), + }); - if (!channel.enabled) { + if (!channel || !channel.enabled) { return { success: false, - channelId: channelId || "", - provider: null, - error: "Channel not active", + provider: channel?.provider as StorageProviderKind, + error: 'Storage channel not found or disabled', }; } - const result = await dispatchViaProvider( - channel.provider, + return await dispatchViaProvider( + channel.provider as StorageProviderKind, channel.config, - {...payload, timestamp: payload.timestamp || new Date()}, - channel.id + input ); - - const [log] = await db - .insert(notificationLog) - .values({ - channelId: channel.id, - policyId: policyId || null, - organizationId: organizationId || null, - - provider: channel.provider, - providerName: channel.name, - event: payload.event as EventKind, - - title: payload.title, - message: payload.message, - level: payload.level, - payload: payload.data || null, - success: result.success, - error: result.success ? null : result.error, - providerResponse: result.response || null, - }) - .returning({id: notificationLog.id}); - - return {...result, channelId: channel.id}; - } catch (err: any) { return { success: false, - channelId: channelId || "", provider: null, - error: err?.message || "Unexpected error during dispatch", + error: err.message || 'Unexpected storage dispatch error', }; } } diff --git a/src/features/storages/helpers.ts b/src/features/storages/helpers.ts new file mode 100644 index 00000000..e6d03b6f --- /dev/null +++ b/src/features/storages/helpers.ts @@ -0,0 +1,10 @@ +import {DatabaseWith} from "@/db/schema/07_database"; +import {StorageChannel} from "@/db/schema/12_storage-channel"; +import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; + + +export async function storeFileBackup(database: DatabaseWith, file: Buffer) { + + + +} \ No newline at end of file diff --git a/src/features/storages/providers/index.ts b/src/features/storages/providers/index.ts new file mode 100644 index 00000000..f30a130c --- /dev/null +++ b/src/features/storages/providers/index.ts @@ -0,0 +1,54 @@ +import type { + StorageProviderKind, + StorageInput, + StorageResult, +} from '../types'; + +import {uploadLocal, getLocal, deleteLocal} from './local'; + +type ProviderHandler = { + upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise; + get: (config: any, input: StorageInput & { action: 'get' }) => Promise; + delete: (config: any, input: StorageInput & { action: 'delete' }) => Promise; +}; + +const handlers: Record = { + local: { + upload: uploadLocal, + get: getLocal, + delete: deleteLocal, + }, + // s3: { + // upload: uploadS3, + // get: getS3, + // delete: deleteS3, + // }, + // gcs: null as any, + // azure: null as any, +}; + +export async function dispatchViaProvider( + kind: StorageProviderKind, + config: any, + input: StorageInput +): Promise { + const provider = handlers[kind]; + + if (!provider) { + return { + success: false, + provider: kind, + error: `Unsupported storage provider: ${kind}`, + }; + } + + try { + return await provider[input.action](config, input as any); + } catch (err: any) { + return { + success: false, + provider: kind, + error: err.message || 'Storage provider error', + }; + } +} diff --git a/src/features/storages/providers/local.ts b/src/features/storages/providers/local.ts new file mode 100644 index 00000000..2357c7e2 --- /dev/null +++ b/src/features/storages/providers/local.ts @@ -0,0 +1,69 @@ +"use server" +import {mkdir, writeFile, unlink, readFile} from 'fs/promises'; +import path from 'path'; +import {StorageDeleteInput, StorageGetInput, StorageResult, StorageUploadInput} from '../types'; +import fs from "node:fs"; +import {getServerUrl} from "@/utils/get-server-url"; + +const BASE_DIR = "/private/uploads/files/"; + +export async function uploadLocal( + config: { baseDir?: string }, + input: { data: StorageUploadInput } +): Promise { + const base = config.baseDir || BASE_DIR; + const fullPath = path.join(process.cwd(), base, input.data.path); + + await mkdir(fullPath, {recursive: true}); + await writeFile(fullPath, input.data.file); + + return { + success: true, + provider: 'local', + url: path.join(fullPath), + }; +} + +export async function getLocal( + config: { baseDir?: string }, + input: { data: StorageGetInput } +): Promise { + const base = config.baseDir || BASE_DIR; + const filePath = path.join(base, input.data.path) + const fileName = path.basename(input.data.path); + const file = await readFile(filePath); + + + if (!fs.existsSync(filePath)) { + console.error("File not found at:", filePath); + return({ + success: false, + provider: 'local', + }); + } + const crypto = require("crypto"); + const baseUrl = getServerUrl(); + + const expiresAt = Date.now() + 60 * 1000; + const token = crypto.createHash("sha256").update(`${fileName}${expiresAt}`).digest("hex"); + + return { + success: true, + provider: 'local', + file: file, + url: `${baseUrl}/api/files/${fileName}?token=${token}&expires=${expiresAt}`, + }; +} + +export async function deleteLocal( + config: { baseDir?: string }, + input: { data: StorageDeleteInput } +): Promise { + const base = config.baseDir || BASE_DIR; + const fullPath = path.join(process.cwd(), base, input.data.path); + await unlink(fullPath); + return { + success: true, + provider: 'local', + }; +} diff --git a/src/features/storages/types.ts b/src/features/storages/types.ts new file mode 100644 index 00000000..c0c5ec81 --- /dev/null +++ b/src/features/storages/types.ts @@ -0,0 +1,39 @@ +export type StorageProviderKind = + | 'local' +// | 's3' + ; + +export type StorageAction = + | 'upload' + | 'get' + | 'delete'; + +export interface StorageUploadInput { + path: string; + file: Buffer | Uint8Array; + contentType?: string; +} + +export interface StorageGetInput { + path: string; + signedUrl?: boolean; + expiresInSeconds?: number; +} + +export interface StorageDeleteInput { + path: string; +} + +export type StorageInput = + | { action: 'upload'; data: StorageUploadInput } + | { action: 'get'; data: StorageGetInput } + | { action: 'delete'; data: StorageDeleteInput }; + +export interface StorageResult { + success: boolean; + provider: StorageProviderKind | null; + url?: string; + file?: Buffer; + error?: string; + response?: any; +} diff --git a/src/utils/init.ts b/src/utils/init.ts index f5b36782..e349bbbd 100644 --- a/src/utils/init.ts +++ b/src/utils/init.ts @@ -4,6 +4,9 @@ import {eq} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {retentionJob} from "@/lib/tasks"; import {generateRSAKeys} from "@/utils/rsa-keys"; +import {Provider} from "react"; +import type {ProviderKind} from "@/features/notifications/types"; +import {StorageProviderKind} from "@/features/storages/types"; export async function init() { @@ -47,6 +50,25 @@ async function createSettingsIfNotExist() { console.log("====Init Setting : Update ===="); await db.update(drizzleDb.schemas.setting).set(configSettings).where(eq(drizzleDb.schemas.setting.name, "system")); } + + const [existingLocalChannelStorage] = await db.select().from(drizzleDb.schemas.storageChannel).where(eq(drizzleDb.schemas.storageChannel.provider, "local")).limit(1); + + const localChannelValues = { + provider: "local" as StorageProviderKind, + enabled: true, + name: "System", + config: {} + } + + if (!existingLocalChannelStorage) { + console.log("====Local Storage : Create ===="); + await db.insert(drizzleDb.schemas.storageChannel).values(localChannelValues); + } else { + console.log("====Local Storage : Update ===="); + await db.update(drizzleDb.schemas.storageChannel).set(localChannelValues).where(eq(drizzleDb.schemas.storageChannel.provider, "local")); + } + + } async function createDefaultOrganization() { From 91f25bc25d782c57527f576ad5983a6c16b44f7e Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Wed, 14 Jan 2026 21:54:13 +0100 Subject: [PATCH 04/24] feat: Working on storage modal in database page in order to add multiple backend storages. Adding a system to manage independently channels from organizations and from system admin. --- .../(admin)/notifications/channels/page.tsx | 6 +- .../(admin)/storages/channels/page.tsx | 4 +- .../database/[databaseId]/page.tsx | 29 +- .../(organization)/settings/page.tsx | 3 + .../channel/channel-add-edit-modal.tsx | 4 - .../channel/channel-card/channel-card.tsx | 34 +- .../channel/channel-form/channel-form.tsx | 5 +- .../providers/notifications/action.ts | 1 + .../channel-form/providers/storages/action.ts | 3 +- .../alert-policy/alert-policy.action.ts | 185 -- .../alert-policy/alert-policy.schema.ts | 27 - .../policy-form.tsx} | 363 ++- .../policy-modal.tsx} | 46 +- .../database/channels-policy/policy.action.ts | 351 +++ .../database/channels-policy/policy.schema.ts | 27 + .../organization-storages-tab.tsx | 60 + .../organization/tabs/organization-tabs.tsx | 22 +- src/db/index.ts | 4 +- src/db/migrations/0022_purple_retro_girl.sql | 16 + src/db/migrations/0023_common_the_captain.sql | 2 + src/db/migrations/0024_lush_blindfold.sql | 2 + src/db/migrations/meta/0022_snapshot.json | 2258 ++++++++++++++++ src/db/migrations/meta/0023_snapshot.json | 2278 ++++++++++++++++ src/db/migrations/meta/0024_snapshot.json | 2298 +++++++++++++++++ src/db/migrations/meta/_journal.json | 21 + src/db/schema/07_database.ts | 3 +- src/db/schema/09_notification-channel.ts | 1 + src/db/schema/12_storage-channel.ts | 2 + src/db/schema/14_storage-backup.ts | 38 + src/db/services/notification-channel.ts | 2 + src/db/services/storage-channel.ts | 25 + src/lib/acl/organization-acl.ts | 2 + 32 files changed, 7662 insertions(+), 460 deletions(-) delete mode 100644 src/components/wrappers/dashboard/database/alert-policy/alert-policy.action.ts delete mode 100644 src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts rename src/components/wrappers/dashboard/database/{alert-policy/alert-policy-form.tsx => channels-policy/policy-form.tsx} (53%) rename src/components/wrappers/dashboard/database/{alert-policy/alert-policy-modal.tsx => channels-policy/policy-modal.tsx} (51%) create mode 100644 src/components/wrappers/dashboard/database/channels-policy/policy.action.ts create mode 100644 src/components/wrappers/dashboard/database/channels-policy/policy.schema.ts create mode 100644 src/components/wrappers/dashboard/organization/tabs/organization-notifiers-tab/organization-storages-tab.tsx create mode 100644 src/db/migrations/0022_purple_retro_girl.sql create mode 100644 src/db/migrations/0023_common_the_captain.sql create mode 100644 src/db/migrations/0024_lush_blindfold.sql create mode 100644 src/db/migrations/meta/0022_snapshot.json create mode 100644 src/db/migrations/meta/0023_snapshot.json create mode 100644 src/db/migrations/meta/0024_snapshot.json create mode 100644 src/db/schema/14_storage-backup.ts create mode 100644 src/db/services/storage-channel.ts diff --git a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx index 00f2e2e9..34001921 100644 --- a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx +++ b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx @@ -1,12 +1,8 @@ import {PageParams} from "@/types/next"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Metadata} from "next"; -import { - NotificationChannelsSection -} from "@/components/wrappers/dashboard/admin/notifications/channels/notification-channels-section"; import {db} from "@/db"; -import {notificationChannel, NotificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; -import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal"; +import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {desc, isNull} from "drizzle-orm"; import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section"; import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; diff --git a/app/(customer)/dashboard/(admin)/storages/channels/page.tsx b/app/(customer)/dashboard/(admin)/storages/channels/page.tsx index 898f1131..f3d0a5f8 100644 --- a/app/(customer)/dashboard/(admin)/storages/channels/page.tsx +++ b/app/(customer)/dashboard/(admin)/storages/channels/page.tsx @@ -1,10 +1,9 @@ import {PageParams} from "@/types/next"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Metadata} from "next"; -import {NotifierAddEditModal} from "@/components/wrappers/dashboard/common/notifier/notifier-add-edit-modal"; import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section"; import {db} from "@/db"; -import {desc, isNull} from "drizzle-orm"; +import {desc, isNotNull, isNull, not} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; @@ -19,6 +18,7 @@ export default async function RoutePage(props: PageParams<{}>) { with: { organizations: true }, + where: isNull(drizzleDb.schemas.storageChannel.organizationId), orderBy: desc(drizzleDb.schemas.storageChannel.createdAt) }) as StorageChannelWith[] diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index 52f8923e..b8d72a97 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -12,9 +12,11 @@ import {getOrganizationProjectDatabases} from "@/lib/services"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet"; import {capitalizeFirstLetter} from "@/utils/text"; -import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal"; import {getOrganizationChannels} from "@/db/services/notification-channel"; import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal"; +import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; +import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal"; +import {HardDrive, Megaphone} from "lucide-react"; export default async function RoutePage(props: PageParams<{ projectId: string; @@ -39,7 +41,8 @@ export default async function RoutePage(props: PageParams<{ with: { project: true, retentionPolicy: true, - alertPolicies: true + alertPolicies: true, + storagePolicies: true } }); @@ -87,6 +90,10 @@ export default async function RoutePage(props: PageParams<{ const organizationChannels = await getOrganizationChannels(organization.id); const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled); + const organizationStorageChannels = await getOrganizationStorageChannels(organization.id); + const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled); + + const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const isMember = activeMember?.role === "member"; @@ -106,8 +113,22 @@ export default async function RoutePage(props: PageParams<{ {/**/} - + {/**/} + } + channels={activeOrganizationChannels} + organizationId={organization.id} + /> + } + kind={"storage"} + channels={activeOrganizationStorageChannels} + organizationId={organization.id} + />
diff --git a/app/(customer)/dashboard/(organization)/settings/page.tsx b/app/(customer)/dashboard/(organization)/settings/page.tsx index 6974a927..cfc6db26 100644 --- a/app/(customer)/dashboard/(organization)/settings/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/page.tsx @@ -11,6 +11,7 @@ import {Metadata} from "next"; import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; import {getOrganizationChannels} from "@/db/services/notification-channel"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; +import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; export const metadata: Metadata = { title: "Settings", @@ -26,6 +27,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) { } const notificationChannels = await getOrganizationChannels(organization.id) + const storageChannels = await getOrganizationStorageChannels(organization.id) const permissions = computeOrganizationPermissions(activeMember); @@ -55,6 +57,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) { activeMember={activeMember} organization={organization} notificationChannels={notificationChannels} + storageChannels={storageChannels} /> diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx index 8e63dcb8..3506e52a 100644 --- a/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal.tsx @@ -74,7 +74,6 @@ export const ChannelAddEditModal = ({ } )} - e.preventDefault()}> {isCreate ? "Add" : "Edit"} {channelText} Channel @@ -82,11 +81,8 @@ export const ChannelAddEditModal = ({ Configure your {channelText.toLowerCase()} channel preferences. - -
{adminView ? - Configuration diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card.tsx index 7529e6da..efcb71f4 100644 --- a/src/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card.tsx @@ -25,9 +25,12 @@ export type ChannelCardProps = { export const ChannelCard = (props: ChannelCardProps) => { - const {data, organization, kind} = props; + const {data, organization, kind, adminView} = props; const isMobile = useIsMobile() + const isOwned = data.organizationId ? true : !organization; + const isLocalSystem = data.provider == "local"; + return (
@@ -38,7 +41,6 @@ export const ChannelCard = (props: ChannelCardProps) => {
-

{isMobile ? truncateWords(data.name, 2) : data.name}

@@ -49,18 +51,22 @@ export const ChannelCard = (props: ChannelCardProps) => {
{kind && (
- - + {(isOwned && !isLocalSystem) && ( + <> + + + + )}
)} diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx index 456cc449..a8bdd60d 100644 --- a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx @@ -94,16 +94,13 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind} }); const provider = form.watch("provider"); - - const channelTypes = kind == "notification" ? notificationTypes : storageTypes - const selectedProviderDetails = channelTypes.find(t => t.value === provider); if (isCreate && !provider) { return (
- {channelTypes.map((type) => { + {channelTypes.filter(p => p.value != "local").map((type) => { const Icon = type.icon; return ( > => { - try { - - const valuesToInsert = parsedInput.alertPolicies.map((policy) => ({ - databaseId: parsedInput.databaseId, - notificationChannelId: policy.notificationChannelId, - eventKinds: policy.eventKinds, - enabled: policy.enabled, - })); - - const insertedPolicies = await db - .insert(drizzleDb.schemas.alertPolicy) - .values(valuesToInsert) - .returning(); - - return { - success: true, - value: insertedPolicies, - actionSuccess: { - message: `Alert policies successfully added`, - }, - }; - - } catch (error) { - return { - success: false, - actionError: { - message: "Failed to add policies.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - }, - }; - } - }); - - - - -export const updateAlertPoliciesAction = userAction - .schema( - z.object({ - databaseId: z.string().min(1), - alertPolicies: z.array(AlertPolicySchema), - }) - ) - .action(async ({parsedInput}): Promise> => { - const {databaseId, alertPolicies} = parsedInput; - - try { - - const updatedPolicies = await db.transaction(async (tx) => { - const results: AlertPolicy[] = []; - for (const policy of alertPolicies) { - const {notificationChannelId, ...updateData} = policy; - - const updated = await tx - .update(drizzleDb.schemas.alertPolicy) - .set(withUpdatedAt({ - ...updateData, - })) - .where( - and( - eq(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelId), - eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId) - ) - ) - .returning(); - - if (updated[0]) { - results.push(updated[0]); - } - } - - return results; - }); - - if (updatedPolicies.length === 0) { - return { - success: false, - actionError: {message: "No policies were updated."}, - }; - } - - return { - success: true, - value: updatedPolicies, - actionSuccess: { - message: `Successfully updated ${updatedPolicies.length} alert policy(ies).`, - }, - }; - } catch (error) { - console.error("Update alert policies failed:", error); - return { - success: false, - actionError: { - message: "Failed to update alert policies.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - }, - }; - } - }); - - -export const deleteAlertPoliciesAction = userAction - .schema( - z.object({ - databaseId: z.string().min(1), - alertPolicies: z.array(AlertPolicySchema), - }) - ) - .action(async ({ parsedInput }): Promise> => { - const { databaseId, alertPolicies } = parsedInput; - - try { - - const notificationChannelIds = alertPolicies.map((alertPolicy) => alertPolicy.notificationChannelId); - - const policiesToDelete = await db - .select() - .from(drizzleDb.schemas.alertPolicy) - .where( - and( - eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId), - inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds) - ) - ); - - if (policiesToDelete.length === 0) { - return { - success: false, - actionError: { message: "No alert policies found to delete." }, - }; - } - - await db - .delete(drizzleDb.schemas.alertPolicy) - .where( - and( - eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId), - inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds) - ) - ); - - return { - success: true, - value: policiesToDelete, - actionSuccess: { - message: `Successfully deleted ${policiesToDelete.length} alert policy(ies).`, - }, - }; - } catch (error) { - console.error("Delete alert policies failed:", error); - return { - success: false, - actionError: { - message: "Failed to delete alert policies.", - status: 500, - cause: error instanceof Error ? error.message : "Unknown error", - }, - }; - } - }); \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts b/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts deleted file mode 100644 index 73a4b265..00000000 --- a/src/components/wrappers/dashboard/database/alert-policy/alert-policy.schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {z} from "zod"; - - -export const AlertPolicySchema = - z.object({ - notificationChannelId: z.string().min(1, "Please select a notification channel"), - eventKinds: z.enum(['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report']).array().nonempty(), - enabled: z.boolean().default(true), - } - ) - -export const AlertPoliciesSchema = z.object({ - alertPolicies: z.array(AlertPolicySchema) -}); - - -export type AlertPoliciesType = z.infer; -export type AlertPolicyType = z.infer; - - -export const EVENT_KIND_OPTIONS = [ - {label: "Error Backup", value: "error_backup"}, - {label: "Error Restore", value: "error_restore"}, - {label: "Success Restore", value: "success_restore"}, - {label: "Success Backup", value: "success_backup"}, - {label: "Weekly Report", value: "weekly_report"}, -]; \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx b/src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx similarity index 53% rename from src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx rename to src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx index 8995ac2a..73940aa5 100644 --- a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-form.tsx +++ b/src/components/wrappers/dashboard/database/channels-policy/policy-form.tsx @@ -1,12 +1,7 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {InfoIcon, Plus, Trash2} from "lucide-react"; import {useFieldArray} from "react-hook-form"; -import { - AlertPoliciesSchema, AlertPoliciesType, AlertPolicyType, - EVENT_KIND_OPTIONS -} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.schema"; import {DatabaseWith} from "@/db/schema/07_database"; -import {AlertPolicy} from "@/db/schema/10_alert-policy"; import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {Label} from "@/components/ui/label"; import {Button} from "@/components/ui/button"; @@ -15,192 +10,192 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/c import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select"; import {useMutation} from "@tanstack/react-query"; import {toast} from "sonner"; -import { - createAlertPoliciesAction, deleteAlertPoliciesAction, - updateAlertPoliciesAction -} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.action"; import {useRouter} from "next/navigation"; import {Switch} from "@/components/ui/switch"; import {Card} from "@/components/ui/card"; import Link from "next/link"; import {useIsMobile} from "@/hooks/use-mobile"; -import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; +import { + ChannelKind, + getChannelIcon, + getChannelTextBasedOnKind +} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; +import {StorageChannel} from "@/db/schema/12_storage-channel"; +import { + EVENT_KIND_OPTIONS, + PoliciesSchema, + PoliciesType, + PolicyType +} from "@/components/wrappers/dashboard/database/channels-policy/policy.schema"; +import { + createAlertPoliciesAction, createStoragePoliciesAction, deleteAlertPoliciesAction, deleteStoragePoliciesAction, + updateAlertPoliciesAction, updateStoragePoliciesAction +} from "@/components/wrappers/dashboard/database/channels-policy/policy.action"; -type AlertPolicyFormProps = { +type ChannelPoliciesFormProps = { onSuccess?: () => void; - notificationChannels: NotificationChannel[]; + channels: NotificationChannel[] | StorageChannel[]; organizationId: string; database: DatabaseWith; + kind: ChannelKind }; -export const AlertPolicyForm = ({database, notificationChannels, organizationId, onSuccess}: AlertPolicyFormProps) => { - const router = useRouter() - const isMobile = useIsMobile() - const organizationNotificationChannels = notificationChannels.map(channel => channel.id) ?? []; +export const ChannelPoliciesForm = ({ + database, + channels, + organizationId, + onSuccess, + kind + }: ChannelPoliciesFormProps) => { + const router = useRouter(); + const isMobile = useIsMobile(); + const channelText = getChannelTextBasedOnKind(kind); - const formattedAlertPoliciesList = (alertPolicies: AlertPolicy[]) => { - return alertPolicies.filter((alertPolicy) => organizationNotificationChannels.includes(alertPolicy.notificationChannelId)).map((alertPolicy) => ({ - notificationChannelId: alertPolicy.notificationChannelId, - eventKinds: alertPolicy.eventKinds, - enabled: alertPolicy.enabled - })); - }; + const organizationChannels = channels.map(c => c.id); - const form = useZodForm({ - schema: AlertPoliciesSchema, - defaultValues: { - alertPolicies: database.alertPolicies && database.alertPolicies.length > 0 ? formattedAlertPoliciesList(database.alertPolicies) : [], - }, - }); + const filterByChannel = ( + items: T[] | undefined | null, + channelKey: K + ): T[] => items?.filter(item => organizationChannels.includes(item[channelKey] as string)) ?? []; - const {fields, append, remove} = useFieldArray({ - control: form.control, - name: "alertPolicies", - }) + const formattedAlertPolicies = filterByChannel(database.alertPolicies, "notificationChannelId") + .map(({notificationChannelId, eventKinds, enabled}) => ({ + channelId: notificationChannelId, + eventKinds, + enabled + })); + const formattedStoragePolicies = filterByChannel(database.storagePolicies, "storageChannelId") + .map(({storageChannelId, enabled}) => ({ + channelId: storageChannelId, + enabled + })); - const addAlertPolicy = () => { - append({notificationChannelId: "", eventKinds: [], enabled: true}); - } + const defaultPolicies: PolicyType[] = + kind === "notification" + ? formattedAlertPolicies + : formattedStoragePolicies.map(({ channelId, enabled }) => ({ channelId, enabled })); - const removeAlertPolicy = (index: number) => { - remove(index) - } + const form = useZodForm({ + schema: PoliciesSchema, + defaultValues: { policies: defaultPolicies }, + context: { kind } + }); - const onCancel = () => { - form.reset() - onSuccess?.() - } + const {fields, append, remove} = useFieldArray({ control: form.control, name: "policies" }); + const addPolicy = () => append({channelId: "", eventKinds: [], enabled: true}); + const removePolicyHandler = (index: number) => remove(index); + const onCancel = () => { form.reset(); onSuccess?.(); }; const mutation = useMutation({ - mutationFn: async ({alertPolicies}: AlertPoliciesType) => { - const defaultFormatedAlertPolicies = formattedAlertPoliciesList(database?.alertPolicies ?? []); - - const alertPoliciesToAdd = alertPolicies?.filter( - (alertPolicy) => !defaultFormatedAlertPolicies.some((a) => a.notificationChannelId == alertPolicy.notificationChannelId) - ) ?? []; + mutationFn: async ({policies}: PoliciesType) => { + const payload = policies.map(p => kind === "notification" ? p : { ...p, eventKinds: undefined }); - const alertPoliciesToRemove = defaultFormatedAlertPolicies.filter( - (alertPolicy) => !alertPolicies?.some((v) => v.notificationChannelId === alertPolicy.notificationChannelId) - ) ?? []; - - const alertPoliciesToUpdate = alertPolicies?.filter((alertPolicy) => { - const existing = defaultFormatedAlertPolicies.find((a) => a.notificationChannelId === alertPolicy.notificationChannelId); + const policiesToAdd = payload.filter( + (policy) => !defaultPolicies.some((a) => a.channelId === policy.channelId) + ); + const policiesToRemove = defaultPolicies.filter( + (policy) => !payload.some((v) => v.channelId === policy.channelId) + ); + const policiesToUpdate = payload.filter((policy) => { + const existing = defaultPolicies.find((a) => a.channelId === policy.channelId); return existing && - (existing.eventKinds !== alertPolicy.eventKinds || existing.enabled !== alertPolicy.enabled); - }) ?? []; - + (existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled); + }); - const results = await Promise.allSettled([ - alertPoliciesToAdd.length > 0 - ? createAlertPoliciesAction({ - databaseId: database.id, - alertPolicies: alertPoliciesToAdd, - }) - : Promise.resolve(null), - alertPoliciesToUpdate.length > 0 - ? updateAlertPoliciesAction({ - databaseId: database.id, - alertPolicies: alertPoliciesToUpdate, - }) - : Promise.resolve(null), + console.log(policiesToUpdate); + console.log(policiesToAdd); - alertPoliciesToRemove.length > 0 - ? deleteAlertPoliciesAction({ - databaseId: database.id, - alertPolicies: alertPoliciesToRemove as AlertPolicyType[], - }) - : Promise.resolve(null), - ]); + const promises = kind === "notification" + ? [ + policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null, + policiesToUpdate.length > 0 ? await updateAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToUpdate}) : null, + policiesToRemove.length > 0 ? await deleteAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToRemove}) : null, + ] + : [ + policiesToAdd.length > 0 ? await createStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToAdd}) : null, + policiesToUpdate.length > 0 ? await updateStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToUpdate}) : null, + policiesToRemove.length > 0 ? await deleteStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToRemove}) : null, + ]; + const results = await Promise.allSettled(promises); const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); - if (rejected) { - throw new Error(rejected.reason?.message || "Network or server error"); - } + if (rejected) throw new Error(rejected.reason?.message || "Network or server error"); const failedActions = results .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") .map(r => r.value) - .filter((value): value is { data: { success: false; actionError: any } } => - value !== null && typeof value === "object" && value.data.success === false - ); - - - if (failedActions.length > 0) { - const firstError = failedActions[0].data.actionError; - const message = firstError?.message || "One or more operations failed"; - throw new Error(message); - } + .filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false); + if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed"); return {success: true}; }, - onSuccess: () => { - toast.success("Alert policies saved successfully"); - //onSuccess?.(); - router.refresh(); - }, - onError: (error: any) => { - toast.error(error.message || "Failed to save alert policies"); - }, + onSuccess: () => { toast.success("Policies saved successfully"); router.refresh(); }, + onError: (error: any) => { toast.error(error.message || "Failed to save policies"); }, }); - return ( -
{ - await mutation.mutateAsync(values); - }} - > + { + + if (kind === "notification") { + for (const policy of values.policies) { + if (!policy.eventKinds || policy.eventKinds.length === 0) { + toast.error("Please select at least one event for all notification policies"); + return; + } + } + } + + await mutation.mutateAsync(values) + } + }>
- + {!isMobile && (

- Choose which channels receive notifications for specific events. + {kind === "notification" + ? "Choose which channels receive notifications for specific events." + : "Choose which storage to use with your database"}

)}
- {organizationNotificationChannels.length === 0 ? ( -
+ {channels.length === 0 ? ( +
-

No notification channels

+

No channels

- Please configure - notification channels in your organization settings first. + Please + configure {channelText.toLowerCase()} channels + in your organization settings first.

) : fields.length === 0 ? ( -
+
-

No alert policies

+

No policies

- Click "Add Policy" to start receiving notifications. + {kind === "notification" ? `Click "Add Policy" to start receiving notifications.` : `Click "Add Policy" to use this storage.`}

) : ( @@ -211,24 +206,17 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
{ - const selectedIds = form - .watch("alertPolicies") - .map((a: AlertPolicyType) => a.notificationChannelId) - .filter(Boolean); - - const availableNotificationChannels = notificationChannels.filter( - (channel) => - channel.id.toString() === field.value?.toString() || - !selectedIds.includes(channel.id.toString()) + name={`policies.${index}.channelId`} + render={({field}) => { + const selectedIds = form.watch("policies").map((a: PolicyType) => a.channelId).filter(Boolean); + const availableChannels = channels.filter( + (channel) => channel.id.toString() === field.value?.toString() || !selectedIds.includes(channel.id.toString()) ); - - const selectedChannel = notificationChannels.find(c => c.id === field.value); + const selectedChannel = channels.find(c => c.id === field.value); return ( @@ -242,10 +230,10 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, {getChannelIcon(selectedChannel.provider)}
- {selectedChannel.name} + {selectedChannel.name} - {selectedChannel.provider} + {selectedChannel.provider}
)} @@ -253,16 +241,12 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, - {availableNotificationChannels.map(channel => ( + {availableChannels.map(channel => (
-
- {getChannelIcon(channel.provider)} -
+
{getChannelIcon(channel.provider)}
{channel.name} - - ({channel.provider}) - + ({channel.provider})
))} @@ -274,14 +258,13 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, }} />
+
- + ( + name={`policies.${index}.enabled`} + render={({field}) => (
@@ -297,43 +280,41 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId, )} />
+
-
- ( - - - Trigger Events - - -
- -
-
- -
- )} - /> + + {kind === "notification" && ( + ( + + Trigger Events + +
+ +
+
+ +
+ )} + /> + )}
))} @@ -343,12 +324,8 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
- - Cancel - - - Save Changes - + Cancel + Save Changes
); diff --git a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx b/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx similarity index 51% rename from src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx rename to src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx index 9279fee7..eb9ad6d9 100644 --- a/src/components/wrappers/dashboard/database/alert-policy/alert-policy-modal.tsx +++ b/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx @@ -1,7 +1,5 @@ "use client" -import {Megaphone} from "lucide-react"; - -import {useState} from "react"; +import {ReactNode, useState} from "react"; import { Dialog, DialogContent, @@ -11,36 +9,50 @@ import { DialogTrigger } from "@/components/ui/dialog"; import {Button} from "@/components/ui/button"; -import {AlertPolicyForm} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-form"; import {DatabaseWith} from "@/db/schema/07_database"; import {NotificationChannel} from "@/db/schema/09_notification-channel"; import {Separator} from "@/components/ui/separator"; import {Badge} from "@/components/ui/badge"; +import {StorageChannel} from "@/db/schema/12_storage-channel"; +import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; +import {ChannelPoliciesForm} from "@/components/wrappers/dashboard/database/channels-policy/policy-form"; + -type AlertPolicyModalProps = { +type ChannelPoliciesModalProps = { database: DatabaseWith; - notificationChannels: NotificationChannel[]; + channels: NotificationChannel[] | StorageChannel[]; organizationId: string; + kind: ChannelKind; + icon: ReactNode; + } -export const AlertPolicyModal = ({database, notificationChannels, organizationId}: AlertPolicyModalProps) => { +export const ChannelPoliciesModal = ({icon, kind, database, channels, organizationId}: ChannelPoliciesModalProps) => { const [open, setOpen] = useState(false); + const channelText = getChannelTextBasedOnKind(kind) - - const notificationsChannelsFiltered = notificationChannels + const channelsFiltered = channels .filter((channel) => channel.enabled) - const notificationsChannelsIds = notificationsChannelsFiltered + const channelsIds = channelsFiltered .map(channel => channel.id); + console.log(channelsIds); + const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId)); + const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId)); + + console.log(channels); + console.log(database.storagePolicies); + console.log("activeAlertPolicies", activeAlertPolicies); + console.log("activeStoragePolicies", activeStoragePolicies); - const activePolicies = database.alertPolicies?.filter((policy) => notificationsChannelsIds.includes(policy.notificationChannelId)); + const activePolicies = kind === "notification" ? activeAlertPolicies : activeStoragePolicies; return ( diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel.tsx index 4aff284d..612241a0 100644 --- a/src/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-card/button-edit-channel.tsx @@ -33,6 +33,7 @@ export const EditChannelButton = ({ }: EditChannelButtonProps) => { const router = useRouter(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const isLocalSystem = channel.provider == "local"; const mutation = useMutation({ @@ -73,10 +74,12 @@ export const EditChannelButton = ({ return ( <> - { - await mutation.mutateAsync(!channel.enabled) - }} - /> + + {!isLocalSystem && ( + { + await mutation.mutateAsync(!channel.enabled) + }}/> + )} { - const {data, organization, kind, adminView} = props; + const {data, organization, kind, adminView, defaultStorageChannelId} = props; const isMobile = useIsMobile() + const isDefaultSystemStorage = defaultStorageChannelId === data.id; + const isOwned = data.organizationId ? true : !organization; const isLocalSystem = data.provider == "local"; @@ -47,11 +51,16 @@ export const ChannelCard = (props: ChannelCardProps) => { {data.provider} + {isDefaultSystemStorage && ( + + default + + )}
{kind && (
- {(isOwned && !isLocalSystem) && ( + {(isOwned) && ( <> { channel={data} kind={kind} /> - + {!isLocalSystem && ( + + )} )}
diff --git a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx index 01406b30..9efef758 100644 --- a/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form.tsx @@ -3,6 +3,7 @@ import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/compon import {Input} from "@/components/ui/input"; import {Separator} from "@/components/ui/separator"; import {PasswordInput} from "@/components/ui/password-input"; +import {Switch} from "@/components/ui/switch"; type StorageS3FormProps = { @@ -59,12 +60,43 @@ export const StorageS3Form = ({form}: StorageS3FormProps) => { Bucket name - + )} /> + ( + + Port + + + + + + )} + /> + + ( + + Use SSL + + + + + + )} + /> + ) } diff --git a/src/components/wrappers/dashboard/admin/channels/channels-section.tsx b/src/components/wrappers/dashboard/admin/channels/channels-section.tsx index cf1adfb2..4e632d7e 100644 --- a/src/components/wrappers/dashboard/admin/channels/channels-section.tsx +++ b/src/components/wrappers/dashboard/admin/channels/channels-section.tsx @@ -10,15 +10,17 @@ import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channel import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; type ChannelsSectionProps = { - channels: NotificationChannelWith[] | StorageChannelWith[] - organizations: OrganizationWithMembers[] - kind: ChannelKind; + channels: NotificationChannelWith[] | StorageChannelWith[], + organizations: OrganizationWithMembers[], + kind: ChannelKind, + defaultStorageChannelId?: string | null | undefined } export const ChannelsSection = ({ organizations, channels, - kind + kind, + defaultStorageChannelId }: ChannelsSectionProps) => { const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -41,6 +43,7 @@ export const ChannelsSection = ({ adminView={true} organizations={organizations} kind={kind} + defaultStorageChannelId={defaultStorageChannelId} />
) : ( diff --git a/src/db/index.ts b/src/db/index.ts index acc6b41b..ec52874b 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -14,6 +14,7 @@ import * as alertPolicy from "./schema/10_alert-policy"; import * as notificationLog from "./schema/11_notification-log"; import * as storageChannel from "./schema/12_storage-channel"; import * as storagePolicy from "@/db/schema/13_storage-policy"; +import * as backupStorage from "@/db/schema/14_storage-backup"; import {Pool} from "pg"; @@ -44,7 +45,8 @@ export const schemas = { ...alertPolicy, ...notificationLog, ...storageChannel, - ...storagePolicy + ...storagePolicy, + ...backupStorage }; export const db = drizzle({ diff --git a/src/db/schema/01_setting.ts b/src/db/schema/01_setting.ts index 030d2a98..94d14bb0 100644 --- a/src/db/schema/01_setting.ts +++ b/src/db/schema/01_setting.ts @@ -4,6 +4,12 @@ import {createSelectSchema} from "drizzle-zod"; import {z} from "zod"; import {timestamps} from "@/db/schema/00_common"; import {storageChannel} from "@/db/schema/12_storage-channel"; +import {relations} from "drizzle-orm"; +import {agent} from "@/db/schema/08_agent"; +import {project} from "@/db/schema/06_project"; +import {alertPolicy} from "@/db/schema/10_alert-policy"; +import {storagePolicy} from "@/db/schema/13_storage-policy"; +import {backup, database, restoration, retentionPolicy} from "@/db/schema/07_database"; export const setting = pgTable("settings", { id: uuid("id").primaryKey().defaultRandom(), @@ -23,5 +29,10 @@ export const setting = pgTable("settings", { ...timestamps }); +export const settingRelations = relations(setting, ({one, many}) => ({ + storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}), +})); + + export const settingSchema = createSelectSchema(setting); export type Setting = z.infer; diff --git a/src/features/storages/dispatch.ts b/src/features/storages/dispatch.ts index 5e58d665..943fa1d9 100644 --- a/src/features/storages/dispatch.ts +++ b/src/features/storages/dispatch.ts @@ -5,6 +5,8 @@ import * as drizzleDb from '@/db'; import {db} from '@/db'; import type {StorageInput, StorageProviderKind, StorageResult,} from './types'; import {dispatchViaProvider} from "@/features/storages/providers"; +import {StorageChannel} from "@/db/schema/12_storage-channel"; +import {Json} from "drizzle-zod"; export async function dispatchStorage( input: StorageInput, @@ -13,34 +15,84 @@ export async function dispatchStorage( organizationId?: string ): Promise { try { - if (!channelId) { + let channel: StorageChannel | null = null; + if (policyId) { + const policyDb = await db.query.storagePolicy.findFirst({ + where: eq(drizzleDb.schemas.storagePolicy.id, policyId), + with: { + storageChannel: true + }, + }); + if (!policyDb || !policyDb.storageChannel) { + return { + success: false, + provider: null, + error: "Policy or associated channel not found", + }; + } + + if (!policyDb.enabled || !policyDb.storageChannel.enabled) { + return { + success: false, + provider: policyDb.storageChannel.provider as any, + error: "Policy or channel is disabled", + }; + } + + channel = { + ...policyDb.storageChannel, + config: policyDb.storageChannel.config as Json, + }; + } + + + if (channelId) { + const fetchedChannel = await db.query.storageChannel.findFirst({ + where: eq(drizzleDb.schemas.storageChannel.id, channelId), + }); + + if (!fetchedChannel) { + return { + success: false, + provider: null, + error: "Channel not found", + }; + } + + channel = { + ...fetchedChannel, + config: fetchedChannel.config as Json, + }; + } + + if (!channel) { return { success: false, provider: null, - error: 'No storage channel provided', + error: "No valid channel to dispatch on storage", }; } - const channel = await db.query.storageChannel.findFirst({ - where: eq(drizzleDb.schemas.storageChannel.id, channelId), - }); - if (!channel || !channel.enabled) { + if (!channel.enabled) { return { success: false, - provider: channel?.provider as StorageProviderKind, - error: 'Storage channel not found or disabled', + provider: null, + error: "Channel not active", }; } + return await dispatchViaProvider( channel.provider as StorageProviderKind, channel.config, input ); + + } catch (err: any) { return { success: false, diff --git a/src/features/storages/helpers.ts b/src/features/storages/helpers.ts index e6d03b6f..8479de79 100644 --- a/src/features/storages/helpers.ts +++ b/src/features/storages/helpers.ts @@ -1,10 +1,106 @@ -import {DatabaseWith} from "@/db/schema/07_database"; -import {StorageChannel} from "@/db/schema/12_storage-channel"; -import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; +import { Backup, DatabaseWith } from "@/db/schema/07_database"; +import { dispatchStorage } from "@/features/storages/dispatch"; +import type { StorageInput, StorageResult } from "@/features/storages/types"; +import * as drizzleDb from "@/db"; +import { withUpdatedAt } from "@/db/utils"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { createHash } from "crypto"; +function computeChecksum(buffer: Buffer): string { + return createHash("sha256").update(buffer).digest("hex"); +} -export async function storeFileBackup(database: DatabaseWith, file: Buffer) { +export async function storeBackupFiles( + backup: Backup, + database: DatabaseWith, + file: Buffer, + fileName: string +): Promise { + const settings = await db.query.setting.findFirst({ + where: eq(drizzleDb.schemas.setting.name, "system"), + with: { storageChannel: true }, + }); + const defaultPolicy = settings?.storageChannel + ? [{ + id: null, + storageChannelId: settings.storageChannel.id, + enabled: true + }] + : []; -} \ No newline at end of file + console.log(database.storagePolicies); + + const policies = (database.storagePolicies?.filter(p => p.enabled) || defaultPolicy); + + console.log("Policies", policies); + + + if (!policies.length) { + return []; + } + + const path = `${database.project?.slug}/${fileName}`; + const size = file.length; + const checksum = computeChecksum(file); + + const results = await Promise.all( + policies.map(async policy => { + const [backupStorage] = await db + .insert(drizzleDb.schemas.backupStorage) + .values({ + backupId: backup.id, + storageChannelId: policy.storageChannelId, + status: "pending", + path, + size, + checksum, + }) + .returning(); + + const input: StorageInput = { + action: "upload", + data: { path, file }, + }; + + // const result = await dispatchStorage(input, policy.id); + let result: StorageResult; + + try { + if (policy.id){ + result = await dispatchStorage(input, policy.id); + }else{ + result = await dispatchStorage(input, undefined, policy.storageChannelId); + } + + } catch (err) { + console.error(err); + result = { + success: false, + provider: null, + error: err instanceof Error ? err.message : "Unknown error" + }; + } + + await db + .update(drizzleDb.schemas.backupStorage) + .set(withUpdatedAt({ status: result.success ? "success" : "failed" })) + .where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id)); + + return result; + }) + ); + + console.log(results); + + const backupStatus = results.some(r => r.success) ? "success" : "failed"; + + await db + .update(drizzleDb.schemas.backup) + .set(withUpdatedAt({ status: backupStatus })) + .where(eq(drizzleDb.schemas.backup.id, backup.id)); + + return results; +} diff --git a/src/features/storages/providers/index.ts b/src/features/storages/providers/index.ts index f30a130c..5950f7bc 100644 --- a/src/features/storages/providers/index.ts +++ b/src/features/storages/providers/index.ts @@ -5,6 +5,7 @@ import type { } from '../types'; import {uploadLocal, getLocal, deleteLocal} from './local'; +import {deleteS3, getS3, uploadS3} from "@/features/storages/providers/s3"; type ProviderHandler = { upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise; @@ -18,11 +19,11 @@ const handlers: Record = { get: getLocal, delete: deleteLocal, }, - // s3: { - // upload: uploadS3, - // get: getS3, - // delete: deleteS3, - // }, + s3: { + upload: uploadS3, + get: getS3, + delete: deleteS3, + }, // gcs: null as any, // azure: null as any, }; diff --git a/src/features/storages/providers/local.ts b/src/features/storages/providers/local.ts index 2357c7e2..05f466bd 100644 --- a/src/features/storages/providers/local.ts +++ b/src/features/storages/providers/local.ts @@ -14,7 +14,9 @@ export async function uploadLocal( const base = config.baseDir || BASE_DIR; const fullPath = path.join(process.cwd(), base, input.data.path); - await mkdir(fullPath, {recursive: true}); + const dir = path.dirname(fullPath); + + await mkdir(dir, { recursive: true }); await writeFile(fullPath, input.data.file); return { diff --git a/src/features/storages/providers/s3.ts b/src/features/storages/providers/s3.ts new file mode 100644 index 00000000..774b9e91 --- /dev/null +++ b/src/features/storages/providers/s3.ts @@ -0,0 +1,92 @@ +import * as Minio from "minio"; +import {StorageDeleteInput, StorageGetInput, StorageResult, StorageUploadInput} from "../types"; + +type S3Config = { + endPointUrl: string; + accessKey: string; + secretKey: string; + bucketName: string; + port?: number; + useSSL?: boolean; +}; + +async function getS3Client(config: S3Config) { + return new Minio.Client({ + endPoint: config.endPointUrl, + accessKey: config.accessKey, + secretKey: config.secretKey, + port: config.port ?? 443, + useSSL: config.useSSL ?? true, + }); +} + +const BASE_DIR = "backups/"; + + +async function ensureBucket(config: S3Config) { + const client = await getS3Client(config); + const exists = await client.bucketExists(config.bucketName); + if (!exists) await client.makeBucket(config.bucketName); +} + +export async function uploadS3(config: S3Config, input: { data: StorageUploadInput }): Promise { + const client = await getS3Client(config); + await ensureBucket(config); + + const key = `${BASE_DIR}${input.data.path}`; + + try { + await client.statObject(config.bucketName, key); + return {success: false, provider: "s3", error: "File already exists"}; + } catch { + // continue if not found + } + + await client.putObject(config.bucketName, key, input.data.file as Buffer); + + return { + success: true, + provider: "s3", + }; +} + +export async function getS3(config: S3Config, input: { data: StorageGetInput }): Promise { + const client = await getS3Client(config); + + // const key = input.data.path; + const key = `${BASE_DIR}${input.data.path}`; + + + try { + await client.statObject(config.bucketName, key); + } catch { + return {success: false, provider: "s3", error: "File not found"}; + } + + const presignedUrl = await client.presignedGetObject(config.bucketName, key, 60); + + const fileStream = await client.getObject(config.bucketName, key); + const chunks: Buffer[] = []; + for await (const chunk of fileStream) chunks.push(chunk as Buffer); + const buffer = Buffer.concat(chunks); + + return { + success: true, + provider: "s3", + file: buffer, + url: presignedUrl, + }; +} + +export async function deleteS3(config: S3Config, input: { data: StorageDeleteInput }): Promise { + const client = await getS3Client(config); + // const key = input.data.path; + const key = `${BASE_DIR}${input.data.path}`; + + try { + await client.removeObject(config.bucketName, key); + return {success: true, provider: "s3"}; + } catch (err: any) { + return {success: false, provider: "s3", error: err.message}; + } +} diff --git a/src/features/storages/types.ts b/src/features/storages/types.ts index c0c5ec81..0bf5385e 100644 --- a/src/features/storages/types.ts +++ b/src/features/storages/types.ts @@ -1,6 +1,6 @@ export type StorageProviderKind = | 'local' -// | 's3' + | 's3' ; export type StorageAction = From 5a0c230ead4b89fc580cf79337976e6f7f6ca610 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Fri, 16 Jan 2026 21:54:16 +0100 Subject: [PATCH 07/24] chore: working on backup api for new storage system. --- .../dashboard/(admin)/notifications/channels/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx index 34001921..28becfab 100644 --- a/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx +++ b/app/(customer)/dashboard/(admin)/notifications/channels/page.tsx @@ -6,6 +6,7 @@ import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notif import {desc, isNull} from "drizzle-orm"; import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section"; import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal"; +import * as drizzleDb from "@/db"; export const metadata: Metadata = { title: "Notification Channels", @@ -17,6 +18,7 @@ export default async function RoutePage(props: PageParams<{}>) { with: { organizations: true }, + where: isNull(drizzleDb.schemas.notificationChannel.organizationId), orderBy: desc(notificationChannel.createdAt) }) as NotificationChannelWith[] From 0216c40bfeaa8cf9c0bb9e8fd6c12ccefdf3e0c2 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Sat, 17 Jan 2026 15:57:37 +0100 Subject: [PATCH 08/24] feat: Working on UI/UX for multiple storage backends. --- .../database/[databaseId]/page.tsx | 22 ++- .../projects/[projectId]/page.tsx | 6 +- app/(customer)/dashboard/layout.tsx | 1 + app/providers.tsx | 5 +- package.json | 1 + pnpm-lock.yaml | 9 + .../admin/notifications/logs/columns.tsx | 6 +- .../backup/actions/backup-actions-cell.tsx | 61 +++++++ .../backup/actions/backup-actions-form.tsx | 158 ++++++++++++++++++ .../backup/actions/backup-actions-modal.tsx | 39 +++++ .../backup/actions/backup-actions.action.ts | 81 +++++++++ .../backup/actions/backup-actions.schema.ts | 10 ++ .../database/backup/backup-modal-context.tsx | 60 +++++++ .../database/database-backup-list.tsx | 4 +- .../projects/database/database-content.tsx | 39 +++++ .../projects/database/database-tabs.tsx | 5 +- src/db/schema/07_database.ts | 9 +- src/db/schema/14_storage-backup.ts | 18 +- src/db/services/backup.ts | 19 +++ src/features/dashboard/backup/columns.tsx | 5 + src/features/storages/helpers.ts | 29 ++-- src/features/storages/providers/local.ts | 8 +- 22 files changed, 556 insertions(+), 39 deletions(-) create mode 100644 src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx create mode 100644 src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx create mode 100644 src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx create mode 100644 src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts create mode 100644 src/components/wrappers/dashboard/database/backup/actions/backup-actions.schema.ts create mode 100644 src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx create mode 100644 src/components/wrappers/dashboard/projects/database/database-content.tsx create mode 100644 src/db/services/backup.ts diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index b8d72a97..a2b91e42 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -2,7 +2,6 @@ import {PageParams} from "@/types/next"; import {notFound, redirect} from "next/navigation"; import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button"; -import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs"; import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi"; import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button"; import {db} from "@/db"; @@ -17,6 +16,8 @@ import {ImportModal} from "@/components/wrappers/dashboard/database/import/impor import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal"; import {HardDrive, Megaphone} from "lucide-react"; +import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; +import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content"; export default async function RoutePage(props: PageParams<{ projectId: string; @@ -54,6 +55,11 @@ export default async function RoutePage(props: PageParams<{ where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id), with: { restorations: true, + storages: { + with: { + storageChannel: true + } + } }, orderBy: (b, {desc}) => [desc(b.createdAt)], }); @@ -147,10 +153,16 @@ export default async function RoutePage(props: PageParams<{ - + + + ); diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx index 0798540e..7e8223ca 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx @@ -15,11 +15,7 @@ import {eq} from "drizzle-orm"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import * as drizzleDb from "@/db"; import {capitalizeFirstLetter} from "@/utils/text"; -import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet"; -import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button"; -import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal"; -import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal"; -import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button"; + export default async function RoutePage(props: PageParams<{ projectId: string diff --git a/app/(customer)/dashboard/layout.tsx b/app/(customer)/dashboard/layout.tsx index 3e683bd0..bf04ccf7 100644 --- a/app/(customer)/dashboard/layout.tsx +++ b/app/(customer)/dashboard/layout.tsx @@ -6,6 +6,7 @@ import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sid import {Header} from "@/features/layout/Header"; import {currentUser} from "@/lib/auth/current-user"; import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater"; +import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; export default async function Layout({children}: { children: ReactNode }) { const user = await currentUser(); diff --git a/app/providers.tsx b/app/providers.tsx index 8ec7abcd..4c6dbe90 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -6,6 +6,7 @@ import {ThemeProvider} from "@/features/theme/theme-provider"; import {Toaster} from "@/components/ui/sonner"; import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root"; +import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; export type ProviderProps = PropsWithChildren<{}>; @@ -21,8 +22,8 @@ export const Providers = (props: ProviderProps) => { > - - {props.children} + + {props.children} diff --git a/package.json b/package.json index bb2e2dac..1fbd16c5 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "sonner": "^2.0.3", + "swiper": "^12.0.3", "tailwind-merge": "^3.3.0", "uuid": "^11.1.0", "vaul": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8f2fba1..bf06a775 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: sonner: specifier: ^2.0.3 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + swiper: + specifier: ^12.0.3 + version: 12.0.3 tailwind-merge: specifier: ^3.3.0 version: 3.4.0 @@ -6230,6 +6233,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swiper@12.0.3: + resolution: {integrity: sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==} + engines: {node: '>= 4.7.0'} + tailwind-merge@3.2.0: resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} @@ -13556,6 +13563,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swiper@12.0.3: {} + tailwind-merge@3.2.0: {} tailwind-merge@3.4.0: {} diff --git a/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx b/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx index 4f44b7cd..246563cc 100644 --- a/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx +++ b/src/components/wrappers/dashboard/admin/notifications/logs/columns.tsx @@ -89,7 +89,7 @@ export function notificationLogsColumns(): ColumnDef { +export const getStatusIcon = (status: boolean) => { switch (status) { case true: return @@ -98,10 +98,12 @@ const getStatusIcon = (status: boolean) => { } } -const getStatusColor = (status: string) => { +export const getStatusColor = (status: string) => { switch (status) { case "delivered": return "bg-green-100 dark:bg-green-100/10" + case "success": + return "bg-green-100 dark:bg-green-100/10" case "failed": return "bg-red-100 dark:bg-red-100/10" case "pending": diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx new file mode 100644 index 00000000..bc7a7b6e --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx @@ -0,0 +1,61 @@ +"use client"; + +import {BackupWith} from "@/db/schema/07_database"; +import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; +import {Button} from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator +} from "@/components/ui/dropdown-menu"; +import {MoreHorizontal, Trash2, Download} from "lucide-react"; +import {ReloadIcon} from "@radix-ui/react-icons"; +import {cn} from "@/lib/utils"; +import {MemberWithUser} from "@/db/schema/03_organization"; +import {useMutation} from "@tanstack/react-query"; +import {deleteBackupAction} from "@/features/dashboard/restore/restore.action"; +import {toast} from "sonner"; + +interface DatabaseActionsCellProps { + backup: BackupWith; + activeMember: MemberWithUser; +} + +export function DatabaseActionsCell({backup, activeMember}: DatabaseActionsCellProps) { + const {openModal} = useBackupModal(); + + + if (backup.deletedAt || activeMember.role === "member") return null; + + + return ( +
+ + + + + + Actions + openModal("restore", backup)}> + Restore + + openModal("download", backup)}> + Download + + + openModal("delete", backup)} className="text-red-600"> + Delete + + + +
+ + ); +} + diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx new file mode 100644 index 00000000..98756abd --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx @@ -0,0 +1,158 @@ +"use client" +import {BackupWith} from "@/db/schema/07_database"; +import React, {useState} from "react"; +import {Swiper, SwiperSlide} from "swiper/react"; + +import "swiper/css"; +import "swiper/css/pagination"; + +import {Pagination, Mousewheel} from "swiper/modules"; +import {DatabaseActionKind} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; +import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; +import { + BackupActionsSchema, + BackupActionsType +} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema"; +import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; +import {useMutation} from "@tanstack/react-query"; +import {BackupStorageWith} from "@/db/schema/14_storage-backup"; +import {TooltipProvider} from "@/components/ui/tooltip"; +import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; +import {truncateWords} from "@/utils/text"; +import {useIsMobile} from "@/hooks/use-mobile"; +import {Badge} from "@/components/ui/badge"; +import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns"; +import {downloadBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; +import {toast} from "sonner"; + +type BackupActionsFormProps = { + backup: BackupWith; + action: DatabaseActionKind; +} + +export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { + const isMobile = useIsMobile(); + + const form = useZodForm({ + schema: BackupActionsSchema, + }); + + const mutation = useMutation({ + mutationFn: async (values: BackupActionsType) => { + // implement your mutation logic here + console.log(values); + + const result = await downloadBackupAction({backupStorageId: values.backupStorageId}) + + const inner = result?.data; + + console.log(inner); + + + if (inner?.success) { + toast.success(inner.actionSuccess?.message); + } else { + toast.error(inner?.actionError?.message); + } + + + }, + }); + + + return ( + + + + {action == "delete" ? + <> + + : +
{ + await mutation.mutateAsync(values); + }} + > + ( + + Choose a storage backup + +
+ + {backup.storages?.map((storage: BackupStorageWith) => ( + + + + + )) ??

No storages available

} +
+
+
+ +
+ )} + /> +
+ + Confirm + +
+ + } + +
+ ); +} diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx new file mode 100644 index 00000000..a092f5b6 --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx @@ -0,0 +1,39 @@ +"use client" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import {Separator} from "@/components/ui/separator"; +import {BackupActionsForm} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-form"; +import { + getBackupActionTextBasedOnActionKind, + useBackupModal +} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; + + +type DatabaseActionsModalProps = {} + + +export const DatabaseBackupActionsModal = ({}: DatabaseActionsModalProps) => { + const {open, action, backup, closeModal} = useBackupModal(); + if (!backup || !action) return null; + const text = getBackupActionTextBasedOnActionKind(action); + + return ( + + + + {text} storage backup ? + + Select the backup storage you want to {text.toLowerCase()} + + + + + + + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts new file mode 100644 index 00000000..18f638ae --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts @@ -0,0 +1,81 @@ +"use server" +import {userAction} from "@/lib/safe-actions/actions"; +import {ServerActionResult} from "@/types/action-type"; +import {z} from "zod"; +import type {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/dispatch"; +import {db} from "@/db"; +import {eq} from "drizzle-orm"; +import * as drizzleDb from "@/db"; + + +export const downloadBackupAction = userAction.schema( + z.object({ + backupStorageId: z.string(), + }) +).action(async ({parsedInput}): Promise> => { + const {backupStorageId} = parsedInput; + try { + + const backupStorage = await db.query.backupStorage.findFirst({ + where: eq(drizzleDb.schemas.backupStorage.id, backupStorageId), + + }); + + if (!backupStorage) { + return { + success: false, + actionError: { + message: "Backup storage not found.", + status: 404, + messageParams: {backupStorageId: backupStorageId}, + }, + }; + } + + console.log(backupStorage); + + if (backupStorage.status != "success" || !backupStorage.path) { + return { + success: false, + actionError: { + message: "An error occurred.", + status: 500, + messageParams: {backupStorageId: backupStorageId}, + }, + } + } + + const input: StorageInput = { + action: "get", + data: { + path: backupStorage.path, + signedUrl: true, + }, + }; + + const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId); + + console.log(result); + + return { + success: true, + value: result.url, + actionSuccess: { + message: "Backup Storage downloaded successfully.", + messageParams: {backupStorageId: backupStorageId}, + }, + }; + } catch (error) { + console.error("Error:", error); + return { + success: false, + actionError: { + message: "Failed to get presigned url.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {backupStorageId: backupStorageId}, + }, + }; + } +}); \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions.schema.ts b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.schema.ts new file mode 100644 index 00000000..2dc473ce --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.schema.ts @@ -0,0 +1,10 @@ +"use client"; + +import { z } from "zod"; +import {zString} from "@/lib/zod"; + +export const BackupActionsSchema = z.object({ + backupStorageId: zString(), +}); + +export type BackupActionsType = z.infer; diff --git a/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx b/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx new file mode 100644 index 00000000..5fa81aa8 --- /dev/null +++ b/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx @@ -0,0 +1,60 @@ +"use client"; + +import {createContext, useContext, useState, ReactNode} from "react"; +import {BackupWith} from "@/db/schema/07_database"; + +export type DatabaseActionKind = "restore" | "download" | "delete"; + +export function getBackupActionTextBasedOnActionKind(kind: DatabaseActionKind) { + switch (kind) { + case "restore": + return "Restore"; + case "download": + return "Download"; + case "delete": + return "Delete"; + default: + return "Unknown"; + } +} + + +type BackupModalContextType = { + open: boolean; + action: DatabaseActionKind | null; + backup: BackupWith | null; + openModal: (action: DatabaseActionKind, backup: BackupWith) => void; + closeModal: () => void; +}; + +const BackupModalContext = createContext(undefined); + +export const BackupModalProvider = ({children}: { children: ReactNode }) => { + const [open, setOpen] = useState(false); + const [action, setAction] = useState(null); + const [backup, setBackup] = useState(null); + + const openModal = (newAction: DatabaseActionKind, newBackup: BackupWith) => { + setAction(newAction); + setBackup(newBackup); + setOpen(true); + }; + + const closeModal = () => { + setOpen(false); + setAction(null); + setBackup(null); + }; + + return ( + + {children} + + ); +}; + +export const useBackupModal = () => { + const context = useContext(BackupModalContext); + if (!context) throw new Error("useBackupModal must be used within BackupModalProvider"); + return context; +}; diff --git a/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx b/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx index 4355df19..c77f2730 100644 --- a/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx +++ b/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx @@ -6,7 +6,7 @@ import {MoreHorizontal, Trash2} from "lucide-react"; import {FilterItem, FiltersDropdown} from "@/components/wrappers/common/table/filters"; import {DataTable} from "@/components/wrappers/common/table/data-table"; import {useMemo, useState} from "react"; -import {Backup, DatabaseWith} from "@/db/schema/07_database"; +import {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database"; import {Setting} from "@/db/schema/01_setting"; import {useMutation} from "@tanstack/react-query"; import {deleteBackupAction} from "@/features/dashboard/restore/restore.action"; @@ -19,7 +19,7 @@ type DatabaseBackupListProps = { isAlreadyRestore: boolean; settings: Setting; database: DatabaseWith; - backups: Backup[]; + backups: BackupWith[]; activeMember: MemberWithUser } diff --git a/src/components/wrappers/dashboard/projects/database/database-content.tsx b/src/components/wrappers/dashboard/projects/database/database-content.tsx new file mode 100644 index 00000000..6f76298b --- /dev/null +++ b/src/components/wrappers/dashboard/projects/database/database-content.tsx @@ -0,0 +1,39 @@ +"use client" +import {DatabaseBackupActionsModal} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal"; +import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs"; +import {Setting} from "@/db/schema/01_setting"; +import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database"; +import {MemberWithUser} from "@/db/schema/03_organization"; +import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; + + +export type DatabaseContentProps = { + settings: Setting, + backups: BackupWith[], + restorations: Restoration[], + isAlreadyRestore: boolean, + database: DatabaseWith, + activeMember: MemberWithUser +} + + +export const DatabaseContent = ({ + settings, + backups, + activeMember, + isAlreadyRestore, + restorations, + database + }: DatabaseContentProps) => { + const {} = useBackupModal(); + + return ( + <> + + + + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/projects/database/database-tabs.tsx b/src/components/wrappers/dashboard/projects/database/database-tabs.tsx index 39848f0e..7223723c 100644 --- a/src/components/wrappers/dashboard/projects/database/database-tabs.tsx +++ b/src/components/wrappers/dashboard/projects/database/database-tabs.tsx @@ -4,7 +4,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; import {useEffect, useState} from "react"; import {useRouter, useSearchParams} from "next/navigation"; import {eventUpdate} from "@/types/events"; -import {Backup, Database, DatabaseWith, Restoration} from "@/db/schema/07_database"; +import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database"; import {Setting} from "@/db/schema/01_setting"; import {DatabaseBackupList} from "@/components/wrappers/dashboard/projects/database/database-backup-list"; import {DatabaseRestoreList} from "@/components/wrappers/dashboard/projects/database/database-restore-list"; @@ -12,7 +12,7 @@ import {MemberWithUser} from "@/db/schema/03_organization"; export type DatabaseTabsProps = { settings: Setting, - backups: Backup[], + backups: BackupWith[], restorations: Restoration[], isAlreadyRestore: boolean, database: DatabaseWith, @@ -39,6 +39,7 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => { }; }, []); + useEffect(() => { const newTab = searchParams.get("tab") ?? "backup"; setTab(newTab); diff --git a/src/db/schema/07_database.ts b/src/db/schema/07_database.ts index 59bf1c39..4e14e1dd 100644 --- a/src/db/schema/07_database.ts +++ b/src/db/schema/07_database.ts @@ -8,7 +8,7 @@ import {z} from "zod"; import {timestamps} from "@/db/schema/00_common"; import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy"; import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy"; -import {backupStorage} from "@/db/schema/14_storage-backup"; +import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup"; export const database = pgTable("databases", { id: uuid("id").primaryKey().defaultRandom(), @@ -126,3 +126,10 @@ export type DatabaseWith = Database & { storagePolicies?: StoragePolicy[] | null; }; + +export type BackupWith = Backup & { + restorations?: Restoration[] | null; + storages?: BackupStorage[] | null; +}; + + diff --git a/src/db/schema/14_storage-backup.ts b/src/db/schema/14_storage-backup.ts index 3444c87c..e7dbfb22 100644 --- a/src/db/schema/14_storage-backup.ts +++ b/src/db/schema/14_storage-backup.ts @@ -1,8 +1,10 @@ import { pgTable, uuid, text, integer, pgEnum } from "drizzle-orm/pg-core"; import { timestamps } from "@/db/schema/00_common"; -import { storageChannel } from "@/db/schema/12_storage-channel"; -import {backup} from "@/db/schema/07_database"; +import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel"; +import {Backup, backup, Restoration} from "@/db/schema/07_database"; import {relations} from "drizzle-orm"; +import {createSelectSchema} from "drizzle-zod"; +import {z} from "zod"; export const backupStorageStatusEnum = pgEnum("backup_storage_status", [ "pending", @@ -35,4 +37,14 @@ export const backupStorageRelations = relations(backupStorage, ({ one }) => ({ fields: [backupStorage.storageChannelId], references: [storageChannel.id], }), -})); \ No newline at end of file +})); + + +export const backupStorageSchema = createSelectSchema(backupStorage); +export type BackupStorage = z.infer; + +export type BackupStorageWith = BackupStorage & { + storageChannel?: StorageChannel | null; +}; + + diff --git a/src/db/services/backup.ts b/src/db/services/backup.ts new file mode 100644 index 00000000..d9234be4 --- /dev/null +++ b/src/db/services/backup.ts @@ -0,0 +1,19 @@ +"use server" +import {eq} from "drizzle-orm"; +import * as drizzleDb from "@/db"; +import {db} from "@/db"; + +export async function getDatabaseBackups(databaseId: string) { + return await db.query.backup.findMany({ + where: eq(drizzleDb.schemas.backup.databaseId, databaseId), + with: { + restorations: true, + storages: { + with: { + storageChannel: true, + }, + }, + }, + orderBy: (b, {desc}) => [desc(b.createdAt)], + }); +} diff --git a/src/features/dashboard/backup/columns.tsx b/src/features/dashboard/backup/columns.tsx index d8310ca3..36e0ccd2 100644 --- a/src/features/dashboard/backup/columns.tsx +++ b/src/features/dashboard/backup/columns.tsx @@ -32,6 +32,7 @@ import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/compon import {MemberWithUser} from "@/db/schema/03_organization"; import {formatLocalizedDate} from "@/utils/date-formatting"; import {formatBytes, isImportedFilename} from "@/utils/text"; +import {DatabaseActionsCell} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-cell"; export function backupColumns( @@ -92,6 +93,10 @@ export function backupColumns( return ; }, }, + { + id: "actions2", + cell: ({row}) => , + }, { id: "actions", cell: ({row, table}) => { diff --git a/src/features/storages/helpers.ts b/src/features/storages/helpers.ts index 8479de79..e52d632a 100644 --- a/src/features/storages/helpers.ts +++ b/src/features/storages/helpers.ts @@ -1,11 +1,11 @@ -import { Backup, DatabaseWith } from "@/db/schema/07_database"; -import { dispatchStorage } from "@/features/storages/dispatch"; -import type { StorageInput, StorageResult } from "@/features/storages/types"; +import {Backup, DatabaseWith} from "@/db/schema/07_database"; +import {dispatchStorage} from "@/features/storages/dispatch"; +import type {StorageInput, StorageResult} from "@/features/storages/types"; import * as drizzleDb from "@/db"; -import { withUpdatedAt } from "@/db/utils"; -import { eq } from "drizzle-orm"; -import { db } from "@/db"; -import { createHash } from "crypto"; +import {withUpdatedAt} from "@/db/utils"; +import {eq} from "drizzle-orm"; +import {db} from "@/db"; +import {createHash} from "crypto"; function computeChecksum(buffer: Buffer): string { return createHash("sha256").update(buffer).digest("hex"); @@ -20,7 +20,7 @@ export async function storeBackupFiles( const settings = await db.query.setting.findFirst({ where: eq(drizzleDb.schemas.setting.name, "system"), - with: { storageChannel: true }, + with: {storageChannel: true}, }); const defaultPolicy = settings?.storageChannel @@ -62,16 +62,16 @@ export async function storeBackupFiles( const input: StorageInput = { action: "upload", - data: { path, file }, + data: {path, file}, }; // const result = await dispatchStorage(input, policy.id); let result: StorageResult; try { - if (policy.id){ + if (policy.id) { result = await dispatchStorage(input, policy.id); - }else{ + } else { result = await dispatchStorage(input, undefined, policy.storageChannelId); } @@ -86,7 +86,7 @@ export async function storeBackupFiles( await db .update(drizzleDb.schemas.backupStorage) - .set(withUpdatedAt({ status: result.success ? "success" : "failed" })) + .set(withUpdatedAt({status: result.success ? "success" : "failed"})) .where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id)); return result; @@ -99,7 +99,10 @@ export async function storeBackupFiles( await db .update(drizzleDb.schemas.backup) - .set(withUpdatedAt({ status: backupStatus })) + .set(withUpdatedAt({ + status: backupStatus, + fileSize: size + })) .where(eq(drizzleDb.schemas.backup.id, backup.id)); return results; diff --git a/src/features/storages/providers/local.ts b/src/features/storages/providers/local.ts index 05f466bd..6114597b 100644 --- a/src/features/storages/providers/local.ts +++ b/src/features/storages/providers/local.ts @@ -16,7 +16,7 @@ export async function uploadLocal( const dir = path.dirname(fullPath); - await mkdir(dir, { recursive: true }); + await mkdir(dir, {recursive: true}); await writeFile(fullPath, input.data.file); return { @@ -31,14 +31,14 @@ export async function getLocal( input: { data: StorageGetInput } ): Promise { const base = config.baseDir || BASE_DIR; - const filePath = path.join(base, input.data.path) + const filePath = path.join(process.cwd(), base, input.data.path) const fileName = path.basename(input.data.path); - const file = await readFile(filePath); + const file = await readFile(filePath); if (!fs.existsSync(filePath)) { console.error("File not found at:", filePath); - return({ + return ({ success: false, provider: 'local', }); From 7d818206b0d01283154fa47863cdbde54bbb168c Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Sat, 17 Jan 2026 17:27:33 +0100 Subject: [PATCH 09/24] refactor: API route for backup/restore --- app/api/agent/[agentId]/status/helpers.ts | 114 ++++++--- app/api/agent/[agentId]/status/route.ts | 10 - app/api/files/[fileName]/route.ts | 60 ----- app/api/files/route.ts | 91 +++++++ proxy.ts | 2 +- .../auth/login/login-form/login-form.tsx | 1 - .../register/register-form/register-form.tsx | 22 +- .../backup/actions/backup-actions-form.tsx | 46 +++- .../backup/actions/backup-actions.action.ts | 53 ++++- .../migrations/0026_demonic_santa_claus.sql | 2 + src/db/migrations/meta/0026_snapshot.json | 225 ++++++++++-------- src/db/migrations/meta/_journal.json | 7 + src/db/schema/07_database.ts | 4 + src/features/storages/providers/local.ts | 9 +- 14 files changed, 412 insertions(+), 234 deletions(-) delete mode 100644 app/api/files/[fileName]/route.ts create mode 100644 app/api/files/route.ts create mode 100644 src/db/migrations/0026_demonic_santa_claus.sql diff --git a/app/api/agent/[agentId]/status/helpers.ts b/app/api/agent/[agentId]/status/helpers.ts index 7db9bb10..1fe3f186 100644 --- a/app/api/agent/[agentId]/status/helpers.ts +++ b/app/api/agent/[agentId]/status/helpers.ts @@ -12,6 +12,8 @@ import {ServerActionResult} from "@/types/action-type"; import {SafeActionResult} from "next-safe-action"; import {ZodString} from "zod"; import {withUpdatedAt} from "@/db/utils"; +import type {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/dispatch"; export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) { const databasesResponse = []; @@ -88,7 +90,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat const restoration = await dbClient.query.restoration.findFirst({ - where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")) + where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")), + with: { + backupStorage: true + } }) @@ -105,53 +110,53 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat restoreAction = true - const backupToRestore = await dbClient.query.backup.findFirst({ - where: eq(drizzleDb.schemas.backup.id, restoration.backupId), - with: { - database: { - with: { - project: true - } - } - } - }) - - const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - if (!settings) { - return NextResponse.json( - {error: "Unable to find settings"}, - {status: 500} - ); + // const backupToRestore = await dbClient.query.backup.findFirst({ + // where: eq(drizzleDb.schemas.backup.id, restoration.backupId), + // with: { + // database: { + // with: { + // project: true + // } + // } + // } + // }) + + // const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); + // if (!settings) { + // return NextResponse.json( + // {error: "Unable to find settings"}, + // {status: 500} + // ); + // } + + if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) { + restoreAction = false + return; } - const fileName = backupToRestore?.file + const input: StorageInput = { + action: "get", + data: { + path: restoration.backupStorage.path, + signedUrl: true, + }, + }; - let data: SafeActionResult, object> | undefined try { + const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId); - if (settings.storage == "local") { - data = await getFileUrlPresignedLocal({fileName: fileName!}) - } else if (settings.storage == "s3") { - - data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`); - } - - if (data?.data?.success) { - urlBackup = data.data.value ?? ""; + if (result.success) { + urlBackup = result.url ?? ""; } else { await dbClient .update(drizzleDb.schemas.restoration) .set({status: "failed"}) .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); - // @ts-ignore - const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL"; + const errorMessage = "Failed to get backup URL"; console.error("Restoration failed: ", errorMessage); - continue; } } catch (err) { @@ -160,9 +165,48 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat .update(drizzleDb.schemas.restoration) .set({status: "failed"}) .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); - continue; } + + + // const fileName = backupToRestore?.file + // + // let data: SafeActionResult, object> | undefined + // + // try { + // + // if (settings.storage == "local") { + // data = await getFileUrlPresignedLocal({fileName: fileName!}) + // } else if (settings.storage == "s3") { + // + // data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`); + // } + // + // if (data?.data?.success) { + // urlBackup = data.data.value ?? ""; + // } else { + // await dbClient + // .update(drizzleDb.schemas.restoration) + // .set({status: "failed"}) + // .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); + // + // // @ts-ignore + // const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL"; + // console.error("Restoration failed: ", errorMessage); + // + // continue; + // } + // } catch (err) { + // console.error("Restoration crashed unexpectedly:", err); + // await dbClient + // .update(drizzleDb.schemas.restoration) + // .set({status: "failed"}) + // .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); + // + // continue; + // } await dbClient .update(drizzleDb.schemas.restoration) .set({status: "ongoing"}) diff --git a/app/api/agent/[agentId]/status/route.ts b/app/api/agent/[agentId]/status/route.ts index d3b31d13..a0749a0b 100644 --- a/app/api/agent/[agentId]/status/route.ts +++ b/app/api/agent/[agentId]/status/route.ts @@ -1,5 +1,4 @@ import {NextResponse} from "next/server"; -import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action"; import {handleDatabases} from "./helpers"; import {eventEmitter} from "../../../events/route"; import * as drizzleDb from "@/db"; @@ -20,13 +19,6 @@ export type Body = { databases: databaseAgent[] } -// Function to test the get file url presigned local -export async function GET(request: Request) { - const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"}) - return Response.json({ - message: url - }) -} export async function POST( request: Request, @@ -38,7 +30,6 @@ export async function POST( const lastContact = new Date(); let message: string - if (!isUuidv4(agentId)) { message = "agentId is not a valid uuid" return NextResponse.json( @@ -57,7 +48,6 @@ export async function POST( } const databasesResponse = await handleDatabases(body, agent, lastContact) - await db .update(drizzleDb.schemas.agent) .set(withUpdatedAt({ diff --git a/app/api/files/[fileName]/route.ts b/app/api/files/[fileName]/route.ts deleted file mode 100644 index f70347e0..00000000 --- a/app/api/files/[fileName]/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as fs from "node:fs"; -import {NextResponse} from "next/server"; -import path from "path"; - -export async function GET( - request: Request, - {params}: { params: Promise<{ fileName: string }> } -) { - - const {searchParams} = new URL(request.url); - const token = searchParams.get('token'); - const expires = searchParams.get('expires'); - const fileName = (await params).fileName - - const uploadsDir = "private/uploads/files/"; - const uploadPath = path.join(uploadsDir, fileName); - - const crypto = require('crypto'); - - let filePath = null; - if (fs.existsSync(uploadPath)) { - filePath = uploadPath; - } else { - return NextResponse.json({error: "File not found"}, {status: 404}) - } - - const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex'); - if (token !== expectedToken) { - return NextResponse.json( - {error: 'Invalid signed token'}, - {status: 403} - ); - } - - const expiresAt = parseInt(expires!, 10); - if (Date.now() > expiresAt) { - return NextResponse.json( - {error: 'Signed token expired'}, - {status: 403} - ); - } - - const fileStream = fs.createReadStream(filePath); - const stream = new ReadableStream({ - start(controller) { - fileStream.on('data', (chunk) => controller.enqueue(chunk)); - fileStream.on('end', () => controller.close()); - fileStream.on('error', (err) => controller.error(err)); - }, - }); - - return new NextResponse(stream, { - headers: { - 'Content-Disposition': `attachment; filename="${fileName}"`, - 'Content-Type': 'application/octet-stream', - }, - }); -} - - diff --git a/app/api/files/route.ts b/app/api/files/route.ts new file mode 100644 index 00000000..b4dc04b4 --- /dev/null +++ b/app/api/files/route.ts @@ -0,0 +1,91 @@ +import {NextResponse} from "next/server"; +import path from "path"; +import {db} from "@/db"; +import {eq} from "drizzle-orm"; +import * as drizzleDb from "@/db"; +import type {StorageInput} from "@/features/storages/types"; +import {dispatchStorage} from "@/features/storages/dispatch"; +import {Readable} from "node:stream"; + +export async function GET( + request: Request, + {params}: { params: Promise<{ fileName: string }> } +) { + + const {searchParams} = new URL(request.url); + const token = searchParams.get('token'); + const expires = searchParams.get('expires'); + const pathFromUrl = searchParams.get('path'); + + if (!pathFromUrl) { + return NextResponse.json({error: "Missing file path in search params"}, {status: 404}) + } + + const localStorageChannel = await db.query.storageChannel.findFirst({ + where: eq(drizzleDb.schemas.storageChannel.provider, "local"), + }) + + if (!localStorageChannel) { + return NextResponse.json({error: "No local storage channel found"}) + } + + const input: StorageInput = { + action: "get", + data: { + path: pathFromUrl, + signedUrl: true, + }, + }; + + const result = await dispatchStorage(input, undefined, localStorageChannel.id); + + if (!result.success) { + return NextResponse.json({error: "Enable to get file from local storage channel, an error occurred !"}) + } + + + const fileName = path.basename(pathFromUrl); + + const crypto = require('crypto'); + const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex'); + if (token !== expectedToken) { + return NextResponse.json( + {error: 'Invalid signed token'}, + {status: 403} + ); + } + + const expiresAt = parseInt(expires!, 10); + if (Date.now() > expiresAt) { + return NextResponse.json( + {error: 'Signed token expired'}, + {status: 403} + ); + } + + if (!result.file || !Buffer.isBuffer(result.file)) { + return NextResponse.json( + {error: "Invalid file payload"}, + {status: 500} + ); + } + + const fileStream = Readable.from(result.file); + + const stream = new ReadableStream({ + start(controller) { + fileStream.on('data', (chunk) => controller.enqueue(chunk)); + fileStream.on('end', () => controller.close()); + fileStream.on('error', (err) => controller.error(err)); + }, + }); + + return new NextResponse(stream, { + headers: { + 'Content-Disposition': `attachment; filename="${fileName}"`, + 'Content-Type': 'application/octet-stream', + }, + }); +} + + diff --git a/proxy.ts b/proxy.ts index 55fd06e4..ac68b7eb 100644 --- a/proxy.ts +++ b/proxy.ts @@ -54,7 +54,7 @@ function checkRouteExists(pathname: string) { /^\/api\/agent\/[^/]+\/status\/?$/, /^\/api\/agent\/[^/]+\/backup\/?$/, /^\/api\/agent\/[^/]+\/restore\/?$/, - /^\/api\/files\/[^/]+\/?$/, + /^\/api\/files\/?$/, /^\/api\/images\/[^/]+\/?$/, /^\/api\/events\/?$/, /^\/api\/init\/?$/, diff --git a/src/components/wrappers/auth/login/login-form/login-form.tsx b/src/components/wrappers/auth/login/login-form/login-form.tsx index cb94fb21..bed6ad47 100644 --- a/src/components/wrappers/auth/login/login-form/login-form.tsx +++ b/src/components/wrappers/auth/login/login-form/login-form.tsx @@ -3,7 +3,6 @@ import {useEffect, useState} from "react"; import {useMutation} from "@tanstack/react-query"; import {toast} from "sonner"; - import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {Input} from "@/components/ui/input"; import {Form} from "@/components/ui/form"; diff --git a/src/components/wrappers/auth/register/register-form/register-form.tsx b/src/components/wrappers/auth/register/register-form/register-form.tsx index 197c2998..4ef63a63 100644 --- a/src/components/wrappers/auth/register/register-form/register-form.tsx +++ b/src/components/wrappers/auth/register/register-form/register-form.tsx @@ -1,21 +1,25 @@ "use client"; import {useRouter} from "next/navigation"; -import {Info} from "lucide-react"; import {useMutation} from "@tanstack/react-query"; import {toast} from "sonner"; - -import {Card, CardContent, CardHeader} from "@/components/ui/card"; -import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; -import {Input} from "@/components/ui/input"; +import {CardContent, CardHeader} from "@/components/ui/card"; import {Form} from "@/components/ui/form"; -import {Button} from "@/components/ui/button"; import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip"; import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema"; -import {PasswordInput} from "@/components/ui/password-input"; import {signUp} from "@/lib/auth/auth-client"; -import Link from "next/link"; +import {useZodForm} from "@/components/ui/form"; import {CardAuth} from "@/features/layout/card-auth"; +import {FormField} from "@/components/ui/form"; +import {FormItem} from "@/components/ui/form"; +import {FormLabel} from "@/components/ui/form"; +import {FormControl} from "@/components/ui/form"; +import {FormMessage} from "@/components/ui/form"; +import {Input} from "@/components/ui/input"; +import {PasswordInput} from "@/components/ui/password-input"; +import {Button} from "@/components/ui/button"; +import Link from "next/link"; +import {Info} from "lucide-react"; export type registerFormProps = { defaultValues?: RegisterType; @@ -26,9 +30,11 @@ export const RegisterForm = (props: registerFormProps) => { const form = useZodForm({ schema: RegisterSchema, }); + const router = useRouter(); const mutation = useMutation({ mutationFn: async (values: RegisterType) => { + // @ts-ignore await signUp.email(values, { onSuccess: () => { toast.success(`Account successfully created`); diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx index 98756abd..b3abec7c 100644 --- a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx @@ -1,5 +1,5 @@ "use client" -import {BackupWith} from "@/db/schema/07_database"; +import {BackupWith, Restoration} from "@/db/schema/07_database"; import React, {useState} from "react"; import {Swiper, SwiperSlide} from "swiper/react"; @@ -7,7 +7,7 @@ import "swiper/css"; import "swiper/css/pagination"; import {Pagination, Mousewheel} from "swiper/modules"; -import {DatabaseActionKind} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; +import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import { BackupActionsSchema, @@ -22,8 +22,14 @@ import {truncateWords} from "@/utils/text"; import {useIsMobile} from "@/hooks/use-mobile"; import {Badge} from "@/components/ui/badge"; import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns"; -import {downloadBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; +import { + createRestorationBackupAction, + downloadBackupAction +} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; import {toast} from "sonner"; +import {SafeActionResult} from "next-safe-action"; +import {ServerActionResult} from "@/types/action-type"; +import {ZodString} from "zod"; type BackupActionsFormProps = { backup: BackupWith; @@ -32,6 +38,7 @@ type BackupActionsFormProps = { export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { const isMobile = useIsMobile(); + const {closeModal} = useBackupModal(); const form = useZodForm({ schema: BackupActionsSchema, @@ -39,18 +46,38 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { const mutation = useMutation({ mutationFn: async (values: BackupActionsType) => { - // implement your mutation logic here - console.log(values); - const result = await downloadBackupAction({backupStorageId: values.backupStorageId}) + let result: SafeActionResult, object> | undefined - const inner = result?.data; - console.log(inner); + if (action === "download") { + result = await downloadBackupAction({backupStorageId: values.backupStorageId}) + } else if (action === "restore") { + result = await createRestorationBackupAction({ + databaseId: backup.databaseId, + backupStorageId: values.backupStorageId, + backupId: backup.id + }) + } + const inner = result?.data; if (inner?.success) { toast.success(inner.actionSuccess?.message); + + if (action === "download") { + console.log(inner.value) + const url = inner.value + if (typeof url === "string") { + window.open(url, "_self"); + } + closeModal() + } else if (action === "restore") { + closeModal() + } + } else { toast.error(inner?.actionError?.message); } @@ -146,7 +173,8 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { )} />
- + Confirm
diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts index 18f638ae..f20e80a3 100644 --- a/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts @@ -7,6 +7,7 @@ import {dispatchStorage} from "@/features/storages/dispatch"; import {db} from "@/db"; import {eq} from "drizzle-orm"; import * as drizzleDb from "@/db"; +import {Restoration} from "@/db/schema/07_database"; export const downloadBackupAction = userAction.schema( @@ -32,9 +33,6 @@ export const downloadBackupAction = userAction.schema( }, }; } - - console.log(backupStorage); - if (backupStorage.status != "success" || !backupStorage.path) { return { success: false, @@ -56,8 +54,6 @@ export const downloadBackupAction = userAction.schema( const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId); - console.log(result); - return { success: true, value: result.url, @@ -78,4 +74,49 @@ export const downloadBackupAction = userAction.schema( }, }; } -}); \ No newline at end of file +}); + + +export const createRestorationBackupAction = userAction + .schema( + z.object({ + backupId: z.string(), + databaseId: z.string(), + backupStorageId: z.string(), + }) + ) + .action(async ({parsedInput}): Promise> => { + try { + const restorationData = await db + .insert(drizzleDb.schemas.restoration) + .values({ + databaseId: parsedInput.databaseId, + backupId: parsedInput.backupId, + backupStorageId: parsedInput.backupStorageId, + status: "waiting", + }) + .returning() + .execute(); + + const createdRestoration = restorationData[0]; + + return { + success: true, + value: createdRestoration, + actionSuccess: { + message: "Restoration has been successfully created.", + messageParams: {restorationId: createdRestoration.id}, + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "Failed to create restoration.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {message: "Error creating the restoration"}, + }, + }; + } + }); diff --git a/src/db/migrations/0026_demonic_santa_claus.sql b/src/db/migrations/0026_demonic_santa_claus.sql new file mode 100644 index 00000000..ec77e081 --- /dev/null +++ b/src/db/migrations/0026_demonic_santa_claus.sql @@ -0,0 +1,2 @@ +ALTER TABLE "restorations" ADD COLUMN "backup_storage_id" uuid;--> statement-breakpoint +ALTER TABLE "restorations" ADD CONSTRAINT "restorations_backup_storage_id_backup_storage_id_fk" FOREIGN KEY ("backup_storage_id") REFERENCES "public"."backup_storage"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/src/db/migrations/meta/0026_snapshot.json b/src/db/migrations/meta/0026_snapshot.json index ba8b1fc6..8110e6cc 100644 --- a/src/db/migrations/meta/0026_snapshot.json +++ b/src/db/migrations/meta/0026_snapshot.json @@ -1,6 +1,6 @@ { - "id": "2767f202-1e4b-42b1-8be0-1fa9dcd48cbb", - "prevId": "a9aa4c81-9ca9-413f-89cb-37361d26927c", + "id": "5d244170-557d-433b-8acd-742ead8246c8", + "prevId": "2767f202-1e4b-42b1-8be0-1fa9dcd48cbb", "version": "7", "dialect": "postgresql", "tables": { @@ -114,25 +114,25 @@ "settings_default_storage_channel_id_storage_channel_id_fk": { "name": "settings_default_storage_channel_id_storage_channel_id_fk", "tableFrom": "settings", + "tableTo": "storage_channel", "columnsFrom": [ "default_storage_channel_id" ], - "tableTo": "storage_channel", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "settings_name_unique": { "name": "settings_name_unique", + "nullsNotDistinct": false, "columns": [ "name" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -235,15 +235,15 @@ "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -336,15 +336,15 @@ "passkey_userId_user_id_fk": { "name": "passkey_userId_user_id_fk", "tableFrom": "passkey", + "tableTo": "user", "columnsFrom": [ "userId" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -431,25 +431,25 @@ "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", + "nullsNotDistinct": false, "columns": [ "token" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -491,15 +491,15 @@ "two_factor_user_id_user_id_fk": { "name": "two_factor_user_id_user_id_fk", "tableFrom": "two_factor", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -614,10 +614,10 @@ "uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", + "nullsNotDistinct": false, "columns": [ "email" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -742,10 +742,10 @@ "uniqueConstraints": { "organization_slug_unique": { "name": "organization_slug_unique", + "nullsNotDistinct": false, "columns": [ "slug" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -806,28 +806,28 @@ "member_organization_id_organization_id_fk": { "name": "member_organization_id_organization_id_fk", "tableFrom": "member", + "tableTo": "organization", "columnsFrom": [ "organization_id" ], - "tableTo": "organization", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "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" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -908,28 +908,28 @@ "invitation_organization_id_organization_id_fk": { "name": "invitation_organization_id_organization_id_fk", "tableFrom": "invitation", + "tableTo": "organization", "columnsFrom": [ "organization_id" ], - "tableTo": "organization", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "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" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -999,25 +999,25 @@ "projects_organization_id_organization_id_fk": { "name": "projects_organization_id_organization_id_fk", "tableFrom": "projects", + "tableTo": "organization", "columnsFrom": [ "organization_id" ], - "tableTo": "organization", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "projects_slug_unique": { "name": "projects_slug_unique", + "nullsNotDistinct": false, "columns": [ "slug" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -1086,15 +1086,15 @@ "backups_database_id_databases_id_fk": { "name": "backups_database_id_databases_id_fk", "tableFrom": "backups", + "tableTo": "databases", "columnsFrom": [ "database_id" ], - "tableTo": "databases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1202,28 +1202,28 @@ "databases_agent_id_agents_id_fk": { "name": "databases_agent_id_agents_id_fk", "tableFrom": "databases", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "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" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1251,6 +1251,12 @@ "notNull": true, "default": "'waiting'" }, + "backup_storage_id": { + "name": "backup_storage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "backup_id": { "name": "backup_id", "type": "uuid", @@ -1285,31 +1291,44 @@ }, "indexes": {}, "foreignKeys": { + "restorations_backup_storage_id_backup_storage_id_fk": { + "name": "restorations_backup_storage_id_backup_storage_id_fk", + "tableFrom": "restorations", + "tableTo": "backup_storage", + "columnsFrom": [ + "backup_storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, "restorations_backup_id_backups_id_fk": { "name": "restorations_backup_id_backups_id_fk", "tableFrom": "restorations", + "tableTo": "backups", "columnsFrom": [ "backup_id" ], - "tableTo": "backups", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "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" ], - "tableTo": "databases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1409,15 +1428,15 @@ "retention_policies_database_id_databases_id_fk": { "name": "retention_policies_database_id_databases_id_fk", "tableFrom": "retention_policies", + "tableTo": "databases", "columnsFrom": [ "database_id" ], - "tableTo": "databases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1500,10 +1519,10 @@ "uniqueConstraints": { "agents_slug_unique": { "name": "agents_slug_unique", + "nullsNotDistinct": false, "columns": [ "slug" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -1578,15 +1597,15 @@ "notification_channel_organization_id_organization_id_fk": { "name": "notification_channel_organization_id_organization_id_fk", "tableFrom": "notification_channel", + "tableTo": "organization", "columnsFrom": [ "organization_id" ], - "tableTo": "organization", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1617,39 +1636,39 @@ "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" ], - "tableTo": "organization", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "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" ], - "tableTo": "notification_channel", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "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" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -1718,28 +1737,28 @@ "alert_policy_notification_channel_id_notification_channel_id_fk": { "name": "alert_policy_notification_channel_id_notification_channel_id_fk", "tableFrom": "alert_policy", + "tableTo": "notification_channel", "columnsFrom": [ "notification_channel_id" ], - "tableTo": "notification_channel", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "alert_policy_database_id_databases_id_fk": { "name": "alert_policy_database_id_databases_id_fk", "tableFrom": "alert_policy", + "tableTo": "databases", "columnsFrom": [ "database_id" ], - "tableTo": "databases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1895,39 +1914,39 @@ "organization_storage_channels_organization_id_organization_id_fk": { "name": "organization_storage_channels_organization_id_organization_id_fk", "tableFrom": "organization_storage_channels", + "tableTo": "organization", "columnsFrom": [ "organization_id" ], - "tableTo": "organization", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "organization_storage_channels_storage_channel_id_storage_channel_id_fk": { "name": "organization_storage_channels_storage_channel_id_storage_channel_id_fk", "tableFrom": "organization_storage_channels", + "tableTo": "storage_channel", "columnsFrom": [ "storage_channel_id" ], - "tableTo": "storage_channel", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "organization_storage_channels_organization_id_storage_channel_id_unique": { "name": "organization_storage_channels_organization_id_storage_channel_id_unique", + "nullsNotDistinct": false, "columns": [ "organization_id", "storage_channel_id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -2002,15 +2021,15 @@ "storage_channel_organization_id_organization_id_fk": { "name": "storage_channel_organization_id_organization_id_fk", "tableFrom": "storage_channel", + "tableTo": "organization", "columnsFrom": [ "organization_id" ], - "tableTo": "organization", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -2074,28 +2093,28 @@ "storage_policy_storage_channel_id_storage_channel_id_fk": { "name": "storage_policy_storage_channel_id_storage_channel_id_fk", "tableFrom": "storage_policy", + "tableTo": "storage_channel", "columnsFrom": [ "storage_channel_id" ], - "tableTo": "storage_channel", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "storage_policy_database_id_databases_id_fk": { "name": "storage_policy_database_id_databases_id_fk", "tableFrom": "storage_policy", + "tableTo": "databases", "columnsFrom": [ "database_id" ], - "tableTo": "databases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -2178,28 +2197,28 @@ "backup_storage_backup_id_backups_id_fk": { "name": "backup_storage_backup_id_backups_id_fk", "tableFrom": "backup_storage", + "tableTo": "backups", "columnsFrom": [ "backup_id" ], - "tableTo": "backups", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "backup_storage_storage_channel_id_storage_channel_id_fk": { "name": "backup_storage_storage_channel_id_storage_channel_id_fk", "tableFrom": "backup_storage", + "tableTo": "storage_channel", "columnsFrom": [ "storage_channel_id" ], - "tableTo": "storage_channel", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -2306,10 +2325,10 @@ } }, "schemas": {}, - "views": {}, "sequences": {}, "roles": {}, "policies": {}, + "views": {}, "_meta": { "columns": {}, "schemas": {}, diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 20f2f7cc..35fe3740 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1768424936753, "tag": "0025_past_franklin_richards", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1768665081232, + "tag": "0026_demonic_santa_claus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/07_database.ts b/src/db/schema/07_database.ts index 4e14e1dd..73966b5a 100644 --- a/src/db/schema/07_database.ts +++ b/src/db/schema/07_database.ts @@ -9,6 +9,7 @@ import {timestamps} from "@/db/schema/00_common"; import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy"; import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy"; import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup"; +import {storageChannel} from "@/db/schema/12_storage-channel"; export const database = pgTable("databases", { id: uuid("id").primaryKey().defaultRandom(), @@ -63,6 +64,8 @@ export const retentionPolicy = pgTable("retention_policies", { export const restoration = pgTable("restorations", { id: uuid("id").primaryKey().defaultRandom(), status: statusEnum("status").default("waiting").notNull(), + backupStorageId: uuid("backup_storage_id") + .references(() => backupStorage.id, {onDelete: "cascade"}), backupId: uuid("backup_id") .notNull() .references(() => backup.id, {onDelete: "cascade"}), @@ -93,6 +96,7 @@ export const backupRelations = relations(backup, ({one, many}) => ({ export const restorationRelations = relations(restoration, ({one}) => ({ backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}), database: one(database, {fields: [restoration.databaseId], references: [database.id]}), + backupStorage: one(backupStorage, {fields: [restoration.backupStorageId], references: [backupStorage.id]}), })); diff --git a/src/features/storages/providers/local.ts b/src/features/storages/providers/local.ts index 6114597b..e6924f9d 100644 --- a/src/features/storages/providers/local.ts +++ b/src/features/storages/providers/local.ts @@ -49,11 +49,18 @@ export async function getLocal( const expiresAt = Date.now() + 60 * 1000; const token = crypto.createHash("sha256").update(`${fileName}${expiresAt}`).digest("hex"); + const params = new URLSearchParams({ + path: input.data.path, + token, + expires: expiresAt.toString(), + }); + + return { success: true, provider: 'local', file: file, - url: `${baseUrl}/api/files/${fileName}?token=${token}&expires=${expiresAt}`, + url: `${baseUrl}/api/files/?${params.toString()}`, }; } From 83ef0ee74ef5aae90e686e0e190784ed84204fb4 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Sat, 17 Jan 2026 19:23:15 +0100 Subject: [PATCH 10/24] feat: backup delete/restore actions. --- app/api/agent/[agentId]/backup/route.ts | 34 --- app/api/agent/[agentId]/status/helpers.ts | 2 +- .../backup/actions/backup-actions-form.tsx | 230 ++++++++++-------- .../backup/actions/backup-actions-modal.tsx | 5 +- .../backup/actions/backup-actions.action.ts | 219 ++++++++++++++++- .../database/backup/backup-modal-context.tsx | 3 + .../database/database-backup-list.tsx | 17 +- 7 files changed, 370 insertions(+), 140 deletions(-) diff --git a/app/api/agent/[agentId]/backup/route.ts b/app/api/agent/[agentId]/backup/route.ts index 8ebe25d6..e1897eef 100644 --- a/app/api/agent/[agentId]/backup/route.ts +++ b/app/api/agent/[agentId]/backup/route.ts @@ -129,41 +129,7 @@ export async function POST( const fileName = `${uuid}${fileExtension}`; const buffer = Buffer.from(await decryptedFile.arrayBuffer()); - - - // const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - // if (!settings) { - // throw new Error("System settings not found."); - // } - - // let success: boolean, message: string, filePath: string; - // - // const result = - // settings.storage === "local" - // ? await uploadLocalPrivate(fileName, buffer) - // : await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!); - // - // ({success, message, filePath} = result); - // - // if (!success) { - // return NextResponse.json( - // {error: message}, - // {status: 500} - // ); - // } - await storeBackupFiles(backup, database, buffer, fileName) - - // - // await db - // .update(drizzleDb.schemas.backup) - // .set(withUpdatedAt({ - // file: fileName, - // fileSize: fileSizeBytes, - // status: 'success', - // })) - // .where(eq(drizzleDb.schemas.backup.id, backup.id)); - eventEmitter.emit('modification', {update: true}); await sendNotificationsBackupRestore(database, "success_backup"); diff --git a/app/api/agent/[agentId]/status/helpers.ts b/app/api/agent/[agentId]/status/helpers.ts index 1fe3f186..88dd6d5c 100644 --- a/app/api/agent/[agentId]/status/helpers.ts +++ b/app/api/agent/[agentId]/status/helpers.ts @@ -131,7 +131,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) { restoreAction = false - return; + continue; } diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx index b3abec7c..d8f61648 100644 --- a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx @@ -1,11 +1,9 @@ "use client" -import {BackupWith, Restoration} from "@/db/schema/07_database"; -import React, {useState} from "react"; +import {Backup, BackupWith, Restoration} from "@/db/schema/07_database"; +import React from "react"; import {Swiper, SwiperSlide} from "swiper/react"; - import "swiper/css"; import "swiper/css/pagination"; - import {Pagination, Mousewheel} from "swiper/modules"; import {DatabaseActionKind, useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; @@ -23,7 +21,7 @@ import {useIsMobile} from "@/hooks/use-mobile"; import {Badge} from "@/components/ui/badge"; import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns"; import { - createRestorationBackupAction, + createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction, downloadBackupAction } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; import {toast} from "sonner"; @@ -37,6 +35,7 @@ type BackupActionsFormProps = { } export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { + const isMobile = useIsMobile(); const {closeModal} = useBackupModal(); @@ -49,8 +48,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { let result: SafeActionResult, object> | undefined - + }, readonly [], ServerActionResult, object> | undefined if (action === "download") { result = await downloadBackupAction({backupStorageId: values.backupStorageId}) @@ -60,13 +58,18 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { backupStorageId: values.backupStorageId, backupId: backup.id }) + } else if (action === "delete") { + result = await deleteBackupStorageAction({ + databaseId: backup.databaseId, + backupStorageId: values.backupStorageId, + backupId: backup.id, + }) } const inner = result?.data; if (inner?.success) { toast.success(inner.actionSuccess?.message); - if (action === "download") { console.log(inner.value) const url = inner.value @@ -76,110 +79,147 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { closeModal() } else if (action === "restore") { closeModal() + } else if (action === "delete") { + closeModal() + } else { + closeModal() } - } else { - toast.error(inner?.actionError?.message); + if (action === "delete") { + toast.success("Backup deleted successfully.") + closeModal() + } else { + toast.error(inner?.actionError?.message); + } } + }, + }); + const mutationDeleteEntireBackup = useMutation({ + mutationFn: async () => { + console.log("mutation deleteEntireBackup"); + + const result = await deleteBackupAction({ + databaseId: backup.databaseId, + backupId: backup.id, + }) + + const inner = result?.data; + + if (inner?.success) { + toast.success(inner.actionSuccess?.message); + closeModal() + } else { + toast.error(inner?.actionError?.message); + } }, }); - return ( - - - {action == "delete" ? - <> - - : -
{ - await mutation.mutateAsync(values); - }} - > - ( - - Choose a storage backup - -
- - {backup.storages?.map((storage: BackupStorageWith) => ( - - - - - )) ??

No storages available

} -
-
-
- -
- )} - /> -
- - Confirm +
+ + + )) ??

No storages available

} + +
+ + + + )} + /> +
+ {action === "delete" && ( + mutationDeleteEntireBackup.mutateAsync()} + isPending={mutationDeleteEntireBackup.isPending} + disabled={mutationDeleteEntireBackup.isPending} + > + Delete All -
- - } + )} + + Confirm + +
+ + ); diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx index a092f5b6..4ab099ce 100644 --- a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-modal.tsx @@ -22,13 +22,14 @@ export const DatabaseBackupActionsModal = ({}: DatabaseActionsModalProps) => { if (!backup || !action) return null; const text = getBackupActionTextBasedOnActionKind(action); + return ( - {text} storage backup ? + {text} backup ? - Select the backup storage you want to {text.toLowerCase()} + Select the backup storage diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts index f20e80a3..c0e53b6f 100644 --- a/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions.action.ts @@ -5,9 +5,10 @@ import {z} from "zod"; import type {StorageInput} from "@/features/storages/types"; import {dispatchStorage} from "@/features/storages/dispatch"; import {db} from "@/db"; -import {eq} from "drizzle-orm"; +import {and, eq, isNull, ne, sql} from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {Restoration} from "@/db/schema/07_database"; +import {Backup, Restoration} from "@/db/schema/07_database"; +import {withUpdatedAt} from "@/db/utils"; export const downloadBackupAction = userAction.schema( @@ -120,3 +121,217 @@ export const createRestorationBackupAction = userAction }; } }); + + +export const deleteBackupStorageAction = userAction + .schema( + z.object({ + backupId: z.string(), + databaseId: z.string(), + backupStorageId: z.string(), + }) + ) + .action(async ({parsedInput}): Promise> => { + const {backupId, databaseId, backupStorageId} = parsedInput; + + try { + const backup = await db.query.backup.findFirst({ + where: and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId)) + }); + + if (!backup) { + return { + success: false, + actionError: { + message: "Backup not found.", + status: 404, + messageParams: {backupStorageId: backupStorageId}, + }, + } + } + + + const backupStorage = await db.query.backupStorage.findFirst({ + where: eq(drizzleDb.schemas.backupStorage.id, backupStorageId), + }); + + + if (!backupStorage) { + return { + success: false, + actionError: { + message: "Backup storage not found.", + status: 404, + messageParams: {backupStorageId: backupStorageId}, + }, + }; + } + + + const [{count}] = await db + .select({count: sql`count(*)`}) + .from(drizzleDb.schemas.backupStorage) + .where( + and( + eq(drizzleDb.schemas.backupStorage.backupId, backupId), + isNull(drizzleDb.schemas.backupStorage.deletedAt), + ne(drizzleDb.schemas.backupStorage.id, backupStorageId) + ) + ); + + + if (Number(count) === 0) { + await db + .update(drizzleDb.schemas.backup) + .set(withUpdatedAt({ + deletedAt: new Date(), + status: backup.status == "ongoing" ? "failed" : backup.status + })) + .where(and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId))) + } + + await db + .update(drizzleDb.schemas.backupStorage) + .set(withUpdatedAt({ + deletedAt: new Date(), + })) + .where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId)) + + + if (backupStorage.status != "success" || !backupStorage.path) { + return { + success: false, + actionError: { + message: "An error occurred.", + status: 500, + messageParams: {backupStorageId: backupStorageId}, + }, + } + } + + + const input: StorageInput = { + action: "delete", + data: { + path: backupStorage.path, + }, + }; + + await dispatchStorage(input, undefined, backupStorage.storageChannelId); + + return { + success: true, + value: backup, + actionSuccess: { + message: `Backup deleted successfully.`, + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "Failed to delete backup.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {message: "Error deleting the backup"}, + }, + }; + } + }); + + +export const deleteBackupAction = userAction + .schema( + z.object({ + backupId: z.string(), + databaseId: z.string(), + }) + ) + .action(async ({parsedInput}): Promise> => { + const {backupId, databaseId} = parsedInput; + + try { + const backup = await db.query.backup.findFirst({ + where: and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId)) + }); + + if (!backup) { + return { + success: false, + actionError: { + message: "Backup not found.", + status: 404, + messageParams: {backupId: backupId}, + }, + } + } + + + const backupStorages = await db.query.backupStorage.findMany({ + where: eq(drizzleDb.schemas.backupStorage.backupId, backupId), + }); + + + if (!backupStorages) { + return { + success: false, + actionError: { + message: "Backup storage not found.", + status: 404, + messageParams: {backupId: backupId}, + }, + }; + } + + for (const backupStorage of backupStorages) { + + await db + .update(drizzleDb.schemas.backupStorage) + .set(withUpdatedAt({ + deletedAt: new Date(), + })) + .where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id)) + + if (backupStorage.status != "success" || !backupStorage.path) { + continue; + } + + const input: StorageInput = { + action: "delete", + data: { + path: backupStorage.path, + }, + }; + + await dispatchStorage(input, undefined, backupStorage.storageChannelId); + + } + + await db + .update(drizzleDb.schemas.backup) + .set(withUpdatedAt({ + deletedAt: new Date(), + status: backup.status == "ongoing" ? "failed" : backup.status + })) + .where(and(eq(drizzleDb.schemas.backup.id, backupId), eq(drizzleDb.schemas.backup.databaseId, databaseId))) + + + return { + success: true, + value: backup, + actionSuccess: { + message: `Backup deleted successfully (ref: ${parsedInput.backupId}).`, + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "Failed to delete backup.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + messageParams: {message: "Error deleting the backup"}, + }, + }; + } + }); \ No newline at end of file diff --git a/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx b/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx index 5fa81aa8..cdfa34ea 100644 --- a/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx +++ b/src/components/wrappers/dashboard/database/backup/backup-modal-context.tsx @@ -2,6 +2,7 @@ import {createContext, useContext, useState, ReactNode} from "react"; import {BackupWith} from "@/db/schema/07_database"; +import {useRouter} from "next/navigation"; export type DatabaseActionKind = "restore" | "download" | "delete"; @@ -31,6 +32,7 @@ const BackupModalContext = createContext(und export const BackupModalProvider = ({children}: { children: ReactNode }) => { const [open, setOpen] = useState(false); + const router = useRouter(); const [action, setAction] = useState(null); const [backup, setBackup] = useState(null); @@ -44,6 +46,7 @@ export const BackupModalProvider = ({children}: { children: ReactNode }) => { setOpen(false); setAction(null); setBackup(null); + router.refresh() }; return ( diff --git a/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx b/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx index c77f2730..39c83bec 100644 --- a/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx +++ b/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx @@ -9,10 +9,10 @@ import {useMemo, useState} from "react"; import {Backup, BackupWith, DatabaseWith} from "@/db/schema/07_database"; import {Setting} from "@/db/schema/01_setting"; import {useMutation} from "@tanstack/react-query"; -import {deleteBackupAction} from "@/features/dashboard/restore/restore.action"; import {toast} from "sonner"; import {useRouter} from "next/navigation"; import {MemberWithUser} from "@/db/schema/03_organization"; +import {deleteBackupAction} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; type DatabaseBackupListProps = { @@ -65,13 +65,18 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => { const results = await Promise.all( backups.map(async (backup) => { if (backup.deletedAt == null) { + const backupDeleted = await deleteBackupAction({ - backupId: backup.id, databaseId: backup.databaseId, - status: backup.status, - file: backup.file ?? "", - projectSlug: props.database?.project?.slug! - }); + backupId: backup.id, + }) + // const backupDeleted = await deleteBackupAction({ + // backupId: backup.id, + // databaseId: backup.databaseId, + // status: backup.status, + // file: backup.file ?? "", + // projectSlug: props.database?.project?.slug! + // }); return { success: backupDeleted?.data?.success, message: backupDeleted?.data?.success From 424fa581b9d52e501b65cf55ae94be1c4eea8019 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Sat, 17 Jan 2026 19:55:17 +0100 Subject: [PATCH 11/24] feat: backup delete/restore actions. --- .../common/button/button-with-confirm.tsx | 50 +--- .../backup/actions/backup-actions-cell.tsx | 34 ++- .../backup/actions/backup-actions-form.tsx | 218 +++++++++------ src/features/dashboard/backup/columns.tsx | 262 +++++++++--------- 4 files changed, 294 insertions(+), 270 deletions(-) diff --git a/src/components/wrappers/common/button/button-with-confirm.tsx b/src/components/wrappers/common/button/button-with-confirm.tsx index aa0a3d40..e4472e09 100644 --- a/src/components/wrappers/common/button/button-with-confirm.tsx +++ b/src/components/wrappers/common/button/button-with-confirm.tsx @@ -1,48 +1,3 @@ -// "use client"; -// -// import { Button } from "@/components/ui/button"; -// import { useState } from "react"; -// import { Loader2 } from "lucide-react"; -// -// export type VariantButton = { -// secondary: string; -// default: string; -// outline: string; -// ghost: string; -// link: string; -// destructive: string; -// }; -// -// export type ButtonWithConfirmProps = { -// icon?: any; -// text: string; -// variant?: keyof VariantButton; -// className?: string; -// onClick?: () => void; -// isPending?: boolean; -// }; -// -// export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => { -// const [isConfirming, setIsConfirming] = useState(false); -// -// return ( -// -// ); -// }; "use client" import {Button, ButtonVariantsProps} from "@/components/ui/button"; import {useState} from "react"; @@ -57,6 +12,7 @@ export type ButtonWithConfirmProps = { button: { main: { className?: string; + type?: "button" | "submit" | "reset" | undefined; text?: string; icon?: any; variant?: ButtonVariantsProps["variant"]; @@ -100,6 +56,7 @@ export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => { )} role="button">
diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx index bc7a7b6e..db3d42ac 100644 --- a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-cell.tsx @@ -15,19 +15,17 @@ import {MoreHorizontal, Trash2, Download} from "lucide-react"; import {ReloadIcon} from "@radix-ui/react-icons"; import {cn} from "@/lib/utils"; import {MemberWithUser} from "@/db/schema/03_organization"; -import {useMutation} from "@tanstack/react-query"; -import {deleteBackupAction} from "@/features/dashboard/restore/restore.action"; -import {toast} from "sonner"; +import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom"; interface DatabaseActionsCellProps { backup: BackupWith; activeMember: MemberWithUser; + isAlreadyRestore: boolean; } -export function DatabaseActionsCell({backup, activeMember}: DatabaseActionsCellProps) { +export function DatabaseActionsCell({backup, activeMember, isAlreadyRestore}: DatabaseActionsCellProps) { const {openModal} = useBackupModal(); - if (backup.deletedAt || activeMember.role === "member") return null; @@ -42,12 +40,26 @@ export function DatabaseActionsCell({backup, activeMember}: DatabaseActionsCellP Actions - openModal("restore", backup)}> - Restore - - openModal("download", backup)}> - Download - + + {backup.status == "success" ? ( + <> + + openModal("restore", backup)} + > + Restore + + + openModal("download", backup)} + > + Download + + + ) : null} + openModal("delete", backup)} className="text-red-600"> Delete diff --git a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx index d8f61648..6167ffe2 100644 --- a/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx +++ b/src/components/wrappers/dashboard/database/backup/actions/backup-actions-form.tsx @@ -28,6 +28,9 @@ import {toast} from "sonner"; import {SafeActionResult} from "next-safe-action"; import {ServerActionResult} from "@/types/action-type"; import {ZodString} from "zod"; +import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; +import {AlertCircleIcon, Trash2} from "lucide-react"; +import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm"; type BackupActionsFormProps = { backup: BackupWith; @@ -36,6 +39,7 @@ type BackupActionsFormProps = { export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { + const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? [] const isMobile = useIsMobile(); const {closeModal} = useBackupModal(); @@ -98,8 +102,6 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { const mutationDeleteEntireBackup = useMutation({ mutationFn: async () => { - console.log("mutation deleteEntireBackup"); - const result = await deleteBackupAction({ databaseId: backup.databaseId, backupId: backup.id, @@ -118,6 +120,8 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { return ( + +
{ await mutation.mutateAsync(values); }} > - ( - - Choose a storage backup - -
- - {backup.storages?.filter((storage) => storage.deletedAt === null).map((storage: BackupStorageWith) => ( - - - - )) ??

No storages available

} -
-
-
- -
- )} - /> + + + )) ??

No storages available

} + +
+ + + + )} + /> + + : + + + Backup does not have files + +

You can safely delete the entire backup; no files seem to be related. Maybe an error + occurred.

+
+
+ } +
{action === "delete" && ( - mutationDeleteEntireBackup.mutateAsync()} + // mutationDeleteEntireBackup.mutateAsync()} + // isPending={mutationDeleteEntireBackup.isPending} + // disabled={mutationDeleteEntireBackup.isPending} + // > + // Delete entire backup + // + + + , + variant: "destructive", + onClick: async () => { + mutationDeleteEntireBackup.mutateAsync() + }, + }, + cancel: { + className: "w-full", + text: "Cancel", + icon: , + variant: "outline", + }, + }} isPending={mutationDeleteEntireBackup.isPending} - disabled={mutationDeleteEntireBackup.isPending} + /> + + )} + + {filteredBackupStorages.length > 0 && ( + - Delete All + Confirm + )} - - Confirm -
- - ); } diff --git a/src/features/dashboard/backup/columns.tsx b/src/features/dashboard/backup/columns.tsx index 36e0ccd2..133f5255 100644 --- a/src/features/dashboard/backup/columns.tsx +++ b/src/features/dashboard/backup/columns.tsx @@ -93,139 +93,139 @@ export function backupColumns( return ; }, }, - { - id: "actions2", - cell: ({row}) => , - }, { id: "actions", - cell: ({row, table}) => { - const status = row.getValue("status"); - const rowData: Backup = row.original; - const fileName = rowData.file; - - const router = useRouter(); - - const mutationRestore = useMutation({ - mutationFn: async () => { - const restoration = await createRestorationAction({ - backupId: rowData.id, - databaseId: rowData.databaseId, - }); - // @ts-ignore - if (restoration.data.success) { - // @ts-ignore - toast.success(restoration.data.actionSuccess?.message || "Restoration created successfully!"); - router.refresh(); - } else { - // @ts-ignore - toast.error(restoration.serverError || "Failed to create restoration."); - } - }, - }); - - const mutationDeleteBackup = useMutation({ - mutationFn: async () => { - const deletion = await deleteBackupAction({ - backupId: rowData.id, - databaseId: rowData.databaseId, - status: rowData.status, - file: rowData.file ?? "", - projectSlug: database.project?.slug! - }); - // @ts-ignore - if (deletion.data.success) { - // @ts-ignore - toast.success(deletion.data.actionSuccess.message); - router.refresh(); - } else { - // @ts-ignore - toast.error(deletion.data.actionError.message); - } - }, - }); - - const handleRestore = async () => { - await mutationRestore.mutateAsync(); - }; - - const handleDelete = async () => { - await mutationDeleteBackup.mutateAsync(); - }; - - const handleDownload = async (fileName: string) => { - - let url: string = ""; - let data: SafeActionResult, object> | undefined - - if (settings.storage == "local") { - data = await getFileUrlPresignedLocal({fileName: fileName!}) - } else if (settings.storage == "s3") { - data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`); - } - if (data?.data?.success) { - url = data.data.value ?? ""; - } else { - // @ts-ignore - const errorMessage = data?.data?.actionError?.message || "Failed to get file!"; - toast.error(errorMessage); - } - - window.open(url, "_self"); - }; - - return ( - <> - {(rowData.deletedAt == null && activeMember.role != "member") && ( - - - - - - Actions - {status == "success" ? ( - <> - - { - await handleRestore(); - }} - > - Restore - - - { - await handleDownload(fileName ?? ""); - }} - > - Download - - - ) : null} - - { - await handleDelete(); - }} - > - Delete - - - - )} - - - ); - }, + cell: ({row}) => , }, + // { + // id: "actions", + // cell: ({row, table}) => { + // const status = row.getValue("status"); + // const rowData: Backup = row.original; + // const fileName = rowData.file; + // + // const router = useRouter(); + // + // const mutationRestore = useMutation({ + // mutationFn: async () => { + // const restoration = await createRestorationAction({ + // backupId: rowData.id, + // databaseId: rowData.databaseId, + // }); + // // @ts-ignore + // if (restoration.data.success) { + // // @ts-ignore + // toast.success(restoration.data.actionSuccess?.message || "Restoration created successfully!"); + // router.refresh(); + // } else { + // // @ts-ignore + // toast.error(restoration.serverError || "Failed to create restoration."); + // } + // }, + // }); + // + // const mutationDeleteBackup = useMutation({ + // mutationFn: async () => { + // const deletion = await deleteBackupAction({ + // backupId: rowData.id, + // databaseId: rowData.databaseId, + // status: rowData.status, + // file: rowData.file ?? "", + // projectSlug: database.project?.slug! + // }); + // // @ts-ignore + // if (deletion.data.success) { + // // @ts-ignore + // toast.success(deletion.data.actionSuccess.message); + // router.refresh(); + // } else { + // // @ts-ignore + // toast.error(deletion.data.actionError.message); + // } + // }, + // }); + // + // const handleRestore = async () => { + // await mutationRestore.mutateAsync(); + // }; + // + // const handleDelete = async () => { + // await mutationDeleteBackup.mutateAsync(); + // }; + // + // const handleDownload = async (fileName: string) => { + // + // let url: string = ""; + // let data: SafeActionResult, object> | undefined + // + // if (settings.storage == "local") { + // data = await getFileUrlPresignedLocal({fileName: fileName!}) + // } else if (settings.storage == "s3") { + // data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`); + // } + // if (data?.data?.success) { + // url = data.data.value ?? ""; + // } else { + // // @ts-ignore + // const errorMessage = data?.data?.actionError?.message || "Failed to get file!"; + // toast.error(errorMessage); + // } + // + // window.open(url, "_self"); + // }; + // + // return ( + // <> + // {(rowData.deletedAt == null && activeMember.role != "member") && ( + // + // + // + // + // + // Actions + // {status == "success" ? ( + // <> + // + // { + // await handleRestore(); + // }} + // > + // Restore + // + // + // { + // await handleDownload(fileName ?? ""); + // }} + // > + // Download + // + // + // ) : null} + // + // { + // await handleDelete(); + // }} + // > + // Delete + // + // + // + // )} + // + // + // ); + // }, + // }, ]; } \ No newline at end of file From 53f82792d82c34e695061affbd2c8396f742ade2 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Sat, 17 Jan 2026 20:56:57 +0100 Subject: [PATCH 12/24] fix: Upload with new storage backend system. --- .../database/[databaseId]/page.tsx | 4 - .../admin/users2/accounts/table-columns.tsx | 75 - .../admin/users2/admin-user-table.tsx | 29 - .../admin/users2/button-delete-use.tsx | 61 - .../dashboard/admin/users2/columns-users.tsx | 108 - .../admin/users2/sessions/table-columns.tsx | 89 - .../database/import/upload-backup.action.ts | 79 +- .../button-delete-account.tsx | 61 - .../delete-account.action.ts | 36 - .../profile2/user-form/user-form.action.ts | 43 - .../profile2/user-form/user-form.schema.ts | 9 - .../0027_special_the_santerians.sql | 1 + src/db/migrations/meta/0027_snapshot.json | 2344 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema/07_database.ts | 1 + src/features/dashboard/backup/columns.tsx | 3 +- 16 files changed, 2395 insertions(+), 555 deletions(-) delete mode 100644 src/components/wrappers/dashboard/admin/users2/accounts/table-columns.tsx delete mode 100644 src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx delete mode 100644 src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx delete mode 100644 src/components/wrappers/dashboard/admin/users2/columns-users.tsx delete mode 100644 src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx delete mode 100644 src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx delete mode 100644 src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts delete mode 100644 src/components/wrappers/dashboard/profile2/user-form/user-form.action.ts delete mode 100644 src/components/wrappers/dashboard/profile2/user-form/user-form.schema.ts create mode 100644 src/db/migrations/0027_special_the_santerians.sql create mode 100644 src/db/migrations/meta/0027_snapshot.json diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index a2b91e42..3cc4888a 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -115,12 +115,8 @@ export default async function RoutePage(props: PageParams<{ {!isMember && (
- {/* Do not delete*/} - {/**/} - {/**/} [] = [ - { - id: "provider", - header: "Provider", - cell: ({row}) => { - return ( -
- {providerSwitch(row.original.providerId)} -
- - ) - - }, - }, - { - header: "Action", - id: "actions", - cell: ({row, table}) => { - const router = useRouter(); - - const mutation = useMutation({ - mutationFn: async () => { - if (row.original.providerId === "credential") { - toast.error(`This provider cannot be unlinked.`); - router.refresh(); - return; - } - - if (table.getRowModel().rows.length <= 1) { - toast.error(`You only have one provider linked to your account. Please add more one to unlink this`); - router.refresh(); - return; - } - - const status = await unlinkUserProviderAction({ - provider: row.original.providerId, - account: row.original.accountId, - }); - - if (status?.serverError || !status) { - toast.error(status?.serverError); - return; - } - toast.success(`Provider unlinked successfully.`); - router.refresh(); - }, - }); - - return ( -
- } - onClick={async () => { - await mutation.mutateAsync(); - }} - size="icon" - /> -
- ); - }, - }, -]; diff --git a/src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx b/src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx deleted file mode 100644 index 8bfd93e7..00000000 --- a/src/components/wrappers/dashboard/admin/users2/admin-user-table.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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/users/columns-users"; - -export type AdminUsersTableProps = { - users: UserWithAccounts[]; - -}; - -export const AdminUsersTable = (props: AdminUsersTableProps) => { - const {users} = props; - return ( -
- - - Active users - Manage your users - - - - - -
- ); -}; diff --git a/src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx b/src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx deleted file mode 100644 index 80886fa4..00000000 --- a/src/components/wrappers/dashboard/admin/users2/button-delete-use.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import {Trash2} from "lucide-react"; -import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm"; -import {useMutation} from "@tanstack/react-query"; -import {useRouter} from "next/navigation"; -import {toast} from "sonner"; -import {deleteUserAction} from "@/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action"; - -export type ButtonDeleteUserProps = { - userId: string; - disabled?: boolean; -}; - -export const ButtonDeleteUser = (props: ButtonDeleteUserProps) => { - const router = useRouter(); - - const mutation = useMutation({ - mutationFn: () => deleteUserAction(props.userId), - onSuccess: async () => { - toast.success("User deleted successfully."); - router.refresh(); - }, - }); - - 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/admin/users2/columns-users.tsx b/src/components/wrappers/dashboard/admin/users2/columns-users.tsx deleted file mode 100644 index 669e6fa5..00000000 --- a/src/components/wrappers/dashboard/admin/users2/columns-users.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client" -import {ColumnDef} from "@tanstack/react-table"; -import {Badge} from "@/components/ui/badge"; -import {updateUserAction} from "@/components/wrappers/dashboard/profile2/user-form/user-form.action"; -import {useMutation} from "@tanstack/react-query"; -import {toast} from "sonner"; -import {useRouter} from "next/navigation"; -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/users/button-delete-use"; -import {formatLocalizedDate} from "@/utils/date-formatting"; - -export const usersColumnsAdmin: ColumnDef[] = [ - { - accessorKey: "role", - header: "Role", - cell: ({row}) => { - const [role, setRole] = useState(row.getValue("role")); - - const {data: session, isPending, error} = authClient.useSession(); - const isSuperAdmin = session?.user.role == "superadmin"; - const isCurrentUser = session?.user.email === row.original.email; - - const updateMutation = useMutation({ - mutationFn: () => updateUserAction({id: row.original.id, data: {role: role}}), - onSuccess: () => { - toast.success(`User updated successfully.`); - }, - onError: () => { - toast.error(`An error occurred while updating user information.`); - }, - }); - - const handleUpdateRole = async () => { - const nextRole = role === "admin" ? "pending" : role === "pending" ? "user" : "admin"; - setRole(nextRole); - await updateMutation.mutateAsync(); - }; - - - if (isPending) return null; - - if (error || !session) { - return null; - } - - - return ( - handleUpdateRole()} - variant="outline" - > - {role} - - ); - }, - }, - { - accessorKey: "name", - header: "Name", - }, - { - accessorKey: "email", - header: "Email", - }, - { - accessorKey: "accounts", - header: "Provider(s)", - cell: ({row}) => { - return ( -
- {row.original.accounts.map((item) => ( -
- {providerSwitch(item.providerId, true)} -
- ))} -
- ) - } - }, - { - accessorKey: "updatedAt", - header: "Updated At", - cell: ({row}) => { - return formatLocalizedDate(row.getValue("updatedAt")) - }, - }, - { - header: "Action", - id: "actions", - cell: ({row}) => { - const router = useRouter(); - const {data: session, isPending, error} = useSession(); - const isSuperAdmin = session?.user.role == "superadmin"; - - if (isPending || error) return null; - - return ( - - ); - }, - }, -]; diff --git a/src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx b/src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx deleted file mode 100644 index 8c6e4760..00000000 --- a/src/components/wrappers/dashboard/admin/users2/sessions/table-columns.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; -import {useMutation} from "@tanstack/react-query"; -import {ColumnDef} from "@tanstack/react-table"; -import {Session} from "better-auth"; -import {Unlink} from "lucide-react"; -import {toast} from "sonner"; -import {useRouter} from "next/navigation"; -import detectOSWithUA from "@/utils/os-parser"; -import {Icon} from "@iconify/react"; -import {authClient} from "@/lib/auth/auth-client"; -import {timeAgo} from "@/utils/date-formatting"; -import {deleteUserSessionAction} from "@/components/wrappers/dashboard/profile2/user-form/user-form.action"; - -export const sessionsColumns: ColumnDef[] = [ - { - accessorKey: "expiresAt", - header: "Expires At", - cell: ({row}) => { - return timeAgo(row.original.expiresAt); - }, - }, - { - id: "device", - header: "Device", - cell: ({row}) => { - const os = detectOSWithUA(row.original.userAgent!); - - return ( -
- {os.icon && - } - {os.showText && {os.name}} -
- ); - }, - }, - { - accessorKey: "userAgent", - header: "User Agent", - }, - { - header: "Action", - id: "actions", - cell: ({row}) => { - const router = useRouter(); - - const {data: session, isPending, error} = authClient.useSession(); - - - const mutation = useMutation({ - mutationFn: async () => { - if (session?.session.id === row.original.id) { - toast.error(`Unable to unlink active session.`); - router.refresh(); - return; - } - - const status = await deleteUserSessionAction(row.original.token); - - if (status?.serverError || !status) { - toast.error(status?.serverError); - return; - } - toast.success(`Session deleted successfully.`); - router.refresh(); - }, - }); - - - if (isPending || error) return null; - - - return ( -
- } - onClick={async () => { - await mutation.mutateAsync(); - }} - size="icon" - /> -
- ); - }, - }, -]; diff --git a/src/components/wrappers/dashboard/database/import/upload-backup.action.ts b/src/components/wrappers/dashboard/database/import/upload-backup.action.ts index 2b8252d6..51503a39 100644 --- a/src/components/wrappers/dashboard/database/import/upload-backup.action.ts +++ b/src/components/wrappers/dashboard/database/import/upload-backup.action.ts @@ -10,6 +10,7 @@ import {eq} from "drizzle-orm"; import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action"; import {z} from "zod"; import {env} from "@/env.mjs"; +import {storeBackupFiles} from "@/features/storages/helpers"; export const uploadBackupAction = userAction @@ -23,7 +24,8 @@ export const uploadBackupAction = userAction where: eq(drizzleDb.schemas.database.id, databaseId), with: { project: true, - alertPolicies: true + alertPolicies: true, + storagePolicies: true } }); @@ -42,55 +44,56 @@ export const uploadBackupAction = userAction const arrayBuffer = await file.arrayBuffer(); - const fileSize = file.size; const uuid = uuidv4(); - const fileName = `imported_${uuid}${fileExtension}`; + const fileName = `${uuid}${fileExtension}`; const buffer = Buffer.from(arrayBuffer); - const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - - if (!settings) { - return { - success: false, - actionError: { - message: "Settings not set", - status: 500, - cause: "Unknown error", - }, - }; - } - - let success: boolean, message: string, filePath: string; - - const result = - settings.storage === "local" - ? await uploadLocalPrivate(fileName, buffer) - : await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!); - - ({success, message, filePath} = result); - - if (!success) { - return { - success: false, - actionError: { - message: "An error has occurred while uploading file", - status: 500, - cause: "Unknown error", - }, - }; - } + // const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); + // + // if (!settings) { + // return { + // success: false, + // actionError: { + // message: "Settings not set", + // status: 500, + // cause: "Unknown error", + // }, + // }; + // } const [backup] = await db .insert(drizzleDb.schemas.backup) .values({ - status: 'success', + imported: true, + status: 'ongoing', databaseId: database.id, - file: fileName, - fileSize: fileSize, }) .returning(); + await storeBackupFiles(backup, database, buffer, fileName) + + + // let success: boolean, message: string, filePath: string; + // + // const result = + // settings.storage === "local" + // ? await uploadLocalPrivate(fileName, buffer) + // : await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!); + // + // ({success, message, filePath} = result); + // + // if (!success) { + // return { + // success: false, + // actionError: { + // message: "An error has occurred while uploading file", + // status: 500, + // cause: "Unknown error", + // }, + // }; + // } + return { success: true, value: backup, diff --git a/src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx b/src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx deleted file mode 100644 index 852ba1a9..00000000 --- a/src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import {useMutation} from "@tanstack/react-query"; -import {Trash2} from "lucide-react"; -import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm"; -import {signOut} from "@/lib/auth/auth-client"; -import {useRouter} from "next/navigation"; -import {deleteUserAction} from "./delete-account.action"; - -export type ButtonDeleteAccountProps = { - text?: string; -}; - -export const ButtonDeleteAccount = (props: ButtonDeleteAccountProps) => { - const router = useRouter(); - - const mutation = useMutation({ - mutationFn: () => deleteUserAction(""), - onSuccess: async () => { - await signOut({ - fetchOptions: { - onSuccess: () => { - router.push("/login"); - }, - }, - }); - }, - }); - - 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} - /> - - - ); -}; \ No newline at end of file diff --git a/src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts b/src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts deleted file mode 100644 index a9214f0f..00000000 --- a/src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts +++ /dev/null @@ -1,36 +0,0 @@ -"use server"; -import {userAction} from "@/lib/safe-actions/actions"; -import { z } from "zod"; -import { v4 as uuidv4 } from "uuid"; -import { db } from "@/db"; -import { eq } from "drizzle-orm"; -import * as drizzleDb from "@/db"; -import {authClient} from "@/lib/auth/auth-client"; -import {auth} from "@/lib/auth/auth"; - - -export const deleteUserAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }) => { - const userId = parsedInput.length > 0 ? parsedInput : ctx.user.id; - const uuid = uuidv4(); - - - // const [updatedUser] = await db - // .update(drizzleDb.schemas.user) - // .set({ - // email: `${uuid}@portabase.com`, - // name: `${uuid}`, - // //deleted: true, - // //todo: add deleted - // }) - // .where(eq(drizzleDb.schemas.user.id, userId)) - // .returning(); - const [deletedUser] = await db - .delete(drizzleDb.schemas.user) - .where(eq(drizzleDb.schemas.user.id, userId)) - .returning(); - - - return { - data: deletedUser, - }; -}); \ No newline at end of file diff --git a/src/components/wrappers/dashboard/profile2/user-form/user-form.action.ts b/src/components/wrappers/dashboard/profile2/user-form/user-form.action.ts deleted file mode 100644 index ee1da954..00000000 --- a/src/components/wrappers/dashboard/profile2/user-form/user-form.action.ts +++ /dev/null @@ -1,43 +0,0 @@ -"use server"; -import {userAction} from "@/lib/safe-actions/actions"; -import { z } from "zod"; -import { UserSchema } from "@/components/wrappers/dashboard/profile2/user-form/user-form.schema"; -import { db } from "@/db"; -import { eq } from "drizzle-orm"; -import * as drizzleDb from "@/db"; -import {revokeSession, unlinkAccount} from "@/lib/auth/auth"; -import {withUpdatedAt} from "@/db/utils"; - -export const updateUserAction = userAction - .schema( - z.object({ - id: z.string(), - data: UserSchema, - }) - ) - .action(async ({ parsedInput }) => { - const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt(parsedInput.data)).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning(); - return { - data: updatedUser, - }; - }); - - -export const deleteUserSessionAction = userAction.schema(z.string()).action(async ({ parsedInput }) => { - const status = await revokeSession(parsedInput); - return status; -}); - - -export const unlinkUserProviderAction = userAction - .schema( - z.object({ - provider: z.string(), - account: z.string(), - }) - ) - .action(async ({ parsedInput }) => { - const status = await unlinkAccount(parsedInput.provider, parsedInput.account); - - return status; - }); diff --git a/src/components/wrappers/dashboard/profile2/user-form/user-form.schema.ts b/src/components/wrappers/dashboard/profile2/user-form/user-form.schema.ts deleted file mode 100644 index 082be886..00000000 --- a/src/components/wrappers/dashboard/profile2/user-form/user-form.schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {z} from "zod"; - -export const UserSchema = z.object({ - name: z.string().optional(), - email: z.string().optional(), - role: z.string().optional(), -}); - -export type UserType = z.infer; diff --git a/src/db/migrations/0027_special_the_santerians.sql b/src/db/migrations/0027_special_the_santerians.sql new file mode 100644 index 00000000..50a13433 --- /dev/null +++ b/src/db/migrations/0027_special_the_santerians.sql @@ -0,0 +1 @@ +ALTER TABLE "backups" ADD COLUMN "imported" boolean DEFAULT false; \ No newline at end of file diff --git a/src/db/migrations/meta/0027_snapshot.json b/src/db/migrations/meta/0027_snapshot.json new file mode 100644 index 00000000..a6d0a020 --- /dev/null +++ b/src/db/migrations/meta/0027_snapshot.json @@ -0,0 +1,2344 @@ +{ + "id": "5db47099-e931-4eaa-8678-d3314a8336ca", + "prevId": "5d244170-557d-433b-8acd-742ead8246c8", + "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 + }, + "default_storage_channel_id": { + "name": "default_storage_channel_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": { + "settings_default_storage_channel_id_storage_channel_id_fk": { + "name": "settings_default_storage_channel_id_storage_channel_id_fk", + "tableFrom": "settings", + "tableTo": "storage_channel", + "columnsFrom": [ + "default_storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credentialId": { + "name": "credentialId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backedUp": { + "name": "backedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "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": { + "passkey_userId_user_id_fk": { + "name": "passkey_userId_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "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.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "theme": { + "name": "theme", + "type": "user_themes", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "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 + }, + "lastChangedPasswordAt": { + "name": "lastChangedPasswordAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "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": { + "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 + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "imported": { + "name": "imported", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "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": { + "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_storage_id": { + "name": "backup_storage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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_storage_id_backup_storage_id_fk": { + "name": "restorations_backup_storage_id_backup_storage_id_fk", + "tableFrom": "restorations", + "tableTo": "backup_storage", + "columnsFrom": [ + "backup_storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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": { + "notification_channel_organization_id_organization_id_fk": { + "name": "notification_channel_organization_id_organization_id_fk", + "tableFrom": "notification_channel", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "public.alert_policy": { + "name": "alert_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "event_kind[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "alert_policy_notification_channel_id_notification_channel_id_fk": { + "name": "alert_policy_notification_channel_id_notification_channel_id_fk", + "tableFrom": "alert_policy", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_policy_database_id_databases_id_fk": { + "name": "alert_policy_database_id_databases_id_fk", + "tableFrom": "alert_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_log": { + "name": "notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_response": { + "name": "provider_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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_storage_channels": { + "name": "organization_storage_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_storage_channels_organization_id_organization_id_fk": { + "name": "organization_storage_channels_organization_id_organization_id_fk", + "tableFrom": "organization_storage_channels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_storage_channels_storage_channel_id_storage_channel_id_fk": { + "name": "organization_storage_channels_storage_channel_id_storage_channel_id_fk", + "tableFrom": "organization_storage_channels", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_storage_channels_organization_id_storage_channel_id_unique": { + "name": "organization_storage_channels_organization_id_storage_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "storage_channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_channel": { + "name": "storage_channel", + "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": false + }, + "provider": { + "name": "provider", + "type": "provider_storage_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": { + "storage_channel_organization_id_organization_id_fk": { + "name": "storage_channel_organization_id_organization_id_fk", + "tableFrom": "storage_channel", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_policy": { + "name": "storage_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "storage_policy_storage_channel_id_storage_channel_id_fk": { + "name": "storage_policy_storage_channel_id_storage_channel_id_fk", + "tableFrom": "storage_policy", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "storage_policy_database_id_databases_id_fk": { + "name": "storage_policy_database_id_databases_id_fk", + "tableFrom": "storage_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_storage": { + "name": "backup_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_channel_id": { + "name": "storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "backup_storage_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "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": { + "backup_storage_backup_id_backups_id_fk": { + "name": "backup_storage_backup_id_backups_id_fk", + "tableFrom": "backup_storage", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_storage_storage_channel_id_storage_channel_id_fk": { + "name": "backup_storage_storage_channel_id_storage_channel_id_fk", + "tableFrom": "backup_storage", + "tableTo": "storage_channel", + "columnsFrom": [ + "storage_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.user_themes": { + "name": "user_themes", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + }, + "public.retention_policy_type": { + "name": "retention_policy_type", + "schema": "public", + "values": [ + "count", + "days", + "gfs" + ] + }, + "public.provider_kind": { + "name": "provider_kind", + "schema": "public", + "values": [ + "slack", + "smtp", + "discord", + "telegram", + "gotify", + "ntfy", + "webhook" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.provider_storage_kind": { + "name": "provider_storage_kind", + "schema": "public", + "values": [ + "local", + "s3" + ] + }, + "public.backup_storage_status": { + "name": "backup_storage_status", + "schema": "public", + "values": [ + "pending", + "success", + "failed" + ] + }, + "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 35fe3740..7961fc0b 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1768665081232, "tag": "0026_demonic_santa_claus", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1768676733859, + "tag": "0027_special_the_santerians", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/07_database.ts b/src/db/schema/07_database.ts index 73966b5a..bdc3ef7c 100644 --- a/src/db/schema/07_database.ts +++ b/src/db/schema/07_database.ts @@ -41,6 +41,7 @@ export const backup = pgTable( databaseId: uuid("database_id") .notNull() .references(() => database.id, {onDelete: "cascade"}), + imported: boolean('imported').default(false), ...timestamps }, ); diff --git a/src/features/dashboard/backup/columns.tsx b/src/features/dashboard/backup/columns.tsx index 133f5255..8422e6d6 100644 --- a/src/features/dashboard/backup/columns.tsx +++ b/src/features/dashboard/backup/columns.tsx @@ -66,9 +66,8 @@ export function backupColumns( accessorKey: "id", header: "Reference", cell: ({row}) => { - const fileName = row.original.file const reference = row.original.id - const isImported = isImportedFilename(`${fileName}`) + const isImported = row.original.imported return isImported ? `${reference} (imported)` : `${reference}` }, }, From e63e62d3e04cf55f48725e3086580086d68fb3eb Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Sat, 17 Jan 2026 21:13:54 +0100 Subject: [PATCH 13/24] fix: Sidebar focus on some items --- .../dashboard/(admin)/admin/settings/page.tsx | 28 +++++++++ .../common/sidebar/menu-sidebar-main.tsx | 13 +--- .../dashboard/common/sidebar/menu-sidebar.tsx | 62 +++++-------------- 3 files changed, 44 insertions(+), 59 deletions(-) create mode 100644 app/(customer)/dashboard/(admin)/admin/settings/page.tsx diff --git a/app/(customer)/dashboard/(admin)/admin/settings/page.tsx b/app/(customer)/dashboard/(admin)/admin/settings/page.tsx new file mode 100644 index 00000000..6685b70c --- /dev/null +++ b/app/(customer)/dashboard/(admin)/admin/settings/page.tsx @@ -0,0 +1,28 @@ +import {PageParams} from "@/types/next"; +import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; +import {db} from "@/db"; +import {notFound} from "next/navigation"; + +export default async function RoutePage(props: PageParams<{}>) { + + const settings = await db.query.setting.findFirst({ + where: (fields, {eq}) => eq(fields.name, "system"), + }); + + if (!settings) { + notFound() + } + + + return ( + + +
+ System settings +
+
+ + +
+ ); +} diff --git a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx index be9f7ed4..3492071e 100644 --- a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx +++ b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx @@ -22,7 +22,6 @@ export const SidebarMenuCustomMain = () => { const {data: session, isPending, error} = authClient.useSession(); const member = authClient.useActiveMember(); - if (isPending) return null; if (error || !session) { @@ -39,9 +38,6 @@ export const SidebarMenuCustomMain = () => { {title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"} ]; - // if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) { - // groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true }); - // } const items: SidebarGroupItem[] = [ { @@ -98,19 +94,14 @@ export const SidebarMenuCustomMain = () => { type: "collapse", submenu: [ {title: "Users", url: "/admin/users", icon: Users, type: "item"}, - {title: "Organizations", url: "/admin/organizations", icon: Building, type: "item"}, + {title: "Organizations", url: "/admin/organizations", icon: Building, type: "item", details: true}, ], }, { title: "Settings", url: "/admin/settings", icon: Settings, - details: true, - type: "collapse", - submenu: [ - {title: "Email", url: "/admin/settings/email", icon: Mail, type: "item"}, - {title: "Storage", url: "/admin/settings/storage", icon: PackageOpen, type: "item"}, - ], + type: "item", }, ], }); diff --git a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx index 77eab973..ebbf35a9 100644 --- a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx +++ b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx @@ -14,7 +14,7 @@ import { } from "@/components/ui/sidebar"; import { ChevronRight, ChevronDown, MoreHorizontal } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; -import React, { useEffect, useState } from "react"; +import React from "react"; import Link from "next/link"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; @@ -46,43 +46,18 @@ type SidebarMenuCustomBaseProps = { export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseProps) => { const pathname = usePathname(); - const [activeItem, setActiveItem] = useState(""); - useEffect(() => { - const normalize = (url: string) => url.split("?")[0].replace(/\/$/, ""); + const normalize = (url: string) => url.split("?")[0].replace(/\/$/, ""); - const findActiveUrl = () => { - const normalizedPathname = normalize(pathname); - for (const group of items) { - for (const item of group.group_content) { - if (item.submenu) { - for (const subItem of item.submenu) { - const subItemUrl = normalize(subItem.url); - const subFullUrl = normalize(`${baseUrl}${subItemUrl}`); - - if (normalizedPathname === subFullUrl || (subItem.details && normalizedPathname.startsWith(`${subFullUrl}/`))) { - return subItemUrl; - } - } - } - - const itemUrl = normalize(item.url); - const fullUrl = item.not_from_base_url ? itemUrl : normalize(`${baseUrl}${itemUrl}`); - - if (normalizedPathname === fullUrl || (item.details && normalizedPathname.startsWith(`${fullUrl}/`))) { - return itemUrl; - } - } - } - return ""; - }; - - setActiveItem(findActiveUrl()); - }, [pathname, baseUrl, items]); + const isItemActive = (item: SidebarItem) => { + const fullUrl = item.not_from_base_url ? normalize(item.url) : normalize(`${baseUrl}${item.url}`); + if (item.details) return normalize(pathname).startsWith(fullUrl); + return normalize(pathname) === fullUrl; + }; const isSubActive = (item: SidebarItem) => { if (!item.submenu) return false; - return item.submenu.some((sub) => sub.url === activeItem); + return item.submenu.some((sub) => isItemActive(sub)); }; return ( @@ -91,7 +66,7 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP - + {group.label} {group.type === "collapse" && ( @@ -118,14 +93,10 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP {item.submenu.map((sub, subIdx) => ( - + setActiveItem(sub.url)} + href={sub.not_from_base_url ? sub.url : `${baseUrl}${sub.url}`} + className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-full justify-start")} > {sub.title} @@ -142,11 +113,10 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP return ( - + setActiveItem(item.url)} className={cn(buttonVariants({ variant: "ghost", size: "lg" }), "justify-start")} > @@ -163,11 +133,7 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP {item.dropdown.map((dropdown, dIdx) => ( {dropdown.title} From 5b980fb3822faf6aead96d985b1ecb666046f279 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Sat, 17 Jan 2026 22:26:44 +0100 Subject: [PATCH 14/24] feat: adding system settings page. And did some refactoring. --- .../dashboard/(admin)/admin/settings/page.tsx | 16 +- .../(organization)/settings/page.tsx | 8 +- app/api/agent/[agentId]/backup/route.ts | 2 - src/components/ui/calendar.tsx | 2 + .../common/button/button-with-loading.tsx | 1 - .../organization-member-change-role.tsx | 4 +- .../details/role-member.action.ts | 2 +- .../settings/keys/admin-settings-section.tsx | 49 ------ .../admin/settings/settings-tabs.tsx | 61 ++++++++ .../storage/settings-storage-section.tsx | 146 +++++++++--------- .../storage/settings-storage.action.ts | 50 ++++++ .../storage/settings-storage.schema.ts | 7 + .../database/channels-policy/policy-modal.tsx | 4 - .../cron-button/advanced-cron-select.tsx | 1 + .../delete-organization-button.tsx | 0 .../settings/columns-organization-members.tsx | 4 +- .../edit-button-settings.tsx | 0 .../settings/member.schema.ts | 0 .../settings-organization-members-table.tsx | 4 +- .../settings/update-member.action.ts | 2 +- .../organization-notifiers-tab.tsx | 0 .../organization-storages-tab.tsx | 0 .../organization/tabs/organization-tabs.tsx | 12 +- src/features/storages/helpers.ts | 20 ++- 24 files changed, 236 insertions(+), 159 deletions(-) delete mode 100644 src/components/wrappers/dashboard/admin/settings/keys/admin-settings-section.tsx create mode 100644 src/components/wrappers/dashboard/admin/settings/settings-tabs.tsx create mode 100644 src/components/wrappers/dashboard/admin/settings/storage/settings-storage.action.ts create mode 100644 src/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema.ts rename src/components/wrappers/dashboard/organization/{delete-organization => }/delete-organization-button.tsx (100%) rename src/components/wrappers/dashboard/{ => organization}/settings/columns-organization-members.tsx (93%) rename src/components/wrappers/dashboard/{ => organization}/settings/edit-button-settings/edit-button-settings.tsx (100%) rename src/components/wrappers/dashboard/{ => organization}/settings/member.schema.ts (100%) rename src/components/wrappers/dashboard/{ => organization}/settings/settings-organization-members-table.tsx (84%) rename src/components/wrappers/dashboard/{ => organization}/settings/update-member.action.ts (97%) rename src/components/wrappers/dashboard/organization/tabs/{organization-notifiers-tab => organization-channels-tab}/organization-notifiers-tab.tsx (100%) rename src/components/wrappers/dashboard/organization/tabs/{organization-notifiers-tab => organization-channels-tab}/organization-storages-tab.tsx (100%) diff --git a/app/(customer)/dashboard/(admin)/admin/settings/page.tsx b/app/(customer)/dashboard/(admin)/admin/settings/page.tsx index 6685b70c..7f803061 100644 --- a/app/(customer)/dashboard/(admin)/admin/settings/page.tsx +++ b/app/(customer)/dashboard/(admin)/admin/settings/page.tsx @@ -2,6 +2,10 @@ import {PageParams} from "@/types/next"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {db} from "@/db"; import {notFound} from "next/navigation"; +import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs"; +import {desc, isNull} from "drizzle-orm"; +import * as drizzleDb from "@/db"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; export default async function RoutePage(props: PageParams<{}>) { @@ -9,11 +13,18 @@ export default async function RoutePage(props: PageParams<{}>) { where: (fields, {eq}) => eq(fields.name, "system"), }); - if (!settings) { + const storageChannels = await db.query.storageChannel.findMany({ + with: { + organizations: true + }, + where: isNull(drizzleDb.schemas.storageChannel.organizationId), + orderBy: desc(drizzleDb.schemas.storageChannel.createdAt) + }) as StorageChannelWith[] + + if (!settings || !storageChannels ) { notFound() } - return ( @@ -22,6 +33,7 @@ export default async function RoutePage(props: PageParams<{}>) {
+ ); diff --git a/app/(customer)/dashboard/(organization)/settings/page.tsx b/app/(customer)/dashboard/(organization)/settings/page.tsx index cfc6db26..02bc5d12 100644 --- a/app/(customer)/dashboard/(organization)/settings/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/page.tsx @@ -3,15 +3,15 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/ import {currentUser} from "@/lib/auth/current-user"; import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {notFound} from "next/navigation"; -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 {Metadata} from "next"; import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; import {getOrganizationChannels} from "@/db/services/notification-channel"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; +import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button"; +import { + EditButtonSettings +} from "@/components/wrappers/dashboard/organization/settings/edit-button-settings/edit-button-settings"; export const metadata: Metadata = { title: "Settings", diff --git a/app/api/agent/[agentId]/backup/route.ts b/app/api/agent/[agentId]/backup/route.ts index e1897eef..da6765ec 100644 --- a/app/api/agent/[agentId]/backup/route.ts +++ b/app/api/agent/[agentId]/backup/route.ts @@ -1,13 +1,11 @@ import {NextResponse} from "next/server"; import {isUuidv4} from "@/utils/verify-uuid"; -import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action"; import {v4 as uuidv4} from "uuid"; import {eventEmitter} from "../../../events/route"; import * as drizzleDb from "@/db"; import {db} from "@/db"; import {Backup} from "@/db/schema/07_database"; import {and, eq} from "drizzle-orm"; -import {env} from "@/env.mjs"; import {withUpdatedAt} from "@/db/utils"; import {decryptedDump, getFileExtension} from "./helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 974fae3f..d35e9634 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -46,7 +46,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: R ...classNames, }} components={{ + // @ts-ignore IconLeft: ({ className, children: _children, ...props }) => , + // @ts-ignore IconRight: ({ className, children: _children, ...props }) => , }} {...props} diff --git a/src/components/wrappers/common/button/button-with-loading.tsx b/src/components/wrappers/common/button/button-with-loading.tsx index 0ea18290..65fa244e 100644 --- a/src/components/wrappers/common/button/button-with-loading.tsx +++ b/src/components/wrappers/common/button/button-with-loading.tsx @@ -109,7 +109,6 @@ export const ButtonWithLoading = ({ {isPending && } {children && children} <>{icon ? icon : null} - {/*{icon && {icon}}*/} ); }; diff --git a/src/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role.tsx b/src/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role.tsx index f0577a8b..0aefb042 100644 --- a/src/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role.tsx +++ b/src/components/wrappers/dashboard/admin/organizations/organization/details/organization-member-change-role.tsx @@ -13,16 +13,14 @@ import { DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import {authClient} from "@/lib/auth/auth-client"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {MemberRoleType} from "@/types/common"; import {MemberWithUser} from "@/db/schema/03_organization"; -import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/update-member.action"; -import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema"; import { updateMemberRoleAdminAction } from "@/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action"; +import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema"; type OrganizationMemberChangeRoleModalProps = { open: boolean; diff --git a/src/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action.ts b/src/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action.ts index e7e7a24f..d20f59a1 100644 --- a/src/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action.ts +++ b/src/components/wrappers/dashboard/admin/organizations/organization/details/role-member.action.ts @@ -3,11 +3,11 @@ import {userAction} from "@/lib/safe-actions/actions"; import {z} from "zod"; import {ServerActionResult} from "@/types/action-type"; import {Member} from "better-auth/plugins"; -import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema"; import {db as dbClient} from "@/db"; import * as drizzleDb from "@/db"; import {and, eq} from "drizzle-orm"; import {withUpdatedAt} from "@/db/utils"; +import {RoleSchemaMember} from "@/components/wrappers/dashboard/organization/settings/member.schema"; export const updateMemberRoleAdminAction = userAction.schema( diff --git a/src/components/wrappers/dashboard/admin/settings/keys/admin-settings-section.tsx b/src/components/wrappers/dashboard/admin/settings/keys/admin-settings-section.tsx deleted file mode 100644 index fe33679e..00000000 --- a/src/components/wrappers/dashboard/admin/settings/keys/admin-settings-section.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; -import {Button} from "@/components/ui/button"; -import {Download} from "lucide-react"; -import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action"; -import {toast} from "sonner"; - -export type AdminSettingsTabProps = {}; - -export const AdminSettingsSection = (props: AdminSettingsTabProps) => { - - const handleDownloadKey = async () => { - - let url: string = ""; - const data = await getFileUrlPresignedLocal({dir: "private/keys/", fileName: "server_public.pem"}) - if (data?.data?.success) { - url = data.data.value ?? ""; - } else { - // @ts-ignore - const errorMessage = data?.data?.actionError?.message || "Failed to get file!"; - toast.error(errorMessage); - } - window.open(url, "_self"); - }; - - return ( -
- - - Instance settings - Manage portabase settings - - -
-
-

Download Public Key

-

- Used for encrypting communications with this instance. -

-
- -
-
-
-
- ); -}; diff --git a/src/components/wrappers/dashboard/admin/settings/settings-tabs.tsx b/src/components/wrappers/dashboard/admin/settings/settings-tabs.tsx new file mode 100644 index 00000000..f5edc2f8 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/settings/settings-tabs.tsx @@ -0,0 +1,61 @@ +"use client"; + +import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; +import {useEffect, useState} from "react"; +import {useRouter, useSearchParams} from "next/navigation"; +import {Setting} from "@/db/schema/01_setting"; +import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section"; +import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; + +export type SettingsTabsProps = { + settings: Setting + storageChannels: StorageChannelWith[] +}; + +export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [tab, setTab] = useState(() => searchParams.get("tab") ?? "email"); + + + useEffect(() => { + const newTab = searchParams.get("tab") ?? "email"; + setTab(newTab); + }, [searchParams]); + + const handleChangeTab = (value: string) => { + router.push(`?tab=${value}`); + }; + + + return ( +
+ + + + Email + + + Default storage + + + + + + + + + +
+ + + ); +}; diff --git a/src/components/wrappers/dashboard/admin/settings/storage/settings-storage-section.tsx b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage-section.tsx index 5ff2ce97..c3bb8a47 100644 --- a/src/components/wrappers/dashboard/admin/settings/storage/settings-storage-section.tsx +++ b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage-section.tsx @@ -1,66 +1,52 @@ "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/settings/storage/storage-s3/storage-s3-form"; -import {useState} from "react"; +import {Info} from "lucide-react"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; -import {useMutation} from "@tanstack/react-query"; -import {checkConnexionToS3} from "@/features/upload/public/upload.action"; -import {toast} from "sonner"; import {useRouter} from "next/navigation"; +import {Setting} from "@/db/schema/01_setting"; +import {Form, FormField, FormItem, useZodForm} from "@/components/ui/form"; +import {StorageChannelWith} from "@/db/schema/12_storage-channel"; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; +import {useMutation} from "@tanstack/react-query"; +import { + DefaultStorageSchema, + DefaultStorageType +} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema"; +import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; import { updateStorageSettingsAction -} 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/settings/storage/storage-s3/s3-form.schema"; +} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action"; +import {toast} from "sonner"; export type SettingsStorageSectionProps = { settings: Setting; + storageChannels: StorageChannelWith[]; }; -export const SettingsStorageSection = (props: SettingsStorageSectionProps) => { +export const SettingsStorageSection = ({settings, storageChannels}: SettingsStorageSectionProps) => { const router = useRouter(); - const mutation = useMutation({ - mutationFn: async () => { - const result = await checkConnexionToS3(); - if (result.error) { - toast.error("An error occured during the connexion !"); - } else { - toast.success("Connexion succeed!"); - } - }, + const form = useZodForm({ + schema: DefaultStorageSchema, + defaultValues: { + storageChannelId: settings.defaultStorageChannelId ?? undefined + } }); - const [isSwitched, setIsSwitched] = useState(props.settings.storage !== "local"); - - const updateMutation = useMutation({ - mutationFn: () => updateStorageSettingsAction({name: "system", data: {storage: isSwitched ? "s3" : "local"}}), - onSuccess: () => { - toast.success(`Settings updated successfully.`); - router.refresh(); - }, - onError: () => { - toast.error(`An error occurred while updating settings information.`); - }, - }); - const HandleSwitchStorage = async () => { - setIsSwitched(!isSwitched); - await updateMutation.mutateAsync(); - }; + const mutation = useMutation({ + mutationFn: async (values: DefaultStorageType) => { + const result = await updateStorageSettingsAction({name: "system", data: values}) + const inner = result?.data; - const extractS3FormValues = (settings: Setting): S3FormType | undefined => { - if (!settings.s3EndPointUrl) return undefined; - return { - s3EndPointUrl: settings.s3EndPointUrl, - s3AccessKeyId: settings.s3AccessKeyId!, - s3SecretAccessKey: settings.s3SecretAccessKey!, - S3BucketName: settings.S3BucketName!, - }; - }; + if (inner?.success) { + toast.success(inner.actionSuccess?.message); + router.refresh(); + } else { + toast.error(inner?.actionError?.message); + } + } + }); return (
@@ -68,39 +54,49 @@ export const SettingsStorageSection = (props: SettingsStorageSectionProps) => { Informations - Actually you can only store you data in one place : s3 compatible or in local. For exemple you - cannot choose to store images in one place - and backups files in another. - + The default storage channel will be used by default to store your backups if no storage policy is + configured at the database level.
-
-
- - { - await HandleSwitchStorage(); - }} - id="storage-mode" +
{ + await mutation.mutateAsync(values); + }} + > +
+ ( + + + + )} /> + + Confirm +
-
- { - await mutation.mutateAsync(); - }} - icon={}>Test connexion -
-
- {isSwitched && ( -
- -
- )} +
); diff --git a/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.action.ts b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.action.ts new file mode 100644 index 00000000..cd7ee875 --- /dev/null +++ b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.action.ts @@ -0,0 +1,50 @@ +"use server" + +import {userAction} from "@/lib/safe-actions/actions"; +import {db} from "@/db"; +import * as drizzleDb from "@/db"; +import {eq} from "drizzle-orm"; +import {ServerActionResult} from "@/types/action-type"; +import {Setting} from "@/db/schema/01_setting"; +import {z} from "zod"; +import {DefaultStorageSchema} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema"; + +export const updateStorageSettingsAction = userAction + .schema( + z.object({ + name: z.string(), + data: DefaultStorageSchema, + }) + ) + .action(async ({parsedInput}): Promise> => { + const {name, data} = parsedInput; + + try { + + const [updatedSettings] = await db + .update(drizzleDb.schemas.setting) + .set({ + defaultStorageChannelId: data.storageChannelId, + }) + .where(eq(drizzleDb.schemas.setting.name, name)) + .returning(); + return { + success: true, + value: updatedSettings, + actionSuccess: { + message: "Settings successfully updated", + }, + }; + } catch (error) { + return { + success: false, + actionError: { + message: "Failed update settings.", + status: 500, + cause: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + }); + + diff --git a/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema.ts b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema.ts new file mode 100644 index 00000000..6b0fec3d --- /dev/null +++ b/src/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const DefaultStorageSchema = z.object({ + storageChannelId: z.string(), +}); + +export type DefaultStorageType = z.infer; diff --git a/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx b/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx index eb9ad6d9..a2a9a8fa 100644 --- a/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx +++ b/src/components/wrappers/dashboard/database/channels-policy/policy-modal.tsx @@ -41,10 +41,6 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId)); const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId)); - console.log(channels); - console.log(database.storagePolicies); - console.log("activeAlertPolicies", activeAlertPolicies); - console.log("activeStoragePolicies", activeStoragePolicies); const activePolicies = kind === "notification" ? activeAlertPolicies : activeStoragePolicies; diff --git a/src/components/wrappers/dashboard/database/cron-button/advanced-cron-select.tsx b/src/components/wrappers/dashboard/database/cron-button/advanced-cron-select.tsx index b8a50c0d..5236f191 100644 --- a/src/components/wrappers/dashboard/database/cron-button/advanced-cron-select.tsx +++ b/src/components/wrappers/dashboard/database/cron-button/advanced-cron-select.tsx @@ -43,6 +43,7 @@ export const AdvancedCronSelect = ({ {!isAdvanced ? (