From fd41f7ea508f4f1a4d8617bccf2c683342682fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 22 Jun 2026 16:56:17 +0200 Subject: [PATCH] fix --- .env.example | 3 + app/(auth)/layout.tsx | 5 +- .../dashboard/(admin)/admin/users/page.tsx | 23 +- .../database/[databaseId]/page.tsx | 221 +- .../projects/[projectId]/page.tsx | 201 +- app/(customer)/dashboard/layout.tsx | 5 +- app/(landing)/page.tsx | 5 +- app/(welcome)/welcome/page.tsx | 1 + .../[agentId]/backup/upload/init/route.ts | 141 +- app/api/agent/[agentId]/restore/route.ts | 216 +- app/api/agent/[agentId]/status/helpers.ts | 533 +-- app/api/agent/[agentId]/status/route.ts | 182 +- app/api/avatar/route.tsx | 10 +- portabase.config.ts | 2 +- src/db/migrations/0067_adorable_jean_grey.sql | 2 + src/db/migrations/0068_bitter_revanche.sql | 1 + src/db/migrations/meta/0067_snapshot.json | 2994 ++++++++++++++++ src/db/migrations/meta/0068_snapshot.json | 3001 +++++++++++++++++ src/db/migrations/meta/_journal.json | 14 + src/db/schema/01_setting.ts | 6 +- src/db/services/agent.ts | 68 +- src/db/services/backup.ts | 26 +- src/db/services/database.ts | 152 +- src/db/services/healthcheck.ts | 329 +- src/db/services/notification-channel.ts | 88 +- src/db/services/notification-log.ts | 156 +- src/db/services/organization.ts | 18 +- src/db/services/project.ts | 130 +- src/db/services/setting.ts | 9 +- src/db/services/storage-channel.ts | 78 +- src/db/services/user.ts | 72 +- src/env.mjs | 4 + src/features/channel/channel-card.tsx | 3 +- .../channel/notifications/channel.action.ts | 18 + .../channel/storages/channel.action.ts | 18 + .../database/channels-policy-form.tsx | 305 +- .../database/channels-policy-modal.tsx | 160 +- .../database/retention-policy-form.tsx | 572 ++-- .../database/retention-policy-sheet.tsx | 88 +- .../layout/logged-in-button.server.tsx | 16 +- src/features/layout/logged-in-button.tsx | 9 +- src/features/layout/logged-in-dropdown.tsx | 7 +- src/features/layout/profile-modal.tsx | 7 +- .../actions/apply-db-settings.action.ts | 53 +- .../components/backup-schedule-selector.tsx | 266 +- .../components/db-settings/db-detail.tsx | 67 +- .../components/db-settings/db-grid.tsx | 2 +- .../components/db-settings/db-section.tsx | 49 +- .../db-settings/notifications-section.tsx | 246 +- .../db-settings/retention-section.tsx | 247 +- .../db-settings/scheduling-section.tsx | 7 - .../db-settings/storage-section.tsx | 214 +- .../onboarding/hooks/use-add-storage.ts | 1 + .../onboarding/hooks/use-create-org.ts | 33 +- .../onboarding/hooks/use-create-project.ts | 8 +- .../onboarding/hooks/use-remove-notifier.ts | 12 +- .../onboarding/hooks/use-remove-storage.ts | 17 +- src/features/onboarding/onboarding-shell.tsx | 21 +- src/features/onboarding/onboarding-state.ts | 49 +- src/features/onboarding/onboarding-steps.tsx | 4 +- .../onboarding/steps/step-agent-key.tsx | 66 +- .../onboarding/steps/step-agent-waiting.tsx | 8 +- .../onboarding/steps/step-db-settings.tsx | 8 +- .../onboarding/steps/step-defaults.tsx | 142 +- src/features/onboarding/steps/step-finish.tsx | 12 +- .../onboarding/steps/step-preferences.tsx | 5 +- .../onboarding/steps/step-project-create.tsx | 21 +- .../onboarding/steps/step-security.tsx | 135 +- .../onboarding/steps/step-storage.tsx | 18 +- src/features/onboarding/types/index.ts | 5 + .../organizations/organization.action.ts | 9 + .../organizations/organization.schema.ts | 6 +- src/features/profile/avatar-with-upload.tsx | 35 +- src/features/profile/profile-general.tsx | 7 +- src/features/profile/setup-2fa-modal.tsx | 279 +- .../profile/two-factor-setup-content.tsx | 171 + src/features/projects/project-form.tsx | 1 + .../settings/avatar-mode-selector.tsx | 54 + src/features/settings/avatar-section.tsx | 60 + src/features/settings/avatar.action.ts | 42 + .../settings/dicebear-style-picker.tsx | 83 + src/features/settings/email-form.tsx | 9 +- src/features/settings/settings-tabs.tsx | 11 +- src/features/settings/use-dicebear-styles.ts | 35 + src/features/users/admin-user-list.tsx | 5 +- src/features/users/user-columns.tsx | 5 +- src/lib/zod.ts | 33 +- src/utils/mock-data.ts | 115 - src/utils/name-from-email.ts | 16 +- src/utils/os-parser.ts | 148 - src/utils/resolve-avatar-url.ts | 27 + src/utils/rsa-keys.ts | 88 +- src/utils/slugify.ts | 26 +- src/utils/text.ts | 84 +- src/utils/verify-uuid.ts | 5 - 95 files changed, 9511 insertions(+), 3458 deletions(-) create mode 100644 src/db/migrations/0067_adorable_jean_grey.sql create mode 100644 src/db/migrations/0068_bitter_revanche.sql create mode 100644 src/db/migrations/meta/0067_snapshot.json create mode 100644 src/db/migrations/meta/0068_snapshot.json create mode 100644 src/features/profile/two-factor-setup-content.tsx create mode 100644 src/features/settings/avatar-mode-selector.tsx create mode 100644 src/features/settings/avatar-section.tsx create mode 100644 src/features/settings/avatar.action.ts create mode 100644 src/features/settings/dicebear-style-picker.tsx create mode 100644 src/features/settings/use-dicebear-styles.ts delete mode 100644 src/utils/mock-data.ts delete mode 100644 src/utils/os-parser.ts create mode 100644 src/utils/resolve-avatar-url.ts delete mode 100644 src/utils/verify-uuid.ts diff --git a/.env.example b/.env.example index a8e3e759..2e9d2fcd 100644 --- a/.env.example +++ b/.env.example @@ -81,3 +81,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost: # Default to false #TUSD_BEHIND_PROXY=true + +# +#SKIP_ONBOARDING=false diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index ee025e52..6a43921f 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,8 +1,9 @@ import React from "react"; import { redirect } from "next/navigation"; import { currentUser } from "@/lib/auth/current-user"; -import { isOnboardingDone } from "@/features/onboarding/is-onboarding-done"; +import { isOnboardingDone } from "@/db/services/setting"; import { AuthLogoSection } from "@/features/auth/auth-logo-section"; +import { env } from "@/env.mjs"; import { Heart } from "lucide-react"; export default async function Layout({ @@ -10,7 +11,7 @@ export default async function Layout({ }: { children: React.ReactNode; }) { - if (!(await isOnboardingDone())) { + if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) { redirect("/welcome"); } diff --git a/app/(customer)/dashboard/(admin)/admin/users/page.tsx b/app/(customer)/dashboard/(admin)/admin/users/page.tsx index d67be444..367f19c6 100644 --- a/app/(customer)/dashboard/(admin)/admin/users/page.tsx +++ b/app/(customer)/dashboard/(admin)/admin/users/page.tsx @@ -5,17 +5,24 @@ import {desc, isNull} from "drizzle-orm"; import {AdminUserList} from "@/features/users/admin-user-list"; import {AdminUserAddModal} from "@/features/users/admin-user-add-modal"; import {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; +import {getSettings} from "@/db/services/setting"; +import {resolveAvatarUrl} from "@/utils/resolve-avatar-url"; export default async function RoutePage(props: PageParams<{}>) { - const users = await db.query.user.findMany({ - where: (fields) => isNull(fields.deletedAt), - with: { - accounts: true - }, - orderBy: (fields) => desc(fields.createdAt), + const [settings, users] = await Promise.all([ + getSettings(), + db.query.user.findMany({ + where: (fields) => isNull(fields.deletedAt), + with: { accounts: true }, + orderBy: (fields) => desc(fields.createdAt), + }), + ]); + + const avatarUrls = Object.fromEntries( + users.map((u) => [u.id, resolveAvatarUrl(u, settings)]) + ); - }); const organizations = await db.query.organization.findMany({ with: { members: true, @@ -38,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) { - + ); 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 cb8afdd5..9dc4e1b1 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -1,122 +1,137 @@ -import {PageParams} from "@/types/next"; -import {notFound, redirect} from "next/navigation"; -import {Page} from "@/features/layout/page"; -import {db} from "@/db"; -import {eq, and, inArray} from "drizzle-orm"; +import { PageParams } from "@/types/next"; +import { notFound, redirect } from "next/navigation"; +import { Page } from "@/features/layout/page"; +import { db } from "@/db"; +import { eq, and, inArray } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {getOrganizationProjectDatabases} from "@/db/services/project"; -import {getActiveMember, getOrganization} from "@/lib/auth/auth"; -import {BackupModalProvider} from "@/features/database/backup-modal-context"; -import {DatabaseContent} from "@/features/database/database-content"; -import {getHealthLast12hLogs} from "@/db/services/healthcheck"; -import {LogsModalProvider} from "@/features/logs/logs-modal-context"; +import { getOrganizationProjectDatabases } from "@/db/services/project"; +import { getActiveMember, getOrganization } from "@/lib/auth/auth"; +import { BackupModalProvider } from "@/features/database/backup-modal-context"; +import { DatabaseContent } from "@/features/database/database-content"; +import { getHealthLast12hLogs } from "@/db/services/healthcheck"; +import { LogsModalProvider } from "@/features/logs/logs-modal-context"; -export default async function RoutePage(props: PageParams<{ +export default async function RoutePage( + props: PageParams<{ projectId: string; - databaseId: string -}>) { - const {projectId, databaseId} = await props.params; + databaseId: string; + }>, +) { + const { projectId, databaseId } = await props.params; - const organization = await getOrganization({}); - const activeMember = await getActiveMember() + const organization = await getOrganization({}); + const activeMember = await getActiveMember(); - if (!organization || !activeMember) { - notFound(); - } + if (!organization || !activeMember) { + notFound(); + } - const databasesProject = await getOrganizationProjectDatabases({ - organizationSlug: organization.slug, - projectId: projectId - }) + const databasesProject = await getOrganizationProjectDatabases({ + organizationSlug: organization.slug, + projectId: projectId, + }); - const dbItem = await db.query.database.findFirst({ - where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)), + const dbItem = await db.query.database.findFirst({ + where: and( + inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), + eq(drizzleDb.schemas.database.id, databaseId), + eq(drizzleDb.schemas.database.projectId, projectId), + ), + with: { + project: true, + retentionPolicy: true, + alertPolicies: true, + storagePolicies: true, + }, + }); + + if (!dbItem) { + redirect("/dashboard/projects"); + } + + const backups = await db.query.backup.findMany({ + where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id), + with: { + restorations: true, + storages: { with: { - project: true, - retentionPolicy: true, - alertPolicies: true, - storagePolicies: true, - } - }); - - if (!dbItem) { - redirect("/dashboard/projects"); - } - - const backups = await db.query.backup.findMany({ - where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id), - with: { - restorations: true, - storages: { - with: { - storageChannel: true - } - }, - logs: true + storageChannel: true, }, - orderBy: (b, {desc}) => [desc(b.createdAt)], - }); + }, + logs: true, + }, + orderBy: (b, { desc }) => [desc(b.createdAt)], + }); - const restorations = await db.query.restoration.findMany({ - where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id), - with: { - logs: true - }, - orderBy: (r, {desc}) => [desc(r.createdAt)], - }); + const restorations = await db.query.restoration.findMany({ + where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id), + with: { + logs: true, + }, + orderBy: (r, { desc }) => [desc(r.createdAt)], + }); - const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing"); - const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); + //const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing"); + const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); - const totalBackups = await db.select({count: drizzleDb.schemas.backup.id}) - .from(drizzleDb.schemas.backup) - .where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id)) - .then(rows => rows.length); + const totalBackups = await db + .select({ count: drizzleDb.schemas.backup.id }) + .from(drizzleDb.schemas.backup) + .where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id)) + .then((rows) => rows.length); - const availableBackups = backups.filter(b => !b.deletedAt).length; + const availableBackups = backups.filter((b) => !b.deletedAt).length; - const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id}) - .from(drizzleDb.schemas.backup) - .where(and( - eq(drizzleDb.schemas.backup.databaseId, dbItem.id), - eq(drizzleDb.schemas.backup.status, "success") - )) - .then(rows => rows.length); + const successfulBackups = await db + .select({ count: drizzleDb.schemas.backup.id }) + .from(drizzleDb.schemas.backup) + .where( + and( + eq(drizzleDb.schemas.backup.databaseId, dbItem.id), + eq(drizzleDb.schemas.backup.status, "success"), + ), + ) + .then((rows) => rows.length); + const [settings] = await db + .select() + .from(drizzleDb.schemas.setting) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .limit(1); + if (!settings) { + notFound(); + } - const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - if (!settings) { - notFound(); - } + const databaseHealthLogs = dbItem + ? await getHealthLast12hLogs({ id: dbItem.id }) + : []; - const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : [] + const successRate = + totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; + //const isMember = activeMember?.role === "member"; - const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; - - const isMember = activeMember?.role === "member"; - - return ( - - - - - - - - ); -} \ No newline at end of file + return ( + + + + + + + + ); +} diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx index 8f3b6c2c..536315fb 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/page.tsx @@ -1,108 +1,115 @@ -import {PageParams} from "@/types/next"; -import {Page, PageContent, PageTitle} from "@/features/layout/page"; -import { - ButtonDeleteProject -} from "@/features/projects/project-delete-button"; -import {CardsWithPagination} from "@/components/common/cards-with-pagination"; -import {ProjectDatabaseCard} from "@/features/projects/project-database-card"; -import {notFound, redirect} from "next/navigation"; -import {db} from "@/db"; -import {eq} from "drizzle-orm"; -import {getActiveMember, getOrganization} from "@/lib/auth/auth"; +import { PageParams } from "@/types/next"; +import { Page, PageContent, PageTitle } from "@/features/layout/page"; +import { ButtonDeleteProject } from "@/features/projects/project-delete-button"; +import { CardsWithPagination } from "@/components/common/cards-with-pagination"; +import { ProjectDatabaseCard } from "@/features/projects/project-database-card"; +import { notFound, redirect } from "next/navigation"; +import { db } from "@/db"; +import { eq } from "drizzle-orm"; +import { getActiveMember, getOrganization } from "@/lib/auth/auth"; import * as drizzleDb from "@/db"; -import {capitalizeFirstLetter} from "@/utils/text"; -import {ProjectDialog} from "@/features/projects/project-dialog"; -import {ProjectWith} from "@/db/schema/06_project"; -import {isUuidv4} from "@/utils/verify-uuid"; -import {getOrganizationAvailableDatabases} from "@/db/services/database"; +import { capitalizeFirstLetter, isUUID } from "@/utils/text"; +import { ProjectDialog } from "@/features/projects/project-dialog"; +import { ProjectWith } from "@/db/schema/06_project"; +import { getOrganizationAvailableDatabases } from "@/db/services/database"; +export default async function RoutePage( + props: PageParams<{ + projectId: string; + }>, +) { + const { projectId } = await props.params; -export default async function RoutePage(props: PageParams<{ - projectId: string -}>) { - const { - projectId - } = await props.params; + if (!isUUID(projectId)) { + notFound(); + } - if (!isUuidv4(projectId)) { - notFound() - } + const organization = await getOrganization({}); + const activeMember = await getActiveMember(); - const organization = await getOrganization({}); - const activeMember = await getActiveMember() + if (!organization) { + notFound(); + } + const org = await db.query.organization.findFirst({ + where: eq(drizzleDb.schemas.organization.slug, organization.slug), + }); - if (!organization) { - notFound(); - } - const org = await db.query.organization.findFirst({ - where: eq(drizzleDb.schemas.organization.slug, organization.slug), - }); + if (!org) notFound(); - if (!org) notFound(); + const proj = await db.query.project.findFirst({ + where: (proj, { and, eq, not }) => + and( + eq(proj.id, projectId), + eq(proj.organizationId, org.id), + not(eq(proj.isArchived, true)), + ), + with: { + databases: true, + }, + }); - const proj = await db.query.project.findFirst({ - where: (proj, { - and, - eq, - not - }) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))), - with: { - databases: true, - }, - }); + if (!proj) { + redirect("/dashboard/projects"); + } - if (!proj) { - redirect("/dashboard/projects"); - } + const availableDatabases = await getOrganizationAvailableDatabases( + organization.id, + proj.id, + ); + const isMember = activeMember?.role === "member"; - const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id) - const isMember = activeMember?.role === "member"; - - return ( - -
- -
- {capitalizeFirstLetter(proj.name)} -
- {!isMember && ( -
-
- -
-
- -
-
- )} -
+ return ( + +
+ +
+ {capitalizeFirstLetter(proj.name)} +
+ {!isMember && ( +
+
+ +
+
+ +
- - {proj.databases.length > 0 ? ( - - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - )} - organizationSlug={organization.slug} - // @ts-ignore - cardItem={ProjectDatabaseCard} - cardsPerPage={20} - numberOfColumns={3} - pageSizeOptions={[10, 20, 50]} - extendedProps={proj} - /> - ) : ( -
-

No databases found

-

You haven’t added any databases to this project yet.

-
- )} -
- - ); -} \ No newline at end of file + )} +
+
+ + {proj.databases.length > 0 ? ( + + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime(), + )} + organizationSlug={organization.slug} + // @ts-ignore + cardItem={ProjectDatabaseCard} + cardsPerPage={20} + numberOfColumns={3} + pageSizeOptions={[10, 20, 50]} + extendedProps={proj} + /> + ) : ( +
+

No databases found

+

+ You haven’t added any databases to this project yet. +

+
+ )} +
+
+ ); +} diff --git a/app/(customer)/dashboard/layout.tsx b/app/(customer)/dashboard/layout.tsx index 4773bf99..74a40963 100644 --- a/app/(customer)/dashboard/layout.tsx +++ b/app/(customer)/dashboard/layout.tsx @@ -5,12 +5,13 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { AppSidebar } from "@/features/layout/app-sidebar"; import { Header } from "@/features/layout/header"; import { currentUser } from "@/lib/auth/current-user"; -import { isOnboardingDone } from "@/features/onboarding/is-onboarding-done"; +import { isOnboardingDone } from "@/db/services/setting"; +import { env } from "@/env.mjs"; import { ModeToggle } from "@/features/theme/mode-toggle"; import { UpdateNotification } from "@/features/updates/update-notification"; export default async function Layout({ children }: { children: ReactNode }) { - if (!(await isOnboardingDone())) { + if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) { redirect("/welcome"); } diff --git a/app/(landing)/page.tsx b/app/(landing)/page.tsx index 1d453b2c..8d1da1a2 100644 --- a/app/(landing)/page.tsx +++ b/app/(landing)/page.tsx @@ -1,10 +1,11 @@ import { redirect } from "next/navigation"; import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie"; import { currentUser } from "@/lib/auth/current-user"; -import { isOnboardingDone } from "@/features/onboarding/is-onboarding-done"; +import { isOnboardingDone } from "@/db/services/setting"; +import { env } from "@/env.mjs"; export default async function Index() { - if (!(await isOnboardingDone())) { + if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) { redirect("/welcome"); } diff --git a/app/(welcome)/welcome/page.tsx b/app/(welcome)/welcome/page.tsx index c15be355..fc50104a 100644 --- a/app/(welcome)/welcome/page.tsx +++ b/app/(welcome)/welcome/page.tsx @@ -11,6 +11,7 @@ export default async function WelcomePage() { return ( diff --git a/app/api/agent/[agentId]/backup/upload/init/route.ts b/app/api/agent/[agentId]/backup/upload/init/route.ts index 4f9deb47..67a6bb03 100644 --- a/app/api/agent/[agentId]/backup/upload/init/route.ts +++ b/app/api/agent/[agentId]/backup/upload/init/route.ts @@ -1,79 +1,86 @@ -import {NextResponse} from "next/server"; -import {and, eq} from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { and, eq } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {db} from "@/db"; -import {getDatabaseOrThrow, withAgentCheck} from "../../helpers"; -import {isUuidv4} from "@/utils/verify-uuid"; -import {eventEmitter} from "@/lib/event"; -import {logger} from "@/lib/logger"; +import { db } from "@/db"; +import { getDatabaseOrThrow, withAgentCheck } from "../../helpers"; +import { eventEmitter } from "@/lib/event"; +import { logger } from "@/lib/logger"; +import { isUUID } from "@/utils/text"; -const log = logger.child({module: "api/agent/backup/upload/init"}); +const log = logger.child({ module: "api/agent/backup/upload/init" }); export type Body = { - generatedId: string - storageChannelId: string - backupId: string -} -export const POST = withAgentCheck(async (request: Request, {params, agent}: { - params: Promise<{ agentId: string }>, - agent: any -}) => { + generatedId: string; + storageChannelId: string; + backupId: string; +}; +export const POST = withAgentCheck( + async ( + request: Request, + { + params, + agent, + }: { + params: Promise<{ agentId: string }>; + agent: any; + }, + ) => { try { - const body: Body = await request.json(); + const body: Body = await request.json(); - log.info({data: body}, "Body for backup upload init"); + log.info({ data: body }, "Body for backup upload init"); - const generatedId = body.generatedId; - const storageChannelId = body.storageChannelId; - const backupId = body.backupId; - - if (!generatedId || !isUuidv4(generatedId)) { - return NextResponse.json( - {error: "generatedId is not a valid UUID"}, - {status: 400} - ); - } - - const database = await getDatabaseOrThrow(generatedId); - - const backup = await db.query.backup.findFirst({ - where: and( - eq(drizzleDb.schemas.backup.id, backupId), - eq(drizzleDb.schemas.backup.databaseId, database.id), - ), - }); - - if (!backup) { - return NextResponse.json( - {error: "Unable to find the corresponding backup"}, - {status: 404} - ); - } - - const [backupStorage] = await db - .insert(drizzleDb.schemas.backupStorage) - .values({ - backupId: backup.id, - storageChannelId: storageChannelId, - status: "pending", - }) - .returning(); - - eventEmitter.emit('modification', {update: true}); + const generatedId = body.generatedId; + const storageChannelId = body.storageChannelId; + const backupId = body.backupId; + if (!generatedId || !isUUID(generatedId)) { return NextResponse.json( - { - message: "Backup storage successfully created", - backupStorage: backupStorage - }, - {status: 200} + { error: "generatedId is not a valid UUID" }, + { status: 400 }, ); + } + + const database = await getDatabaseOrThrow(generatedId); + + const backup = await db.query.backup.findFirst({ + where: and( + eq(drizzleDb.schemas.backup.id, backupId), + eq(drizzleDb.schemas.backup.databaseId, database.id), + ), + }); + + if (!backup) { + return NextResponse.json( + { error: "Unable to find the corresponding backup" }, + { status: 404 }, + ); + } + + const [backupStorage] = await db + .insert(drizzleDb.schemas.backupStorage) + .values({ + backupId: backup.id, + storageChannelId: storageChannelId, + status: "pending", + }) + .returning(); + + eventEmitter.emit("modification", { update: true }); + + return NextResponse.json( + { + message: "Backup storage successfully created", + backupStorage: backupStorage, + }, + { status: 200 }, + ); } catch (error) { - log.error({error: error}, "Error in POST for INIT backup"); - return NextResponse.json( - {error: "Internal server error"}, - {status: 500} - ); + log.error({ error: error }, "Error in POST for INIT backup"); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } -}); - + }, +); diff --git a/app/api/agent/[agentId]/restore/route.ts b/app/api/agent/[agentId]/restore/route.ts index 267a92e0..ad770025 100644 --- a/app/api/agent/[agentId]/restore/route.ts +++ b/app/api/agent/[agentId]/restore/route.ts @@ -1,110 +1,124 @@ -import {NextResponse} from "next/server"; -import {isUuidv4} from "@/utils/verify-uuid"; +import { NextResponse } from "next/server"; import * as drizzleDb from "@/db"; -import {db as dbClient, db} from "@/db"; -import {and, eq} from "drizzle-orm"; -import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers"; -import {logger} from "@/lib/logger"; -import {withUpdatedAt} from "@/db/utils"; -import {JobLogEntry} from "@/features/logs/types"; +import { db as dbClient, db } from "@/db"; +import { and, eq } from "drizzle-orm"; +import { sendNotificationsBackupRestore } from "@/features/notifications/notifications.helpers"; +import { logger } from "@/lib/logger"; +import { withUpdatedAt } from "@/db/utils"; +import { JobLogEntry } from "@/features/logs/types"; +import { isUUID } from "@/utils/text"; -const log = logger.child({module: "api/agent/restore"}); +const log = logger.child({ module: "api/agent/restore" }); export type BodyResultRestore = { - generatedId: string - status: string - logs: JobLogEntry[] - durationMs: number -} -type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success'; - + generatedId: string; + status: string; + logs: JobLogEntry[]; + durationMs: number; +}; +type RestorationStatus = "waiting" | "ongoing" | "failed" | "success"; export async function POST( - request: Request, - {params}: { params: Promise<{ agentId: string }> } + request: Request, + { params }: { params: Promise<{ agentId: string }> }, ) { + try { + const agentId = (await params).agentId; + const body: BodyResultRestore = await request.json(); - try { - - const agentId = (await params).agentId - const body: BodyResultRestore = await request.json(); - - - if (!isUuidv4(body.generatedId)) { - return NextResponse.json( - {error: "generatedId is not a valid uuid"}, - {status: 500} - ); - } - - const agent = await db.query.agent.findFirst({ - where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)), - }) - if (!agent) { - return NextResponse.json({error: "Agent not found"}, {status: 404}) - } - - const database = await db.query.database.findFirst({ - where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId), - with: { - alertPolicies: true - } - }) - - if (!database) { - return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404}) - } - - const restoration = await db.query.restoration.findFirst({ - where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),) - }) - - if (!restoration) { - return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404}) - } - - const [restorationUpdated] = await db - .update(drizzleDb.schemas.restoration) - .set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs})) - .where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning(); - - - const logsToInsert = body.logs.map((entry) => ({ - backupId: null, - restorationId: restorationUpdated.id, - - loggedAt: new Date(entry.timestamp), - - entryType: entry.type, - level: entry.level, - - message: entry.message, - command: entry.command ?? null, - output: entry.output ?? null, - - exitCode: entry.exit_code ?? null, - durationMs: entry.duration_ms ?? null, - })); - - if (logsToInsert.length > 0) { - await dbClient - .insert(drizzleDb.schemas.jobLog) - .values(logsToInsert); - } - - await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore"); - - const response = { - status: true, - message: "Restoration successfully updated" - } - - return Response.json(response, {status: 200}) - } catch (error) { - log.error({error: error}, "Error in POST handler") - return NextResponse.json( - {error: 'Internal server error'}, - {status: 500} - ); + if (!isUUID(body.generatedId)) { + return NextResponse.json( + { error: "generatedId is not a valid uuid" }, + { status: 500 }, + ); } -} \ No newline at end of file + + const agent = await db.query.agent.findFirst({ + where: and( + eq(drizzleDb.schemas.agent.id, agentId), + eq(drizzleDb.schemas.agent.isArchived, false), + ), + }); + if (!agent) { + return NextResponse.json({ error: "Agent not found" }, { status: 404 }); + } + + const database = await db.query.database.findFirst({ + where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId), + with: { + alertPolicies: true, + }, + }); + + if (!database) { + return NextResponse.json( + { error: "Database associated with generatedId provided not found" }, + { status: 404 }, + ); + } + + const restoration = await db.query.restoration.findFirst({ + where: and( + eq(drizzleDb.schemas.restoration.status, "ongoing"), + eq(drizzleDb.schemas.restoration.databaseId, database.id), + ), + }); + + if (!restoration) { + return NextResponse.json( + { error: "Unable to fin the corresponding restoration" }, + { status: 404 }, + ); + } + + const [restorationUpdated] = await db + .update(drizzleDb.schemas.restoration) + .set( + withUpdatedAt({ + status: body.status as RestorationStatus, + durationMs: body.durationMs, + }), + ) + .where(eq(drizzleDb.schemas.restoration.id, restoration.id)) + .returning(); + + const logsToInsert = body.logs.map((entry) => ({ + backupId: null, + restorationId: restorationUpdated.id, + + loggedAt: new Date(entry.timestamp), + + entryType: entry.type, + level: entry.level, + + message: entry.message, + command: entry.command ?? null, + output: entry.output ?? null, + + exitCode: entry.exit_code ?? null, + durationMs: entry.duration_ms ?? null, + })); + + if (logsToInsert.length > 0) { + await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert); + } + + await sendNotificationsBackupRestore( + database, + body.status == "failed" ? "error_restore" : "success_restore", + ); + + const response = { + status: true, + message: "Restoration successfully updated", + }; + + return Response.json(response, { status: 200 }); + } catch (error) { + log.error({ error: error }, "Error in POST handler"); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/agent/[agentId]/status/helpers.ts b/app/api/agent/[agentId]/status/helpers.ts index 9b32bf1c..ca8605fd 100644 --- a/app/api/agent/[agentId]/status/helpers.ts +++ b/app/api/agent/[agentId]/status/helpers.ts @@ -1,275 +1,328 @@ -import {NextResponse} from "next/server"; -import {Body} from "./route"; -import {isUuidv4} from "@/utils/verify-uuid"; -import {Agent} from "@/db/schema/08_agent"; -import {DatabaseWith} from "@/db/schema/07_database"; +import { NextResponse } from "next/server"; +import { Body } from "./route"; +import { Agent } from "@/db/schema/08_agent"; +import { DatabaseWith } from "@/db/schema/07_database"; import * as drizzleDb from "@/db"; -import {db, db as dbClient} from "@/db"; -import {and, eq, inArray} from "drizzle-orm"; -import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types"; -import {withUpdatedAt} from "@/db/utils"; -import type {StorageInput} from "@/features/storages/storages.types"; -import {dispatchStorage} from "@/features/storages/storages.dispatch"; -import {Setting} from "@/db/schema/01_setting"; -import {logger} from "@/lib/logger"; +import { db, db as dbClient } from "@/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { dbmsEnumSchema, EDbmsSchema } from "@/db/schema/types"; +import { withUpdatedAt } from "@/db/utils"; +import type { StorageInput } from "@/features/storages/storages.types"; +import { dispatchStorage } from "@/features/storages/storages.dispatch"; +import { Setting } from "@/db/schema/01_setting"; +import { logger } from "@/lib/logger"; +import { isUUID } from "@/utils/text"; -const log = logger.child({module: "api/agent/status/helpers"}); +const log = logger.child({ module: "api/agent/status/helpers" }); -export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) { - const databasesResponse = []; +export async function handleDatabases( + body: Body, + agent: Agent, + lastContact: Date, + settings: Setting, +) { + const databasesResponse = []; - const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({ - generatedId: database.agentDatabaseId, - dbms: database.dbms, - storages: storages, - encrypt: settings.encryption, - data: { - backup: { - action: backupAction, - cron: database.backupPolicy, - }, - restore: { - action: restoreAction, - file: UrlBackup, - metaFile: urlMeta - }, - }, + const formatDatabase = ( + database: DatabaseWith, + backupAction: boolean, + restoreAction: boolean, + UrlBackup: string | null, + storages: PingDatabaseStorageChannels[], + urlMeta: string | null, + ) => ({ + generatedId: database.agentDatabaseId, + dbms: database.dbms, + storages: storages, + encrypt: settings.encryption, + data: { + backup: { + action: backupAction, + cron: database.backupPolicy, + }, + restore: { + action: restoreAction, + file: UrlBackup, + metaFile: urlMeta, + }, + }, + }); + + for (const db of body.databases) { + const existingDatabase = await dbClient.query.database.findFirst({ + where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId), + with: { + project: true, + }, }); - for (const db of body.databases) { + let backupAction: boolean = false; + let restoreAction: boolean = false; + let urlBackup: string | null = null; + let urlMeta: string | null = null; - const existingDatabase = await dbClient.query.database.findFirst({ - where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId), - with: { - project: true - } + if (!existingDatabase) { + if (!isUUID(db.generatedId)) { + return NextResponse.json( + { error: "generatedId is not a valid uuid" }, + { status: 500 }, + ); + } + + if (!dbmsEnumSchema.safeParse(db.dbms).success) { + log.error( + { name: "handleDatabases" }, + `Database type not available: ${db.dbms}`, + ); + continue; + } + + const [databaseCreated] = await dbClient + .insert(drizzleDb.schemas.database) + .values({ + agentId: agent.id, + name: db.name, + dbms: db.dbms as EDbmsSchema, + agentDatabaseId: db.generatedId, + lastContact: db.pingStatus ? lastContact : null, + healthErrorCount: null, + }) + .returning(); + + if (databaseCreated) { + await dbClient.insert(drizzleDb.schemas.healthcheckLog).values({ + kind: "database", + status: db.pingStatus ? "success" : "failed", + objectId: databaseCreated.id, + date: lastContact, }); - let backupAction: boolean = false - let restoreAction: boolean = false - let urlBackup: string | null = null; - let urlMeta: string | null = null + const storages = await getDatabaseStorageChannels(databaseCreated.id); - if (!existingDatabase) { - if (!isUuidv4(db.generatedId)) { - return NextResponse.json( - {error: "generatedId is not a valid uuid"}, - {status: 500} - ); - } + databasesResponse.push( + formatDatabase( + databaseCreated, + backupAction, + restoreAction, + urlBackup, + storages, + null, + ), + ); + } + } else { + const [databaseUpdated] = await dbClient + .update(drizzleDb.schemas.database) + .set( + withUpdatedAt({ + name: db.name, + agentId: agent.id, + dbms: db.dbms as EDbmsSchema, + lastContact: db.pingStatus + ? lastContact + : existingDatabase.lastContact, + healthErrorCount: db.pingStatus + ? null + : existingDatabase.healthErrorCount, + }), + ) + .where(eq(drizzleDb.schemas.database.id, existingDatabase.id)) + .returning(); - if (!dbmsEnumSchema.safeParse(db.dbms).success) { - log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`); - continue; - } + await dbClient.insert(drizzleDb.schemas.healthcheckLog).values({ + kind: "database", + status: db.pingStatus ? "success" : "failed", + objectId: databaseUpdated.id, + date: lastContact, + }); - const [databaseCreated] = await dbClient - .insert(drizzleDb.schemas.database) - .values({ - agentId: agent.id, - name: db.name, - dbms: db.dbms as EDbmsSchema, - agentDatabaseId: db.generatedId, - lastContact: db.pingStatus ? lastContact : null, - healthErrorCount: null - }) - .returning(); + const activeBackup = await dbClient.query.backup.findFirst({ + where: and( + eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), + inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]), + ), + }); + const restoration = await dbClient.query.restoration.findFirst({ + where: and( + eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), + eq(drizzleDb.schemas.restoration.status, "waiting"), + ), + with: { + backupStorage: true, + }, + }); - if (databaseCreated) { + if (activeBackup && activeBackup.status == "waiting") { + backupAction = true; + await dbClient + .update(drizzleDb.schemas.backup) + .set(withUpdatedAt({ status: "ongoing" })) + .where(eq(drizzleDb.schemas.backup.id, activeBackup.id)); + } - await dbClient - .insert(drizzleDb.schemas.healthcheckLog) - .values({ - kind: "database", - status: db.pingStatus ? "success" : "failed", - objectId: databaseCreated.id, - date: lastContact - }) + if (restoration) { + restoreAction = true; - const storages = await getDatabaseStorageChannels(databaseCreated.id) - - databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null)); - } - } else { - - const [databaseUpdated] = await dbClient - .update(drizzleDb.schemas.database) - .set(withUpdatedAt({ - name: db.name, - agentId: agent.id, - dbms: db.dbms as EDbmsSchema, - lastContact: db.pingStatus ? lastContact : existingDatabase.lastContact, - healthErrorCount: db.pingStatus ? null : existingDatabase.healthErrorCount, - })) - .where(eq(drizzleDb.schemas.database.id, existingDatabase.id)) - .returning(); - - - await dbClient - .insert(drizzleDb.schemas.healthcheckLog) - .values({ - kind: "database", - status: db.pingStatus ? "success" : "failed", - objectId: databaseUpdated.id, - date: lastContact - }) - - - const activeBackup = await dbClient.query.backup.findFirst({ - where: and( - eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), - inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]) - ) - }) - - const restoration = await dbClient.query.restoration.findFirst({ - where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")), - with: { - backupStorage: true - } - }) - - if (activeBackup && activeBackup.status == "waiting") { - backupAction = true - - await dbClient - .update(drizzleDb.schemas.backup) - .set(withUpdatedAt({status: "ongoing"})) - .where(eq(drizzleDb.schemas.backup.id, activeBackup.id)); - } - - if (restoration) { - restoreAction = true - - if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) { - restoreAction = false - continue; - } - - const input: StorageInput = { - action: "get", - data: { - path: restoration.backupStorage.path, - signedUrl: true, - }, - metadata: { - storageId: restoration.backupStorage.storageChannelId, - fileKind: "backups" - } - }; - - const inputMeta: StorageInput = { - action: "get", - data: { - path: `${restoration.backupStorage.path}.meta`, - signedUrl: true, - }, - metadata: { - storageId: restoration.backupStorage.storageChannelId, - fileKind: "backups" - } - }; - - - try { - const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId); - const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId); - - if (result.success) { - urlBackup = result.url ?? null; - urlMeta = resultMeta.url ?? null - } else { - await dbClient - .update(drizzleDb.schemas.restoration) - .set(withUpdatedAt({status: "failed"})) - .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); - - const errorMessage = "Failed to get backup URL"; - log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed"); - continue; - } - } catch (err) { - log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly"); - await dbClient - .update(drizzleDb.schemas.restoration) - .set(withUpdatedAt({status: "failed"})) - .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); - continue; - } - - await dbClient - .update(drizzleDb.schemas.restoration) - .set(withUpdatedAt({status: "ongoing"})) - .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); - } - const storages = await getDatabaseStorageChannels(databaseUpdated.id) - databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta)); + if ( + !restoration.backupStorage || + restoration.backupStorage.status != "success" || + !restoration.backupStorage.path + ) { + restoreAction = false; + continue; } - } - return databasesResponse; -} + const input: StorageInput = { + action: "get", + data: { + path: restoration.backupStorage.path, + signedUrl: true, + }, + metadata: { + storageId: restoration.backupStorage.storageChannelId, + fileKind: "backups", + }, + }; + + const inputMeta: StorageInput = { + action: "get", + data: { + path: `${restoration.backupStorage.path}.meta`, + signedUrl: true, + }, + metadata: { + storageId: restoration.backupStorage.storageChannelId, + fileKind: "backups", + }, + }; + + try { + const result = await dispatchStorage( + input, + undefined, + restoration.backupStorage.storageChannelId, + ); + const resultMeta = await dispatchStorage( + inputMeta, + undefined, + restoration.backupStorage.storageChannelId, + ); + + if (result.success) { + urlBackup = result.url ?? null; + urlMeta = resultMeta.url ?? null; + } else { + await dbClient + .update(drizzleDb.schemas.restoration) + .set(withUpdatedAt({ status: "failed" })) + .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); + + const errorMessage = "Failed to get backup URL"; + log.error( + { error: errorMessage, name: "handleDatabases" }, + "Restoration failed", + ); + continue; + } + } catch (err) { + log.error( + { error: err, name: "handleDatabases" }, + "Restoration crashed unexpectedly", + ); + await dbClient + .update(drizzleDb.schemas.restoration) + .set(withUpdatedAt({ status: "failed" })) + .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); + continue; + } + + await dbClient + .update(drizzleDb.schemas.restoration) + .set(withUpdatedAt({ status: "ongoing" })) + .where(eq(drizzleDb.schemas.restoration.id, restoration.id)); + } + const storages = await getDatabaseStorageChannels(databaseUpdated.id); + databasesResponse.push( + formatDatabase( + databaseUpdated, + backupAction, + restoreAction, + urlBackup, + storages, + urlMeta, + ), + ); + } + } + return databasesResponse; +} type PingDatabaseStorageChannels = { - id: string; - config: any - provider: string -} + id: string; + config: any; + provider: string; +}; -async function getDatabaseStorageChannels(databaseId: string): Promise { +async function getDatabaseStorageChannels( + databaseId: string, +): Promise { + const database = await db.query.database.findFirst({ + where: eq(drizzleDb.schemas.database.id, databaseId), + with: { + project: true, + retentionPolicy: true, + alertPolicies: true, + storagePolicies: true, + }, + }); - const database = await db.query.database.findFirst({ - where: eq(drizzleDb.schemas.database.id, databaseId), - with: { - project: true, - retentionPolicy: true, - alertPolicies: true, - storagePolicies: true - } - }); + if (!database) { + return []; + } - if (!database) { - return [] - } + const settings = await db.query.setting.findFirst({ + where: eq(drizzleDb.schemas.setting.name, "system"), + with: { storageChannel: true }, + }); - const settings = await db.query.setting.findFirst({ - where: eq(drizzleDb.schemas.setting.name, "system"), - with: {storageChannel: true}, - }); - - const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel - ? [{ + const defaultStorageChannel: PingDatabaseStorageChannels[] = + settings?.storageChannel + ? [ + { id: settings.storageChannel.id, provider: settings.storageChannel.provider, config: settings.storageChannel.config, - }] - : []; + }, + ] + : []; + const enabledDatabaseStorageChannels = await Promise.all( + (database.storagePolicies ?? []) + .filter((p) => p.enabled) + .map(async (policy) => { + const storageChannel = await db.query.storageChannel.findFirst({ + where: eq( + drizzleDb.schemas.storageChannel.id, + policy.storageChannelId, + ), + }); - const enabledDatabaseStorageChannels = await Promise.all( - (database.storagePolicies ?? []) - .filter(p => p.enabled) - .map(async policy => { - const storageChannel = await db.query.storageChannel.findFirst({ - where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId), - }); + if (!storageChannel) return null; - if (!storageChannel) return null; + return { + id: storageChannel.id, + config: storageChannel.config, + provider: storageChannel.provider, + } as PingDatabaseStorageChannels; + }), + ); - return { - id: storageChannel.id, - config: storageChannel.config, - provider: storageChannel.provider, - } as PingDatabaseStorageChannels; - }) + const filteredChannels: PingDatabaseStorageChannels[] = + enabledDatabaseStorageChannels.filter( + (c): c is PingDatabaseStorageChannels => c !== null, ); - const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter( - (c): c is PingDatabaseStorageChannels => c !== null - ); - - return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel; + return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel; } - diff --git a/app/api/agent/[agentId]/status/route.ts b/app/api/agent/[agentId]/status/route.ts index cf67b690..e5362d03 100644 --- a/app/api/agent/[agentId]/status/route.ts +++ b/app/api/agent/[agentId]/status/route.ts @@ -1,99 +1,107 @@ -import {NextResponse} from "next/server"; -import {handleDatabases} from "./helpers"; +import { NextResponse } from "next/server"; +import { handleDatabases } from "./helpers"; import * as drizzleDb from "@/db"; -import {db} from "@/db"; -import {EDbmsSchema} from "@/db/schema/types"; -import {and, eq} from "drizzle-orm"; -import {isUuidv4} from "@/utils/verify-uuid"; -import {withUpdatedAt} from "@/db/utils"; -import {logger} from "@/lib/logger"; - - -const log = logger.child({module: "api/agent/status/route"}); +import { db } from "@/db"; +import { EDbmsSchema } from "@/db/schema/types"; +import { and, eq } from "drizzle-orm"; +import { withUpdatedAt } from "@/db/utils"; +import { logger } from "@/lib/logger"; +import { isUUID } from "@/utils/text"; +const log = logger.child({ module: "api/agent/status/route" }); export type databaseAgent = { - name: string, - dbms: EDbmsSchema, - generatedId: string - pingStatus: boolean -} + name: string; + dbms: EDbmsSchema; + generatedId: string; + pingStatus: boolean; +}; export type Body = { - version: string, - databases: databaseAgent[] -} - + version: string; + databases: databaseAgent[]; +}; export async function POST( - request: Request, - {params}: { params: Promise<{ agentId: string }> } + request: Request, + { params }: { params: Promise<{ agentId: string }> }, ) { - try { - const agentId = (await params).agentId - log.debug(`Agent ID: ${agentId}`) - const body: Body = await request.json(); - const lastContact = new Date(); - let message: string + try { + const agentId = (await params).agentId; + log.debug(`Agent ID: ${agentId}`); + const body: Body = await request.json(); + const lastContact = new Date(); + let message: string; - if (!isUuidv4(agentId)) { - message = "agentId is not a valid uuid" - log.error({error: message}, "An error occurred") - return NextResponse.json( - {error: "agentId is not a valid uuid"}, - {status: 500} - ); - } - - const agent = await db.query.agent.findFirst({ - where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)), - }) - - if (!agent) { - message = "Agent not found" - return NextResponse.json({error: message}, {status: 404}) - } - - const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); - if (!settings) { - return NextResponse.json({error: "An error occured"}, {status: 404}) - } - - const databasesResponse = await handleDatabases(body, agent, lastContact, settings) - - await db - .update(drizzleDb.schemas.agent) - .set(withUpdatedAt({ - version: body.version, - lastContact: lastContact, - healthErrorCount: null - })) - .where(eq(drizzleDb.schemas.agent.id, agentId)); - - await db - .insert(drizzleDb.schemas.healthcheckLog) - .values({ - kind: "agent", - status: "success", - objectId: agentId, - date: lastContact - }) - - const response = { - agent: { - id: agentId, - lastContact: lastContact - }, - databases: databasesResponse - } - - return Response.json(response) - } catch (error) { - log.error({error: error}, "Error in POST handler") - return NextResponse.json( - {error: 'Internal server error'}, - {status: 500} - ); + if (!isUUID(agentId)) { + message = "agentId is not a valid uuid"; + log.error({ error: message }, "An error occurred"); + return NextResponse.json( + { error: "agentId is not a valid uuid" }, + { status: 500 }, + ); } -} + const agent = await db.query.agent.findFirst({ + where: and( + eq(drizzleDb.schemas.agent.id, agentId), + eq(drizzleDb.schemas.agent.isArchived, false), + ), + }); + + if (!agent) { + message = "Agent not found"; + return NextResponse.json({ error: message }, { status: 404 }); + } + + const [settings] = await db + .select() + .from(drizzleDb.schemas.setting) + .where(eq(drizzleDb.schemas.setting.name, "system")) + .limit(1); + if (!settings) { + return NextResponse.json({ error: "An error occured" }, { status: 404 }); + } + + const databasesResponse = await handleDatabases( + body, + agent, + lastContact, + settings, + ); + + await db + .update(drizzleDb.schemas.agent) + .set( + withUpdatedAt({ + version: body.version, + lastContact: lastContact, + healthErrorCount: null, + }), + ) + .where(eq(drizzleDb.schemas.agent.id, agentId)); + + await db.insert(drizzleDb.schemas.healthcheckLog).values({ + kind: "agent", + status: "success", + objectId: agentId, + date: lastContact, + }); + + const response = { + agent: { + id: agentId, + lastContact: lastContact, + }, + databases: databasesResponse, + }; + + return Response.json(response); + } catch (error) { + log.error({ error: error }, "Error in POST handler"); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/avatar/route.tsx b/app/api/avatar/route.tsx index 6235a3ab..3c4fdee8 100644 --- a/app/api/avatar/route.tsx +++ b/app/api/avatar/route.tsx @@ -1,7 +1,8 @@ import { ImageResponse } from "next/og"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { getSettings } from "@/db/services/setting"; -export const runtime = "edge"; +export const runtime = "nodejs"; const AVATAR_COLORS = [ "#4f46e5", @@ -15,6 +16,11 @@ const AVATAR_COLORS = [ ]; export async function GET(request: NextRequest) { + const settings = await getSettings(); + if (settings?.avatarMode && settings.avatarMode !== "internal") { + return new NextResponse(null, { status: 404 }); + } + const { searchParams } = new URL(request.url); const initials = (searchParams.get("initials") ?? "?") .slice(0, 2) diff --git a/portabase.config.ts b/portabase.config.ts index 7b3b728a..4e756ada 100644 --- a/portabase.config.ts +++ b/portabase.config.ts @@ -46,7 +46,7 @@ export const PORTABASE_DEFAULT_SETTINGS = { "https://api.iconify.design", "https://code.iconify.design", "https://api.github.com", - + "https://api.dicebear.com", ], OBJECT_SRC: ["'none'"], BASE_URI: ["'self'"], diff --git a/src/db/migrations/0067_adorable_jean_grey.sql b/src/db/migrations/0067_adorable_jean_grey.sql new file mode 100644 index 00000000..455e0c92 --- /dev/null +++ b/src/db/migrations/0067_adorable_jean_grey.sql @@ -0,0 +1,2 @@ +CREATE TYPE "public"."avatar_mode" AS ENUM('internal', 'gravatar', 'dicebear');--> statement-breakpoint +ALTER TABLE "settings" ADD COLUMN "avatar_mode" "avatar_mode" DEFAULT 'internal' NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/0068_bitter_revanche.sql b/src/db/migrations/0068_bitter_revanche.sql new file mode 100644 index 00000000..a801cd73 --- /dev/null +++ b/src/db/migrations/0068_bitter_revanche.sql @@ -0,0 +1 @@ +ALTER TABLE "settings" ADD COLUMN "dicebear_style" varchar(64) DEFAULT 'thumbs' NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0067_snapshot.json b/src/db/migrations/meta/0067_snapshot.json new file mode 100644 index 00000000..99b22bc1 --- /dev/null +++ b/src/db/migrations/meta/0067_snapshot.json @@ -0,0 +1,2994 @@ +{ + "id": "c2dda0eb-6358-43d0-8d13-17c2b3641f21", + "prevId": "30cd506c-1b93-44d2-9b65-5c0987efeb1a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "smtp_secure": { + "name": "smtp_secure", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "default_notification_channel_id": { + "name": "default_notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "default_storage_channel_id": { + "name": "default_storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "encryption": { + "name": "encryption", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding": { + "name": "onboarding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_mode": { + "name": "avatar_mode", + "type": "avatar_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "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_notification_channel_id_notification_channel_id_fk": { + "name": "settings_default_notification_channel_id_notification_channel_id_fk", + "tableFrom": "settings", + "tableTo": "notification_channel", + "columnsFrom": [ + "default_notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "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 + }, + "user_id": { + "name": "user_id", + "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 + }, + "aaguid": { + "name": "aaguid", + "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_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "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 + }, + "verified": { + "name": "verified", + "type": "boolean", + "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 + }, + "lastConnectedAt": { + "name": "lastConnectedAt", + "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": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "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 + }, + "migrated": { + "name": "migrated", + "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 + }, + "health_error_count": { + "name": "health_error_count", + "type": "integer", + "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'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "health_error_count": { + "name": "health_error_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "organization_id": { + "name": "organization_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": { + "agents_organization_id_organization_id_fk": { + "name": "agents_organization_id_organization_id_fk", + "tableFrom": "agents", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_agents": { + "name": "organization_agents", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_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": { + "organization_agents_organization_id_organization_id_fk": { + "name": "organization_agents_organization_id_organization_id_fk", + "tableFrom": "organization_agents", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_agents_agent_id_agents_id_fk": { + "name": "organization_agents_agent_id_agents_id_fk", + "tableFrom": "organization_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_agents_organization_id_agent_id_unique": { + "name": "organization_agents_organization_id_agent_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "agent_id" + ] + } + }, + "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": "bigint", + "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 + }, + "public.healthcheck_log": { + "name": "healthcheck_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "healthcheck_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "healthcheck_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_log": { + "name": "job_log", + "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": false + }, + "restoration_id": { + "name": "restoration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "entry_type": { + "name": "entry_type", + "type": "job_log_entry_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "job_log_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "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": { + "job_log_backup_id_backups_id_fk": { + "name": "job_log_backup_id_backups_id_fk", + "tableFrom": "job_log", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_log_restoration_id_restorations_id_fk": { + "name": "job_log_restoration_id_restorations_id_fk", + "tableFrom": "job_log", + "tableTo": "restorations", + "columnsFrom": [ + "restoration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.avatar_mode": { + "name": "avatar_mode", + "schema": "public", + "values": [ + "internal", + "gravatar", + "dicebear" + ] + }, + "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", + "nextcloud", + "teams", + "pushover" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report", + "error_health_agent", + "error_health_database" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.provider_storage_kind": { + "name": "provider_storage_kind", + "schema": "public", + "values": [ + "local", + "s3", + "google-drive", + "blob" + ] + }, + "public.backup_storage_status": { + "name": "backup_storage_status", + "schema": "public", + "values": [ + "pending", + "success", + "failed" + ] + }, + "public.healthcheck_status": { + "name": "healthcheck_status", + "schema": "public", + "values": [ + "success", + "failed" + ] + }, + "public.healthcheck_kind": { + "name": "healthcheck_kind", + "schema": "public", + "values": [ + "database", + "agent" + ] + }, + "public.job_log_entry_type": { + "name": "job_log_entry_type", + "schema": "public", + "values": [ + "log", + "command" + ] + }, + "public.job_log_level": { + "name": "job_log_level", + "schema": "public", + "values": [ + "debug", + "info", + "warn", + "error" + ] + }, + "public.dbms_status": { + "name": "dbms_status", + "schema": "public", + "values": [ + "postgresql", + "mysql", + "mariadb", + "mongodb", + "sqlite", + "redis", + "valkey", + "firebird", + "mssql" + ] + }, + "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/0068_snapshot.json b/src/db/migrations/meta/0068_snapshot.json new file mode 100644 index 00000000..4a483ed0 --- /dev/null +++ b/src/db/migrations/meta/0068_snapshot.json @@ -0,0 +1,3001 @@ +{ + "id": "e39ddcff-5791-4157-84ec-e38c27b81d67", + "prevId": "c2dda0eb-6358-43d0-8d13-17c2b3641f21", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "smtp_secure": { + "name": "smtp_secure", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "default_notification_channel_id": { + "name": "default_notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "default_storage_channel_id": { + "name": "default_storage_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "encryption": { + "name": "encryption", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding": { + "name": "onboarding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_mode": { + "name": "avatar_mode", + "type": "avatar_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "dicebear_style": { + "name": "dicebear_style", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'thumbs'" + }, + "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_notification_channel_id_notification_channel_id_fk": { + "name": "settings_default_notification_channel_id_notification_channel_id_fk", + "tableFrom": "settings", + "tableTo": "notification_channel", + "columnsFrom": [ + "default_notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "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 + }, + "user_id": { + "name": "user_id", + "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 + }, + "aaguid": { + "name": "aaguid", + "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_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "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 + }, + "verified": { + "name": "verified", + "type": "boolean", + "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 + }, + "lastConnectedAt": { + "name": "lastConnectedAt", + "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": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "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 + }, + "migrated": { + "name": "migrated", + "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 + }, + "health_error_count": { + "name": "health_error_count", + "type": "integer", + "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'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "health_error_count": { + "name": "health_error_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "organization_id": { + "name": "organization_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": { + "agents_organization_id_organization_id_fk": { + "name": "agents_organization_id_organization_id_fk", + "tableFrom": "agents", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_agents": { + "name": "organization_agents", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_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": { + "organization_agents_organization_id_organization_id_fk": { + "name": "organization_agents_organization_id_organization_id_fk", + "tableFrom": "organization_agents", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_agents_agent_id_agents_id_fk": { + "name": "organization_agents_agent_id_agents_id_fk", + "tableFrom": "organization_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_agents_organization_id_agent_id_unique": { + "name": "organization_agents_organization_id_agent_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "agent_id" + ] + } + }, + "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": "bigint", + "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 + }, + "public.healthcheck_log": { + "name": "healthcheck_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "healthcheck_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "healthcheck_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_log": { + "name": "job_log", + "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": false + }, + "restoration_id": { + "name": "restoration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "entry_type": { + "name": "entry_type", + "type": "job_log_entry_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "job_log_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "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": { + "job_log_backup_id_backups_id_fk": { + "name": "job_log_backup_id_backups_id_fk", + "tableFrom": "job_log", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_log_restoration_id_restorations_id_fk": { + "name": "job_log_restoration_id_restorations_id_fk", + "tableFrom": "job_log", + "tableTo": "restorations", + "columnsFrom": [ + "restoration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.avatar_mode": { + "name": "avatar_mode", + "schema": "public", + "values": [ + "internal", + "gravatar", + "dicebear" + ] + }, + "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", + "nextcloud", + "teams", + "pushover" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report", + "error_health_agent", + "error_health_database" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.provider_storage_kind": { + "name": "provider_storage_kind", + "schema": "public", + "values": [ + "local", + "s3", + "google-drive", + "blob" + ] + }, + "public.backup_storage_status": { + "name": "backup_storage_status", + "schema": "public", + "values": [ + "pending", + "success", + "failed" + ] + }, + "public.healthcheck_status": { + "name": "healthcheck_status", + "schema": "public", + "values": [ + "success", + "failed" + ] + }, + "public.healthcheck_kind": { + "name": "healthcheck_kind", + "schema": "public", + "values": [ + "database", + "agent" + ] + }, + "public.job_log_entry_type": { + "name": "job_log_entry_type", + "schema": "public", + "values": [ + "log", + "command" + ] + }, + "public.job_log_level": { + "name": "job_log_level", + "schema": "public", + "values": [ + "debug", + "info", + "warn", + "error" + ] + }, + "public.dbms_status": { + "name": "dbms_status", + "schema": "public", + "values": [ + "postgresql", + "mysql", + "mariadb", + "mongodb", + "sqlite", + "redis", + "valkey", + "firebird", + "mssql" + ] + }, + "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 9893e063..88b1bef7 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -463,6 +463,20 @@ "when": 1782118902777, "tag": "0065_overjoyed_mantis", "breakpoints": true + }, + { + "idx": 67, + "version": "7", + "when": 1782133059337, + "tag": "0067_adorable_jean_grey", + "breakpoints": true + }, + { + "idx": 68, + "version": "7", + "when": 1782134460680, + "tag": "0068_bitter_revanche", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/01_setting.ts b/src/db/schema/01_setting.ts index da66b0b6..cb3e3d82 100644 --- a/src/db/schema/01_setting.ts +++ b/src/db/schema/01_setting.ts @@ -1,4 +1,6 @@ -import {boolean, pgTable, uuid, varchar} from "drizzle-orm/pg-core"; +import {boolean, pgEnum, pgTable, uuid, varchar} from "drizzle-orm/pg-core"; + +export const avatarModeEnum = pgEnum('avatar_mode', ['internal', 'gravatar', 'dicebear']); import {createSelectSchema} from "drizzle-zod"; import {z} from "zod"; import {timestamps} from "@/db/schema/00_common"; @@ -21,6 +23,8 @@ export const setting = pgTable("settings", { .references(() => storageChannel.id, {onDelete: "set null"}), encryption: boolean("encryption").default(false), onboarding: boolean("onboarding").default(false).notNull(), + avatarMode: avatarModeEnum('avatar_mode').default('internal').notNull(), + dicebearStyle: varchar('dicebear_style', { length: 64 }).default('thumbs').notNull(), ...timestamps }); diff --git a/src/db/services/agent.ts b/src/db/services/agent.ts index 6638f395..ce7ed2eb 100644 --- a/src/db/services/agent.ts +++ b/src/db/services/agent.ts @@ -1,43 +1,41 @@ -import {and, desc, eq, sql} from "drizzle-orm"; -import {db} from "@/db"; -import {Agent, agent, organizationAgent} from "@/db/schema/08_agent"; -import {Database, database} from "@/db/schema/07_database"; +"use server"; + +import { and, desc, eq, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { Agent, agent, organizationAgent } from "@/db/schema/08_agent"; +import { Database, database } from "@/db/schema/07_database"; export async function getOrganizationAgents(organizationId: string) { - - return await db - .select({ - id: agent.id, - name: agent.name, - organizationId: agent.organizationId, - slug: agent.slug, - healthErrorCount: agent.healthErrorCount, - description: agent.description, - isArchived: agent.isArchived, - lastContact: agent.lastContact, - version: agent.version, - updatedAt: agent.updatedAt, - createdAt: agent.createdAt, - deletedAt: agent.deletedAt, - databases: sql` + return (await db + .select({ + id: agent.id, + name: agent.name, + organizationId: agent.organizationId, + slug: agent.slug, + healthErrorCount: agent.healthErrorCount, + description: agent.description, + isArchived: agent.isArchived, + lastContact: agent.lastContact, + version: agent.version, + updatedAt: agent.updatedAt, + createdAt: agent.createdAt, + deletedAt: agent.deletedAt, + databases: sql` COALESCE( json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL), '[]' ) `, - }) - .from(organizationAgent) - .innerJoin( - agent, - eq(organizationAgent.agentId, agent.id) - ) - .leftJoin(database, eq(database.agentId, agent.id)) - .groupBy(agent.id) - .orderBy(desc(agent.createdAt)) - .where( - and( - eq(organizationAgent.organizationId, organizationId), - eq(agent.isArchived, false) - ) - ) as unknown as Agent[]; + }) + .from(organizationAgent) + .innerJoin(agent, eq(organizationAgent.agentId, agent.id)) + .leftJoin(database, eq(database.agentId, agent.id)) + .groupBy(agent.id) + .orderBy(desc(agent.createdAt)) + .where( + and( + eq(organizationAgent.organizationId, organizationId), + eq(agent.isArchived, false), + ), + )) as unknown as Agent[]; } diff --git a/src/db/services/backup.ts b/src/db/services/backup.ts index d9234be4..717d013b 100644 --- a/src/db/services/backup.ts +++ b/src/db/services/backup.ts @@ -1,19 +1,19 @@ -"use server" -import {eq} from "drizzle-orm"; +"use server"; +import { eq } from "drizzle-orm"; import * as drizzleDb from "@/db"; -import {db} 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), + return await db.query.backup.findMany({ + where: eq(drizzleDb.schemas.backup.databaseId, databaseId), + with: { + restorations: true, + storages: { with: { - restorations: true, - storages: { - with: { - storageChannel: true, - }, - }, + storageChannel: true, }, - orderBy: (b, {desc}) => [desc(b.createdAt)], - }); + }, + }, + orderBy: (b, { desc }) => [desc(b.createdAt)], + }); } diff --git a/src/db/services/database.ts b/src/db/services/database.ts index f0ea0d4b..77113426 100644 --- a/src/db/services/database.ts +++ b/src/db/services/database.ts @@ -1,39 +1,123 @@ -"use server" -import {db} from "@/db"; -import {DatabaseWith} from "@/db/schema/07_database"; -import {AgentWith} from "@/db/schema/08_agent"; +"use server"; +import { inArray } from "drizzle-orm"; +import { db } from "@/db"; +import { database, retentionPolicy } from "@/db/schema/07_database"; +import { alertPolicy } from "@/db/schema/10_alert-policy"; +import { storagePolicy } from "@/db/schema/13_storage-policy"; +import { DatabaseWith } from "@/db/schema/07_database"; +import { AgentWith } from "@/db/schema/08_agent"; +import type { + OnboardingDbSettings, + EventKind, +} from "@/features/onboarding/types"; export async function getOrganizationAvailableDatabases( - organizationId: string, - projectId?: string + organizationId: string, + projectId?: string, ) { + const availableDatabases = (await db.query.database.findMany({ + where: (db, { eq, or, isNull }) => + projectId + ? or(isNull(db.projectId), eq(db.projectId, projectId)) + : isNull(db.projectId), + with: { + agent: { + with: { + organizations: true, + }, + }, + project: true, + backups: true, + restorations: true, + }, + orderBy: (db, { desc }) => [desc(db.createdAt)], + })) as DatabaseWith[]; - const availableDatabases = ( - await db.query.database.findMany({ - where: (db, { eq, or, isNull }) => - projectId - ? or(isNull(db.projectId), eq(db.projectId, projectId)) - : isNull(db.projectId), - with: { - agent: { - with: { - organizations: true - } - }, - project: true, - backups: true, - restorations: true, - }, - orderBy: (db, {desc}) => [desc(db.createdAt)], - }) - ) as DatabaseWith[]; - - return availableDatabases.filter(db => { - const agent = db.agent as AgentWith; - if (agent?.isArchived) return false; - return ( - agent?.organizationId === organizationId || - agent?.organizations?.some(org => org.organizationId === organizationId) - ); - }) + return availableDatabases.filter((db) => { + const agent = db.agent as AgentWith; + if (agent?.isArchived) return false; + return ( + agent?.organizationId === organizationId || + agent?.organizations?.some((org) => org.organizationId === organizationId) + ); + }); +} + +export async function getDatabasesSettings( + databaseIds: string[], +): Promise> { + if (databaseIds.length === 0) return {}; + + const [retentionPolicies, dbs, alertPolicies, storagePolicies] = + await Promise.all([ + db + .select() + .from(retentionPolicy) + .where(inArray(retentionPolicy.databaseId, databaseIds)), + db + .select({ id: database.id, backupPolicy: database.backupPolicy }) + .from(database) + .where(inArray(database.id, databaseIds)), + db + .select() + .from(alertPolicy) + .where(inArray(alertPolicy.databaseId, databaseIds)), + db + .select() + .from(storagePolicy) + .where(inArray(storagePolicy.databaseId, databaseIds)), + ]); + + const result: Record = {}; + + for (const dbId of databaseIds) { + const rp = retentionPolicies.find((r) => r.databaseId === dbId); + const dbRow = dbs.find((d) => d.id === dbId); + const alerts = alertPolicies.filter((a) => a.databaseId === dbId); + const storages = storagePolicies.filter((s) => s.databaseId === dbId); + + const settings: OnboardingDbSettings = {}; + + if (rp) { + settings.retention = { + type: rp.type, + count: rp.count ?? 7, + days: rp.days ?? 30, + gfs: { + daily: rp.gfsDaily ?? 7, + weekly: rp.gfsWeekly ?? 4, + monthly: rp.gfsMonthly ?? 12, + yearly: rp.gfsYearly ?? 3, + }, + }; + } + + if (dbRow) { + if (dbRow.backupPolicy) { + settings.backupMethod = "automatic"; + settings.backupCron = dbRow.backupPolicy; + } else { + settings.backupMethod = "manual"; + } + } + + if (alerts.length > 0) { + settings.notificationPolicies = alerts.map((a) => ({ + channelId: a.notificationChannelId, + eventKinds: a.eventKinds as EventKind[], + enabled: a.enabled, + })); + } + + if (storages.length > 0) { + settings.storagePolicies = storages.map((s) => ({ + channelId: s.storageChannelId, + enabled: s.enabled, + })); + } + + result[dbId] = settings; + } + + return result; } diff --git a/src/db/services/healthcheck.ts b/src/db/services/healthcheck.ts index 6c16ba17..e5df9f45 100644 --- a/src/db/services/healthcheck.ts +++ b/src/db/services/healthcheck.ts @@ -1,185 +1,200 @@ -import {db} from "@/db"; +"use server"; + +import { db } from "@/db"; import * as drizzleDb from "@/db"; -import {and, eq, gte, isNotNull, lt} from "drizzle-orm"; -import {dispatchNotification} from "@/features/notifications/notifications.dispatch"; -import {EventPayload} from "@/features/notifications/notifications.types"; -import {logger} from "@/lib/logger"; +import { and, eq, gte, isNotNull, lt } from "drizzle-orm"; +import { dispatchNotification } from "@/features/notifications/notifications.dispatch"; +import { EventPayload } from "@/features/notifications/notifications.types"; +import { logger } from "@/lib/logger"; -const log = logger.child({module: "tasks/healthcheck"}); +const log = logger.child({ module: "tasks/healthcheck" }); -export async function getHealthLast12hLogs({id}: { id: string }) { - const now = new Date() - const since = new Date(now.getTime() - 12 * 60 * 60 * 1000) +export async function getHealthLast12hLogs({ id }: { id: string }) { + const now = new Date(); + const since = new Date(now.getTime() - 12 * 60 * 60 * 1000); - return db - .select() - .from(drizzleDb.schemas.healthcheckLog) - .where( - and( - eq(drizzleDb.schemas.healthcheckLog.objectId, id), - gte(drizzleDb.schemas.healthcheckLog.date, since) - ) - ) + return db + .select() + .from(drizzleDb.schemas.healthcheckLog) + .where( + and( + eq(drizzleDb.schemas.healthcheckLog.objectId, id), + gte(drizzleDb.schemas.healthcheckLog.date, since), + ), + ); } export async function deleteHealthLogsOlderThan12h() { - const now = new Date() - const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000) + const now = new Date(); + const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000); - const logsToDelete = await db - .select() - .from(drizzleDb.schemas.healthcheckLog) - .where( - lt(drizzleDb.schemas.healthcheckLog.date, threshold) - ) + const logsToDelete = await db + .select() + .from(drizzleDb.schemas.healthcheckLog) + .where(lt(drizzleDb.schemas.healthcheckLog.date, threshold)); - log.info({name: "deleteHealthLogsOlderThan12h"},`Number of logs found to delete: ${logsToDelete.length}`) + log.info( + { name: "deleteHealthLogsOlderThan12h" }, + `Number of logs found to delete: ${logsToDelete.length}`, + ); - await db - .delete(drizzleDb.schemas.healthcheckLog) - .where( - lt(drizzleDb.schemas.healthcheckLog.date, threshold) - ) + await db + .delete(drizzleDb.schemas.healthcheckLog) + .where(lt(drizzleDb.schemas.healthcheckLog.date, threshold)); - return logsToDelete.length + return logsToDelete.length; } export async function checkAgentsHealthError() { - const agents = await db.query.agent.findMany({ - where: isNotNull(drizzleDb.schemas.agent.lastContact), - }); + const agents = await db.query.agent.findMany({ + where: isNotNull(drizzleDb.schemas.agent.lastContact), + }); - const settings = await db.query.setting.findFirst({ - where: (fields, {eq}) => eq(fields.name, "system"), - }); + const settings = await db.query.setting.findFirst({ + where: (fields, { eq }) => eq(fields.name, "system"), + }); - if (!settings) { - throw new Error("System settings not found"); - } - - if (!settings.defaultNotificationChannelId) { - log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`) - return - } - - const now = new Date(); - - for (const agent of agents) { - if (!agent.lastContact) continue; - - const lastContactDate = new Date(agent.lastContact); - const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; - - if (diffMinutes > 10) { - if ((agent.healthErrorCount ?? 0) < 3) { - - const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1 - await db.update(drizzleDb.schemas.agent) - .set({ - healthErrorCount: newHealthErrorCount, - }) - .where(eq(drizzleDb.schemas.agent.id, agent.id)); - - const payload: EventPayload = { - title: "Agent down", - message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`, - level: "critical", - event: "error_health_agent", - data: { - agent: agent.name, - id: agent.id, - error: "Agent is down", - }, - }; - log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`) - - await dispatchNotification( - payload, - undefined, - settings.defaultNotificationChannelId, - undefined - ); - } - - } + if (!settings) { + throw new Error("System settings not found"); + } + + if (!settings.defaultNotificationChannelId) { + log.error( + { name: "checkAgentsHealthError" }, + `No default notification channel id found.`, + ); + return; + } + + const now = new Date(); + + for (const agent of agents) { + if (!agent.lastContact) continue; + + const lastContactDate = new Date(agent.lastContact); + const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; + + if (diffMinutes > 10) { + if ((agent.healthErrorCount ?? 0) < 3) { + const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1; + await db + .update(drizzleDb.schemas.agent) + .set({ + healthErrorCount: newHealthErrorCount, + }) + .where(eq(drizzleDb.schemas.agent.id, agent.id)); + + const payload: EventPayload = { + title: "Agent down", + message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`, + level: "critical", + event: "error_health_agent", + data: { + agent: agent.name, + id: agent.id, + error: "Agent is down", + }, + }; + log.info( + { name: "checkAgentsHealthError", payload: payload }, + `Agent Healthcheck Notification`, + ); + + await dispatchNotification( + payload, + undefined, + settings.defaultNotificationChannelId, + undefined, + ); + } } + } } - - export async function checkDatabasesHealthError() { + const databases = await db.query.database.findMany({ + where: isNotNull(drizzleDb.schemas.database.lastContact), + with: { + agent: true, + alertPolicies: true, + }, + }); - const databases = await db.query.database.findMany({ - where: isNotNull(drizzleDb.schemas.database.lastContact), - with: { - agent: true, - alertPolicies: true + const now = new Date(); + + for (const database of databases) { + if (!database.lastContact) continue; + + const lastContactDate = new Date(database.lastContact); + const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; + + if (diffMinutes > 10) { + if ((database.healthErrorCount ?? 0) < 3) { + const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1; + await db + .update(drizzleDb.schemas.database) + .set({ + healthErrorCount: newHealthErrorCount, + }) + .where(eq(drizzleDb.schemas.database.id, database.id)); + + const settings = await db.query.setting.findFirst({ + where: eq(drizzleDb.schemas.setting.name, "system"), + with: { notificationChannel: true }, + }); + + const defaultPolicy = settings?.notificationChannel + ? [ + { + id: null, + notificationChannelId: settings.notificationChannel.id, + enabled: settings.notificationChannel.enabled, + eventKinds: ["error_health_database"], + }, + ] + : []; + + const policiesToUse = + database.alertPolicies && database.alertPolicies.length > 0 + ? database.alertPolicies.filter( + (policy) => + policy.enabled && + policy.eventKinds.includes("error_health_database"), + ) + : defaultPolicy; + + if (!policiesToUse || policiesToUse.length === 0) { + continue; } - }) - const now = new Date(); + const promises = policiesToUse.map((alertPolicy) => { + const payload: EventPayload = { + title: "Database down", + message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`, + level: "critical", + event: "error_health_database", + data: { + agent: database.name, + id: database.id, + error: "Database is down", + }, + }; - for (const database of databases) { - if (!database.lastContact) continue; + log.info( + { name: "checkDatabasesHealthError", payload: payload }, + `Database Healthcheck Notification`, + ); - const lastContactDate = new Date(database.lastContact); - const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; + return dispatchNotification( + payload, + alertPolicy.id == null ? undefined : alertPolicy.id, + alertPolicy.id ? undefined : alertPolicy.notificationChannelId, + undefined, + ); + }); - if (diffMinutes > 10) { - if ((database.healthErrorCount ?? 0) < 3) { - - const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1 - await db.update(drizzleDb.schemas.database) - .set({ - healthErrorCount: newHealthErrorCount, - }) - .where(eq(drizzleDb.schemas.database.id, database.id)); - - const settings = await db.query.setting.findFirst({ - where: eq(drizzleDb.schemas.setting.name, "system"), - with: { notificationChannel: true }, - }); - - const defaultPolicy = settings?.notificationChannel - ? [{ - id: null, - notificationChannelId: settings.notificationChannel.id, - enabled: settings.notificationChannel.enabled, - eventKinds: ["error_health_database"] - }] - : []; - - const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0) - ? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database")) - : defaultPolicy; - - if (!policiesToUse || policiesToUse.length === 0) { - continue - } - - const promises = policiesToUse.map(alertPolicy => { - - const payload: EventPayload = { - title: "Database down", - message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`, - level: "critical", - event: "error_health_database", - data: { - agent: database.name, - id: database.id, - error: "Database is down", - }, - }; - - log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`) - - return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined); - }); - - await Promise.all(promises); - } - } + await Promise.all(promises); + } } + } } - - diff --git a/src/db/services/notification-channel.ts b/src/db/services/notification-channel.ts index 06cafa21..50040e59 100644 --- a/src/db/services/notification-channel.ts +++ b/src/db/services/notification-channel.ts @@ -1,30 +1,62 @@ -import {desc, eq} from "drizzle-orm"; -import {db} from "@/db"; -import { - NotificationChannel, - notificationChannel, - organizationNotificationChannel -} from "@/db/schema/09_notification-channel"; -import {storageChannel} from "@/db/schema/12_storage-channel"; +"use server"; -export async function getOrganizationChannels(organizationId: string) { - return await db - .select({ - id: notificationChannel.id, - name: notificationChannel.name, - provider: notificationChannel.provider, - config: notificationChannel.config, - enabled: notificationChannel.enabled, - updatedAt: notificationChannel.updatedAt, - createdAt: notificationChannel.createdAt, - deletedAt: notificationChannel.deletedAt, - organizationId: notificationChannel.organizationId - }) - .from(organizationNotificationChannel) - .innerJoin( - notificationChannel, - eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id) - ) - .orderBy(desc(notificationChannel.createdAt)) - .where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[]; +import { desc, eq, isNull } from "drizzle-orm"; +import { db } from "@/db"; +import { + NotificationChannel, + notificationChannel, + organizationNotificationChannel, +} from "@/db/schema/09_notification-channel"; + +export async function getOrganizationChannels( + organizationId: string, +): Promise { + const [orgChannels, systemChannels] = await Promise.all([ + db + .select({ + id: notificationChannel.id, + name: notificationChannel.name, + provider: notificationChannel.provider, + config: notificationChannel.config, + enabled: notificationChannel.enabled, + updatedAt: notificationChannel.updatedAt, + createdAt: notificationChannel.createdAt, + deletedAt: notificationChannel.deletedAt, + organizationId: notificationChannel.organizationId, + }) + .from(organizationNotificationChannel) + .innerJoin( + notificationChannel, + eq( + organizationNotificationChannel.notificationChannelId, + notificationChannel.id, + ), + ) + .orderBy(desc(notificationChannel.createdAt)) + .where( + eq(organizationNotificationChannel.organizationId, organizationId), + ), + db + .select({ + id: notificationChannel.id, + name: notificationChannel.name, + provider: notificationChannel.provider, + config: notificationChannel.config, + enabled: notificationChannel.enabled, + updatedAt: notificationChannel.updatedAt, + createdAt: notificationChannel.createdAt, + deletedAt: notificationChannel.deletedAt, + organizationId: notificationChannel.organizationId, + }) + .from(notificationChannel) + .orderBy(desc(notificationChannel.createdAt)) + .where(isNull(notificationChannel.organizationId)), + ]); + + const seen = new Set(); + return [...orgChannels, ...systemChannels].filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) as NotificationChannel[]; } diff --git a/src/db/services/notification-log.ts b/src/db/services/notification-log.ts index 6366d4bd..de497e57 100644 --- a/src/db/services/notification-log.ts +++ b/src/db/services/notification-log.ts @@ -1,81 +1,91 @@ -import {and, desc, eq, gte, lte} from 'drizzle-orm'; -import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log"; -import {notificationChannel} from "@/db/schema/09_notification-channel"; -import {db} from "@/db"; -import {Json} from "drizzle-zod"; +"use server"; + +import { and, desc, eq, gte, lte } from "drizzle-orm"; +import { + NotificationLevel, + notificationLog, +} from "@/db/schema/11_notification-log"; +import { notificationChannel } from "@/db/schema/09_notification-channel"; +import { db } from "@/db"; +import { Json } from "drizzle-zod"; export type NotificationLogWithRelations = { - id: string; + id: string; + title: string; + level: NotificationLevel; + success: boolean; + error: string | null; + sentAt: Date; + payload: Json | null; + content: { title: string; - level: NotificationLevel; - success: boolean; - error: string | null; - sentAt: Date; - payload: Json | null; - content: { - title: string; - message: string; - }, - channel: { - name: string; - provider: string; - } | null; - policy: { - event: string | null; - } | null; + message: string; + }; + channel: { + name: string; + provider: string; + } | null; + policy: { + event: string | null; + } | null; }; -export async function getNotificationHistory( - filters?: { - channelId?: string; - policyId?: string; - organizationId?: string; - level?: NotificationLevel; - success?: boolean; - from?: Date; - to?: Date; - limit?: number; - } -): Promise { - const where = []; - if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId)); - if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId)); - if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId)); - if (filters?.level) where.push(eq(notificationLog.level, filters.level)); - if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success)); - if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from)); - if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to)); +export async function getNotificationHistory(filters?: { + channelId?: string; + policyId?: string; + organizationId?: string; + level?: NotificationLevel; + success?: boolean; + from?: Date; + to?: Date; + limit?: number; +}): Promise { + const where = []; + if (filters?.channelId) + where.push(eq(notificationLog.channelId, filters.channelId)); + if (filters?.policyId) + where.push(eq(notificationLog.policyId, filters.policyId)); + if (filters?.organizationId) + where.push(eq(notificationLog.organizationId, filters.organizationId)); + if (filters?.level) where.push(eq(notificationLog.level, filters.level)); + if (typeof filters?.success === "boolean") + where.push(eq(notificationLog.success, filters.success)); + if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from)); + if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to)); - const rows = await db - .select({ - id: notificationLog.id, - title: notificationLog.title, - level: notificationLog.level, - success: notificationLog.success, - error: notificationLog.error, - sentAt: notificationLog.sentAt, - payload: notificationLog.payload, - content: { - title: notificationLog.title, - message: notificationLog.message, - }, - channel: { - name: notificationLog.providerName, - provider: notificationLog.provider, - }, - policy: { - event: notificationLog.event, - }, - }) - .from(notificationLog) - .leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id)) - // .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id)) - .where(and(...where)) - .orderBy(desc(notificationLog.sentAt)) - .limit(filters?.limit || 100); + const rows = await db + .select({ + id: notificationLog.id, + title: notificationLog.title, + level: notificationLog.level, + success: notificationLog.success, + error: notificationLog.error, + sentAt: notificationLog.sentAt, + payload: notificationLog.payload, + content: { + title: notificationLog.title, + message: notificationLog.message, + }, + channel: { + name: notificationLog.providerName, + provider: notificationLog.provider, + }, + policy: { + event: notificationLog.event, + }, + }) + .from(notificationLog) + .leftJoin( + notificationChannel, + eq(notificationLog.channelId, notificationChannel.id), + ) + // .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id)) + .where(and(...where)) + .orderBy(desc(notificationLog.sentAt)) + .limit(filters?.limit || 100); - return rows.map(row => ({ - ...row, - payload: row.payload as Json, - })); + return rows.map((row) => ({ + ...row, + payload: row.payload as Json, + })); } diff --git a/src/db/services/organization.ts b/src/db/services/organization.ts index a892bde9..51ac0ddf 100644 --- a/src/db/services/organization.ts +++ b/src/db/services/organization.ts @@ -1,15 +1,17 @@ +"use server"; + import { db } from "@/db"; import { eq } from "drizzle-orm"; import { member } from "@/db/schema/04_member"; import { organization } from "@/db/schema/03_organization"; export async function getUserOrganization(userId: string) { - const memberRow = await db.query.member.findFirst({ - columns: { organizationId: true }, - where: eq(member.userId, userId), - }); - if (!memberRow) return null; - return db.query.organization.findFirst({ - where: eq(organization.id, memberRow.organizationId), - }); + const memberRow = await db.query.member.findFirst({ + columns: { organizationId: true }, + where: eq(member.userId, userId), + }); + if (!memberRow) return null; + return db.query.organization.findFirst({ + where: eq(organization.id, memberRow.organizationId), + }); } diff --git a/src/db/services/project.ts b/src/db/services/project.ts index b87c04e8..7270b1dc 100644 --- a/src/db/services/project.ts +++ b/src/db/services/project.ts @@ -1,71 +1,77 @@ -import {getOrganization} from "@/lib/auth/auth"; -import {db} from "@/db"; -import {and, eq} from "drizzle-orm"; -import * as drizzleDb from "@/db"; -import {project} from "@/db/schema/06_project"; +"use server"; +import { getOrganization } from "@/lib/auth/auth"; +import { db } from "@/db"; +import { and, eq } from "drizzle-orm"; +import * as drizzleDb from "@/db"; +import { project } from "@/db/schema/06_project"; export async function getOrganizationProject(organizationId: string) { - return db.query.project.findFirst({ - where: eq(project.organizationId, organizationId), - with: { - databases: true - } - }); + return db.query.project.findFirst({ + where: eq(project.organizationId, organizationId), + with: { + databases: true, + }, + }); } -export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: { - organizationSlug: string, projectId: string +export const getOrganizationProjectDatabases = async ({ + organizationSlug, + projectId, +}: { + organizationSlug: string; + projectId: string; }) => { - try { + try { + const organization = await getOrganization({}); - const organization = await getOrganization({}); - - if (!organization) { - return { - name: "ErrorGettingOrganizationProjectDatabases", - message: "No organization found.", - status: 400, - cause: "Unknown error occurred.", - }; - } - const databasesProject = await db.query.project.findFirst({ - where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)), - with: { - databases: true - } - }); - - if (!databasesProject) { - return { - name: "ErrorGettingOrganizationProjectDatabases", - message: "No organization found.", - status: 400, - cause: "Unknown error occurred.", - }; - } - - return { - data: databasesProject.databases, - ids: databasesProject.databases.map((project) => project.id) - } - - - } catch (e: any) { - const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error"; - const status = e?.response?.status || 500; - - console.error("API GettingOrganizationProjectDatabases error:", { - message: errorMessage, - status, - raw: e, - }); - - throw { - name: "ErrorGettingOrganizationProjectDatabases", - message: errorMessage, - status, - cause: e, - }; + if (!organization) { + return { + name: "ErrorGettingOrganizationProjectDatabases", + message: "No organization found.", + status: 400, + cause: "Unknown error occurred.", + }; } + const databasesProject = await db.query.project.findFirst({ + where: and( + eq(drizzleDb.schemas.project.organizationId, organization.id), + eq(drizzleDb.schemas.project.id, projectId), + ), + with: { + databases: true, + }, + }); + + if (!databasesProject) { + return { + name: "ErrorGettingOrganizationProjectDatabases", + message: "No organization found.", + status: 400, + cause: "Unknown error occurred.", + }; + } + + return { + data: databasesProject.databases, + ids: databasesProject.databases.map((project) => project.id), + }; + } catch (e: any) { + const errorMessage = + e?.response?.data?.message || e?.message || "Unknown auth error"; + const status = e?.response?.status || 500; + + console.error("API GettingOrganizationProjectDatabases error:", { + message: errorMessage, + status, + raw: e, + }); + + throw { + name: "ErrorGettingOrganizationProjectDatabases", + message: errorMessage, + status, + cause: e, + }; + } }; diff --git a/src/db/services/setting.ts b/src/db/services/setting.ts index a499db86..fb9f7b2c 100644 --- a/src/db/services/setting.ts +++ b/src/db/services/setting.ts @@ -1,5 +1,12 @@ +"use server"; + import { db } from "@/db"; export async function getSettings() { - return db.query.setting.findFirst(); + return db.query.setting.findFirst(); +} + +export async function isOnboardingDone(): Promise { + const settings = await db.query.setting.findFirst(); + return settings?.onboarding ?? false; } diff --git a/src/db/services/storage-channel.ts b/src/db/services/storage-channel.ts index 9cf6227a..03dc3aa6 100644 --- a/src/db/services/storage-channel.ts +++ b/src/db/services/storage-channel.ts @@ -1,25 +1,57 @@ -import {desc, eq} from "drizzle-orm"; -import {db} from "@/db"; -import {organizationStorageChannel, StorageChannel, storageChannel} from "@/db/schema/12_storage-channel"; +"use server"; -export async function getOrganizationStorageChannels(organizationId: string) { - return await db - .select({ - id: storageChannel.id, - name: storageChannel.name, - provider: storageChannel.provider, - organizationId: storageChannel.organizationId, - config: storageChannel.config, - enabled: storageChannel.enabled, - updatedAt: storageChannel.updatedAt, - createdAt: storageChannel.createdAt, - deletedAt: storageChannel.deletedAt, - }) - .from(organizationStorageChannel) - .innerJoin( - storageChannel, - eq(organizationStorageChannel.storageChannelId, storageChannel.id) - ) - .orderBy(desc(storageChannel.createdAt)) - .where(eq(organizationStorageChannel.organizationId, organizationId)) as unknown as StorageChannel[]; +import { desc, eq, isNull } from "drizzle-orm"; +import { db } from "@/db"; +import { + organizationStorageChannel, + StorageChannel, + storageChannel, +} from "@/db/schema/12_storage-channel"; + +export async function getOrganizationStorageChannels( + organizationId: string, +): Promise { + const [orgChannels, systemChannels] = await Promise.all([ + db + .select({ + id: storageChannel.id, + name: storageChannel.name, + provider: storageChannel.provider, + organizationId: storageChannel.organizationId, + config: storageChannel.config, + enabled: storageChannel.enabled, + updatedAt: storageChannel.updatedAt, + createdAt: storageChannel.createdAt, + deletedAt: storageChannel.deletedAt, + }) + .from(organizationStorageChannel) + .innerJoin( + storageChannel, + eq(organizationStorageChannel.storageChannelId, storageChannel.id), + ) + .orderBy(desc(storageChannel.createdAt)) + .where(eq(organizationStorageChannel.organizationId, organizationId)), + db + .select({ + id: storageChannel.id, + name: storageChannel.name, + provider: storageChannel.provider, + organizationId: storageChannel.organizationId, + config: storageChannel.config, + enabled: storageChannel.enabled, + updatedAt: storageChannel.updatedAt, + createdAt: storageChannel.createdAt, + deletedAt: storageChannel.deletedAt, + }) + .from(storageChannel) + .orderBy(desc(storageChannel.createdAt)) + .where(isNull(storageChannel.organizationId)), + ]); + + const seen = new Set(); + return [...orgChannels, ...systemChannels].filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }) as StorageChannel[]; } diff --git a/src/db/services/user.ts b/src/db/services/user.ts index df4a1ad0..676a780d 100644 --- a/src/db/services/user.ts +++ b/src/db/services/user.ts @@ -1,45 +1,49 @@ -import {SignUpUser} from "@/types/auth"; -import {hashPassword} from "better-auth/crypto"; -import {db} from "@/db"; -import * as drizzleDb from "@/db"; -import {User, UserThemeEnum} from "@/db/schema/02_user"; -import {assertValidPassword} from "@/utils/password"; +"use server"; +import { SignUpUser } from "@/types/auth"; +import { hashPassword } from "better-auth/crypto"; +import { db } from "@/db"; +import * as drizzleDb from "@/db"; +import { User, UserThemeEnum } from "@/db/schema/02_user"; +import { assertValidPassword } from "@/utils/password"; export async function hasUsers(): Promise { - const result = await db.select().from(drizzleDb.schemas.user).limit(1); - return result.length > 0; + const result = await db.select().from(drizzleDb.schemas.user).limit(1); + return result.length > 0; } export async function createUserDb(data: SignUpUser): Promise { - assertValidPassword(data.password); + assertValidPassword(data.password); - const now = new Date(); - const userId = crypto.randomUUID(); + const now = new Date(); + const userId = crypto.randomUUID(); - const [newUser] = await db.insert(drizzleDb.schemas.user).values({ - ...data, - id: userId, - name: data.name, - email: data.email, - emailVerified: true, - role: data.role, - createdAt: now, - updatedAt: now, - theme: data.theme as UserThemeEnum, - }).returning(); + const [newUser] = await db + .insert(drizzleDb.schemas.user) + .values({ + ...data, + id: userId, + name: data.name, + email: data.email, + emailVerified: true, + role: data.role, + createdAt: now, + updatedAt: now, + theme: data.theme as UserThemeEnum, + }) + .returning(); - if (data.password) { - const hashedPassword = await hashPassword(data.password); - await db.insert(drizzleDb.schemas.account).values({ - providerId: "credential", - accountId: userId, - userId: userId, - password: hashedPassword, - createdAt: now, - updatedAt: now, - }); - } + if (data.password) { + const hashedPassword = await hashPassword(data.password); + await db.insert(drizzleDb.schemas.account).values({ + providerId: "credential", + accountId: userId, + userId: userId, + password: hashedPassword, + createdAt: now, + updatedAt: now, + }); + } - return newUser + return newUser; } diff --git a/src/env.mjs b/src/env.mjs index 54fe41a1..4eacf2f6 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -88,6 +88,8 @@ export const env = createEnv({ ALLOWED_GROUP: z.string().optional(), + SKIP_ONBOARDING: z.string().optional().default("false"), + AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"), AUTH_SIGNUP_ENABLED: z.string().optional().default("true"), AUTH_PASSKEY_ENABLED: z.string().optional().default("false"), @@ -174,6 +176,8 @@ export const env = createEnv({ ALLOWED_GROUP: process.env.ALLOWED_GROUP, + SKIP_ONBOARDING: process.env.SKIP_ONBOARDING, + AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED, AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED, AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED, diff --git a/src/features/channel/channel-card.tsx b/src/features/channel/channel-card.tsx index 5ee1cb92..819bf904 100644 --- a/src/features/channel/channel-card.tsx +++ b/src/features/channel/channel-card.tsx @@ -34,6 +34,7 @@ export const ChannelCard = (props: ChannelCardProps) => { const isOwned = data.organizationId ? true : !organization; const isLocalSystem = data.provider == "local"; + const isSystemChannel = data.organizationId === null; return (
@@ -69,7 +70,7 @@ export const ChannelCard = (props: ChannelCardProps) => { channel={data} kind={kind} /> - {!isLocalSystem && ( + {!isLocalSystem && !isSystemChannel && ( void; - channels: NotificationChannel[] | StorageChannel[]; - database: DatabaseWith; - kind: ChannelKind + channels: ChannelEntry[]; + defaultPolicies: PolicyType[]; + kind: ChannelKind; + isBackupOnly?: boolean; + isPending?: boolean; + onSave: (policies: PolicyType[]) => Promise; + onCancel?: () => void; + noChannelsMessage?: ReactNode; }; - export const ChannelPoliciesForm = ({ - database, - channels, - onSuccess, - kind - }: ChannelPoliciesFormProps) => { - const queryClient = useQueryClient(); - const router = useRouter(); + channels, + defaultPolicies, + kind, + isBackupOnly = false, + isPending = false, + onSave, + onCancel, + noChannelsMessage, +}: ChannelPoliciesFormProps) => { const isMobile = useIsMobile(); const channelText = getChannelTextBasedOnKind(kind); - const isBackupOnly = backupOnly.some((type) => database.dbms === type); - - - const organizationChannels = channels.map(c => c.id); - - const filterByChannel = ( - items: T[] | undefined | null, - channelKey: K - ): T[] => items?.filter(item => organizationChannels.includes(item[channelKey] as string)) ?? []; - - 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 defaultPolicies: PolicyType[] = - kind === "notification" - ? formattedAlertPolicies - : formattedStoragePolicies.map(({ channelId, enabled }) => ({ channelId, enabled })); - const form = useZodForm({ schema: PoliciesSchema, defaultValues: { policies: defaultPolicies }, - context: { kind } + context: { kind }, }); - const {fields, append, remove} = useFieldArray({ control: form.control, name: "policies" }); + 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 addPolicy = () => append({ channelId: "", eventKinds: [], enabled: true }); - const mutation = useMutation({ - mutationFn: async ({policies}: PoliciesType) => { - const payload = policies.map(p => kind === "notification" ? p : { ...p, eventKinds: undefined }); - - 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 !== policy.eventKinds || existing.enabled !== policy.enabled); - }); - 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"); - - const failedActions = results - .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") - .map(r => r.value) - .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("Policies saved successfully"); - queryClient.invalidateQueries({queryKey: ["database-data", database.id]}); - router.refresh(); - }, - onError: (error: any) => { toast.error(error.message || "Failed to save policies"); }, - }); + const handleCancel = () => { + form.reset(); + onCancel?.(); + }; return ( -
{ - + { if (kind === "notification") { for (const policy of values.policies) { if (!policy.eventKinds || policy.eventKinds.length === 0) { @@ -152,10 +76,9 @@ export const ChannelPoliciesForm = ({ } } } - - await mutation.mutateAsync(values) - } - }> + await onSave(values.policies); + }} + >
@@ -175,38 +98,44 @@ export const ChannelPoliciesForm = ({ type="button" size="sm" className="h-8" - onClick={addPolicy}> - Add Policy + onClick={addPolicy} + > + Add Policy
{channels.length === 0 ? (
- +

No channels

-

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

+ {noChannelsMessage ?? ( +

+ No {channelText.toLowerCase()} channels configured. +

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

No policies

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

) : (
{fields.map((field, index) => ( - +
-
+
- + ( + render={({ field }) => (
{!isMobile && ( -
@@ -285,10 +236,14 @@ export const ChannelPoliciesForm = ({
-
@@ -297,23 +252,29 @@ export const ChannelPoliciesForm = ({ ( + render={({ field }) => ( - Trigger Events + + Trigger Events +
- +
)} /> @@ -327,9 +288,13 @@ export const ChannelPoliciesForm = ({
- Cancel - Save Changes + {onCancel && ( + + Cancel + + )} + Save Changes
); -}; \ No newline at end of file +}; diff --git a/src/features/database/channels-policy-modal.tsx b/src/features/database/channels-policy-modal.tsx index ee9ebb6b..9490f5a7 100644 --- a/src/features/database/channels-policy-modal.tsx +++ b/src/features/database/channels-policy-modal.tsx @@ -1,22 +1,36 @@ -"use client" -import {ReactNode, useState} from "react"; +"use client"; + +import { ReactNode, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, - DialogTrigger + DialogTrigger, } from "@/components/ui/dialog"; -import {Button} from "@/components/ui/button"; -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 "@/features/channel/channels-helpers"; -import {ChannelPoliciesForm} from "@/features/database/channels-policy-form"; - +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import Link from "next/link"; +import { DatabaseWith } from "@/db/schema/07_database"; +import { NotificationChannel } from "@/db/schema/09_notification-channel"; +import { StorageChannel } from "@/db/schema/12_storage-channel"; +import { ChannelKind, getChannelTextBasedOnKind } from "@/features/channel/channels-helpers"; +import { ChannelPoliciesForm } from "@/features/database/channels-policy-form"; +import { PolicyType } from "@/features/database/channels-policy.schema"; +import { + createAlertPoliciesAction, + createStoragePoliciesAction, + deleteAlertPoliciesAction, + deleteStoragePoliciesAction, + updateAlertPoliciesAction, + updateStoragePoliciesAction, +} from "@/features/database/channels-policy.action"; +import { backupOnly } from "@/features/database/database-tabs"; type ChannelPoliciesModalProps = { database: DatabaseWith; @@ -24,24 +38,93 @@ type ChannelPoliciesModalProps = { organizationId: string; kind: ChannelKind; icon: ReactNode; +}; -} - -export const ChannelPoliciesModal = ({icon, kind, database, channels, organizationId}: ChannelPoliciesModalProps) => { +export const ChannelPoliciesModal = ({ icon, kind, database, channels, organizationId }: ChannelPoliciesModalProps) => { const [open, setOpen] = useState(false); - const channelText = getChannelTextBasedOnKind(kind) + const queryClient = useQueryClient(); + const router = useRouter(); + const channelText = getChannelTextBasedOnKind(kind); + const channelsFiltered = channels.filter((c) => c.enabled); + const channelIds = channelsFiltered.map((c) => c.id); - const channelsFiltered = channels - .filter((channel) => channel.enabled) + const defaultPolicies: PolicyType[] = + kind === "notification" + ? (database.alertPolicies ?? []) + .filter((p) => channelIds.includes(p.notificationChannelId)) + .map(({ notificationChannelId, eventKinds, enabled }) => ({ + channelId: notificationChannelId, + eventKinds, + enabled, + })) + : (database.storagePolicies ?? []) + .filter((p) => channelIds.includes(p.storageChannelId)) + .map(({ storageChannelId, enabled }) => ({ channelId: storageChannelId, enabled })); - const channelsIds = channelsFiltered - .map(channel => channel.id); - const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId)); - const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId)); + const activePolicies = kind === "notification" + ? database.alertPolicies?.filter((p) => channelIds.includes(p.notificationChannelId)) + : database.storagePolicies?.filter((p) => channelIds.includes(p.storageChannelId)); + const mutation = useMutation({ + mutationFn: async (policies: PolicyType[]) => { + const payload = policies.map((p) => + kind === "notification" ? p : { ...p, eventKinds: undefined }, + ); - const activePolicies = kind === "notification" ? activeAlertPolicies : activeStoragePolicies; + const toAdd = payload.filter((p) => !defaultPolicies.some((d) => d.channelId === p.channelId)); + const toRemove = defaultPolicies.filter((d) => !payload.some((p) => p.channelId === d.channelId)); + const toUpdate = payload.filter((p) => { + const existing = defaultPolicies.find((d) => d.channelId === p.channelId); + return existing && (existing.eventKinds !== p.eventKinds || existing.enabled !== p.enabled); + }); + + const results = await Promise.allSettled( + kind === "notification" + ? [ + toAdd.length > 0 + ? createAlertPoliciesAction({ databaseId: database.id, alertPolicies: toAdd }) + : null, + toUpdate.length > 0 + ? updateAlertPoliciesAction({ databaseId: database.id, alertPolicies: toUpdate }) + : null, + toRemove.length > 0 + ? deleteAlertPoliciesAction({ databaseId: database.id, alertPolicies: toRemove }) + : null, + ] + : [ + toAdd.length > 0 + ? createStoragePoliciesAction({ databaseId: database.id, storagePolicies: toAdd }) + : null, + toUpdate.length > 0 + ? updateStoragePoliciesAction({ databaseId: database.id, storagePolicies: toUpdate }) + : null, + toRemove.length > 0 + ? deleteStoragePoliciesAction({ databaseId: database.id, storagePolicies: toRemove }) + : null, + ], + ); + + const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); + if (rejected) throw new Error(rejected.reason?.message || "Network or server error"); + + const failed = results + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") + .map((r) => r.value) + .filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false); + + if (failed.length > 0) throw new Error(failed[0].data.actionError?.message || "One or more operations failed"); + }, + onSuccess: () => { + toast.success("Policies saved successfully"); + queryClient.invalidateQueries({ queryKey: ["database-data", database.id] }); + router.refresh(); + setOpen(false); + }, + onError: (error: any) => { + toast.error(error.message || "Failed to save policies"); + }, + }); return ( @@ -49,9 +132,7 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati - ) -} \ No newline at end of file + ); +}; diff --git a/src/features/database/retention-policy-form.tsx b/src/features/database/retention-policy-form.tsx index d6c008eb..701ccab2 100644 --- a/src/features/database/retention-policy-form.tsx +++ b/src/features/database/retention-policy-form.tsx @@ -1,306 +1,294 @@ -"use client" +"use client"; + import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, - useZodForm + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + useZodForm, } from "@/components/ui/form"; -import {RetentionSettings, RetentionSettingsSchema} from "@/features/database/retention-policy.schema"; -import {useMutation, useQueryClient} from "@tanstack/react-query"; -import {useRouter} from "next/navigation"; -import {updateOrCreateBackupRetentionPolicyAction} from "@/features/database/retention-policy.action"; -import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database"; -import {toast} from "sonner"; -import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"; -import {Badge} from "@/components/ui/badge"; -import {Separator} from "@/components/ui/separator"; -import {Input} from "@/components/ui/input"; -import {Button} from "@/components/ui/button"; -import {Calendar, Save} from "lucide-react"; +import { + RetentionSettings, + RetentionSettingsSchema, +} from "@/features/database/retention-policy.schema"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Calendar, Save } from "lucide-react"; export type BackupRetentionSettingsFormProps = { - defaultValues?: RetentionPolicy; - database: DatabaseWith; + defaultValues?: RetentionSettings; + currentType?: string; + isPending?: boolean; + onSave: (values: RetentionSettings) => Promise; }; -export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => { - const queryClient = useQueryClient(); - const router = useRouter(); +export const BackupRetentionSettingsForm = ({ + defaultValues, + currentType, + isPending = false, + onSave, +}: BackupRetentionSettingsFormProps) => { + const form = useZodForm({ + schema: RetentionSettingsSchema, + defaultValues: defaultValues ?? { + count: 7, + days: 30, + gfs: { daily: 7, weekly: 4, monthly: 12, yearly: 3 }, + }, + }); - const defaultValuesFormatted: RetentionSettings = { - type: defaultValues?.type, - count: defaultValues?.count ?? 7, - days: defaultValues?.days ?? 30, - gfs: { - daily: defaultValues?.gfsDaily ?? 7, - weekly: defaultValues?.gfsWeekly ?? 4, - monthly: defaultValues?.gfsMonthly ?? 12, - yearly: defaultValues?.gfsYearly ?? 3, - }, - }; + const calculateTotalFiles = (values: RetentionSettings) => { + if (values.type === "gfs" && values.gfs) { + return ( + (values.gfs.daily ?? 0) + + (values.gfs.weekly ?? 0) + + (values.gfs.monthly ?? 0) + + (values.gfs.yearly ?? 0) + ); + } + return values.type === "count" + ? (values.count ?? 0) + : values.type === "days" + ? (values.days ?? 0) + : 0; + }; - const form = useZodForm({ - schema: RetentionSettingsSchema, - defaultValues: defaultValuesFormatted, - }); + const getStorageEstimate = (totalFiles: number) => { + if (totalFiles <= 10) return "Low"; + if (totalFiles <= 30) return "Medium"; + return "High"; + }; - const mutation = useMutation({ - mutationFn: async (payload: RetentionSettings) => - await updateOrCreateBackupRetentionPolicyAction({ - databaseId: database.id, - settings: payload, - }), - onSuccess: () => { - toast.success("Retention policy updated successfully."); - queryClient.invalidateQueries({queryKey: ["database-data", database.id]}); - router.refresh(); - }, - onError: () => { - toast.error("An error occurred while updating retention policy."); - }, - }); - - const calculateTotalFiles = (values: RetentionSettings) => { - if (values.type === "gfs" && values.gfs) { - return ( - (values.gfs.daily ?? 0) + - (values.gfs.weekly ?? 0) + - (values.gfs.monthly ?? 0) + - (values.gfs.yearly ?? 0) - ); - } - return values.type === "count" ? values.count ?? 0 : values.type === "days" ? values.days ?? 0 : 0; - }; - - const getStorageEstimate = (totalFiles: number) => { - if (totalFiles <= 10) return "Low"; - if (totalFiles <= 30) return "Medium"; - return "High"; - }; - - return ( -
-
-
{ - await mutation.mutateAsync(values); - }} - > - ( - - Retention Policy Type - - - {[ - { - id: "count", - label: "Keep last N backups", - desc: "Simple count-based retention (e.g., keep last 10 backups)", - }, - { - id: "days", - label: "Keep backups for X days", - desc: "Time-based retention (e.g., keep backups for 30 days)", - }, - { - id: "gfs", - label: "GFS Rotation", - desc: "Grandfather-Father-Son rotation for enterprise/critical systems", - badge: "Recommended", - }, - ].map((opt) => ( - - -
- - {opt.label} - {opt.badge && ( - - {opt.badge} - - )} - -

{opt.desc}

-
- - {database.retentionPolicy?.type === opt.id && ( - - Actual - - )} -
- ))} -
-
- -
- )} - /> - - {form.watch("type") && ( - - )} - - - {form.watch("type") === "count" && ( - ( - - Number of backups to keep - - field.onChange(e.target.valueAsNumber)} - /> - - - Older backups beyond this count will be automatically deleted. - - - + return ( +
+
+ { + await onSave(values); + }} + > + ( + + Retention Policy Type + + + {[ + { + id: "count", + label: "Keep last N backups", + desc: "Simple count-based retention (e.g., keep last 10 backups)", + }, + { + id: "days", + label: "Keep backups for X days", + desc: "Time-based retention (e.g., keep backups for 30 days)", + }, + { + id: "gfs", + label: "GFS Rotation", + desc: "Grandfather-Father-Son rotation for enterprise/critical systems", + badge: "Recommended", + }, + ].map((opt) => ( + + +
+ + {opt.label} + {opt.badge && ( + + {opt.badge} + )} - /> - )} - - {form.watch("type") === "days" && ( - ( - - Retention period (days) - - field.onChange(e.target.valueAsNumber)} - /> - - - Backups older than {field.value} days will be automatically deleted. - - - - )} - /> - )} - - {form.watch("type") === "gfs" && ( -
- {["daily", "weekly", "monthly", "yearly"].map((key) => ( - ( - - - {key.charAt(0).toUpperCase() + key.slice(1)} backups - - - field.onChange(e.target.valueAsNumber)} - /> - - - Keep N {key} backups - - - - )} - /> - ))} + +

+ {opt.desc} +

- )} + {currentType === opt.id && ( + + Actual + + )} + + ))} + + + + + )} + /> - {form.watch("type") && ( - <> - -
-
-
- - Storage Impact Summary -
- {(() => { - const totalFiles = calculateTotalFiles(form.getValues()); - const estimate = getStorageEstimate(totalFiles); - return ( - - {estimate} Usage - - ); - })()} -
-
-
- Estimated files per database: -

- {calculateTotalFiles(form.getValues())} backup files -

-
-
- Policy type: -

- {form.watch("type") === "gfs" - ? "GFS Rotation" - : form.watch("type") === "count" - ? "Count-based" - : "Time-based"} -

-
-
-
- - - )} - + {form.watch("type") && } + + {form.watch("type") === "count" && ( + ( + + Number of backups to keep + + field.onChange(e.target.valueAsNumber)} + /> + + + Older backups beyond this count will be automatically + deleted. + + + + )} + /> + )} + + {form.watch("type") === "days" && ( + ( + + Retention period (days) + + field.onChange(e.target.valueAsNumber)} + /> + + + Backups older than {field.value} days will be automatically + deleted. + + + + )} + /> + )} + + {form.watch("type") === "gfs" && ( +
+ {[ + { key: "daily" as const, label: "Daily", max: 31 }, + { key: "weekly" as const, label: "Weekly", max: 52 }, + { key: "monthly" as const, label: "Monthly", max: 120 }, + { key: "yearly" as const, label: "Yearly", max: 50 }, + ].map(({ key, label, max }) => ( + ( + + {label} backups + + + field.onChange(e.target.valueAsNumber) + } + /> + + Keep N {key} backups + + + )} + /> + ))}
-
- ); + )} + + {form.watch("type") && ( + <> + +
+
+
+ + Storage Impact Summary +
+ {(() => { + const totalFiles = calculateTotalFiles(form.getValues()); + const estimate = getStorageEstimate(totalFiles); + return ( + + {estimate} Usage + + ); + })()} +
+
+
+ + Estimated files per database: + +

+ {calculateTotalFiles(form.getValues())} backup files +

+
+
+ Policy type: +

+ {form.watch("type") === "gfs" + ? "GFS Rotation" + : form.watch("type") === "count" + ? "Count-based" + : "Time-based"} +

+
+
+
+ + + )} + +
+
+ ); }; diff --git a/src/features/database/retention-policy-sheet.tsx b/src/features/database/retention-policy-sheet.tsx index 5ee0dafb..e235cf30 100644 --- a/src/features/database/retention-policy-sheet.tsx +++ b/src/features/database/retention-policy-sheet.tsx @@ -1,16 +1,52 @@ -import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet"; -import {Button} from "@/components/ui/button"; -import {Database, Ruler} from "lucide-react"; -import {DatabaseWith as DbSchema, RetentionPolicy} from "@/db/schema/07_database"; -import { - BackupRetentionSettingsForm -} from "@/features/database/retention-policy-form"; +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Database, Ruler } from "lucide-react"; +import { DatabaseWith, RetentionPolicy } from "@/db/schema/07_database"; +import { BackupRetentionSettingsForm } from "@/features/database/retention-policy-form"; +import { RetentionSettings } from "@/features/database/retention-policy.schema"; +import { updateOrCreateBackupRetentionPolicyAction } from "@/features/database/retention-policy.action"; type RetentionPolicySheetProps = { - database: DbSchema -} + database: DatabaseWith; +}; + +const toRetentionSettings = (rp: RetentionPolicy | undefined | null): RetentionSettings | undefined => { + if (!rp) return undefined; + return { + type: rp.type, + count: rp.count ?? 7, + days: rp.days ?? 30, + gfs: { + daily: rp.gfsDaily ?? 7, + weekly: rp.gfsWeekly ?? 4, + monthly: rp.gfsMonthly ?? 12, + yearly: rp.gfsYearly ?? 3, + }, + }; +}; + +export const RetentionPolicySheet = ({ database }: RetentionPolicySheetProps) => { + const queryClient = useQueryClient(); + const router = useRouter(); + + const mutation = useMutation({ + mutationFn: async (payload: RetentionSettings) => + updateOrCreateBackupRetentionPolicyAction({ databaseId: database.id, settings: payload }), + onSuccess: () => { + toast.success("Retention policy updated successfully."); + queryClient.invalidateQueries({ queryKey: ["database-data", database.id] }); + router.refresh(); + }, + onError: () => { + toast.error("An error occurred while updating retention policy."); + }, + }); -export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => { return ( @@ -18,32 +54,32 @@ export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => { - + - + Backup Retention Policy Configure how long to keep your .dump backup files. Choose from simple count-based, time-based, - or - enterprise GFS rotation strategies. + or enterprise GFS rotation strategies. - {database.backupPolicy !== null ? - - : -
+ {database.backupPolicy !== null ? ( + { await mutation.mutateAsync(values); }} + /> + ) : ( +

- No backup policy configured yet. Please configure one ! + No backup policy configured yet. Please configure one!

- } + )} - ) -} \ No newline at end of file + ); +}; diff --git a/src/features/layout/logged-in-button.server.tsx b/src/features/layout/logged-in-button.server.tsx index 091c4f62..c72e5010 100644 --- a/src/features/layout/logged-in-button.server.tsx +++ b/src/features/layout/logged-in-button.server.tsx @@ -3,16 +3,20 @@ import { getAccounts, getSession, getSessions } from "@/lib/auth/auth"; import { LoggedInButtonClient } from "./logged-in-button"; import { SUPPORTED_PROVIDERS } from "@/lib/auth/config"; import { env } from "@/env.mjs"; +import { getSettings } from "@/db/services/setting"; +import { resolveAvatarUrl } from "@/utils/resolve-avatar-url"; export const LoggedInButton = async () => { - const user = await currentUser(); - const sessions = await getSessions(); - const currentSession = await getSession(); - const accounts = await getAccounts(); + const [user, sessions, currentSession, accounts, settings] = await Promise.all([ + currentUser(), + getSessions(), + getSession(), + getAccounts(), + getSettings(), + ]); if (!user) return null; - return ( { accounts={accounts} providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)} apiEnabled={env.API_ENABLED} + avatarMode={settings?.avatarMode ?? "internal"} + avatarUrl={resolveAvatarUrl(user, settings)} /> ); }; diff --git a/src/features/layout/logged-in-button.tsx b/src/features/layout/logged-in-button.tsx index 662a22a3..caeb8a87 100644 --- a/src/features/layout/logged-in-button.tsx +++ b/src/features/layout/logged-in-button.tsx @@ -7,6 +7,7 @@ import { LoggedInDropdown } from "./logged-in-dropdown"; import { Account, Session } from "better-auth"; import { AuthProviderConfig } from "@/lib/auth/config"; import {User} from "@/db/schema/02_user"; +import type { AvatarMode } from "@/features/onboarding/types"; type LoggedInButtonClientProps = { user: User; @@ -15,9 +16,11 @@ type LoggedInButtonClientProps = { accounts: Account[]; providers: AuthProviderConfig[]; apiEnabled: boolean; + avatarMode?: AvatarMode; + avatarUrl?: string; }; -export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers, apiEnabled }: LoggedInButtonClientProps) => { +export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers, apiEnabled, avatarMode, avatarUrl }: LoggedInButtonClientProps) => { return (
{(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()} - {user.image && } + {avatarUrl && }
{user.name} diff --git a/src/features/layout/logged-in-dropdown.tsx b/src/features/layout/logged-in-dropdown.tsx index 42012a26..2fc4b6b0 100644 --- a/src/features/layout/logged-in-dropdown.tsx +++ b/src/features/layout/logged-in-dropdown.tsx @@ -8,6 +8,7 @@ import { signOut } from "@/lib/auth/auth-client"; import { ProfileModal } from "@/features/layout/profile-modal"; import { Account, Session, User as UserType } from "@/db/schema/02_user"; import { AuthProviderConfig } from "@/lib/auth/config"; +import type { AvatarMode } from "@/features/onboarding/types"; export type LoggedInDropdownProps = PropsWithChildren<{ user: UserType; @@ -17,9 +18,11 @@ export type LoggedInDropdownProps = PropsWithChildren<{ children: ReactNode; providers: AuthProviderConfig[]; apiEnabled: boolean; + avatarMode?: AvatarMode; + avatarUrl?: string; }>; -export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers, apiEnabled }: LoggedInDropdownProps) => { +export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers, apiEnabled, avatarMode, avatarUrl }: LoggedInDropdownProps) => { const router = useRouter(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -35,6 +38,8 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi onOpenChange={setIsModalOpen} providers={providers} apiEnabled={apiEnabled} + avatarMode={avatarMode} + avatarUrl={avatarUrl} /> {children} diff --git a/src/features/layout/profile-modal.tsx b/src/features/layout/profile-modal.tsx index c1925a0e..90fb6e4b 100644 --- a/src/features/layout/profile-modal.tsx +++ b/src/features/layout/profile-modal.tsx @@ -6,6 +6,7 @@ import { ProfileSidebar } from "./profile-sidebar"; import type { AuthProviderConfig } from "@/lib/auth/config"; import { User, Session, Account } from "@/db/schema/02_user"; import { ProfileGeneral } from "@/features/profile/profile-general"; +import type { AvatarMode } from "@/features/onboarding/types"; import { ProfileSecurity } from "@/features/profile/profile-security"; import { ProfileProviders } from "@/features/profile/profile-providers"; import { ProfileAccount } from "@/features/profile/profile-account"; @@ -20,9 +21,11 @@ type ProfileModalProps = { onOpenChange: (open: boolean) => void; providers: AuthProviderConfig[]; apiEnabled: boolean; + avatarMode?: AvatarMode; + avatarUrl?: string; }; -export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers, apiEnabled }: ProfileModalProps) => { +export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers, apiEnabled, avatarMode, avatarUrl }: ProfileModalProps) => { return ( @@ -35,7 +38,7 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
- + diff --git a/src/features/onboarding/actions/apply-db-settings.action.ts b/src/features/onboarding/actions/apply-db-settings.action.ts index ab3fae02..1151a926 100644 --- a/src/features/onboarding/actions/apply-db-settings.action.ts +++ b/src/features/onboarding/actions/apply-db-settings.action.ts @@ -1,62 +1,13 @@ "use server"; -import { z } from "zod"; import { db } from "@/db"; import * as drizzleDb from "@/db"; import { eq } from "drizzle-orm"; import { userAction } from "@/lib/safe-actions/actions"; - -const RetentionSchema = z.object({ - type: z.enum(["count", "days", "gfs"]).optional(), - count: z.number().min(1).max(100), - days: z.number().min(1).max(3650), - gfs: z.object({ - daily: z.number().min(1).max(31), - weekly: z.number().min(0).max(52), - monthly: z.number().min(0).max(120), - yearly: z.number().min(0).max(50), - }), -}); - -const EventKindSchema = z.enum([ - "error_backup", - "error_restore", - "success_restore", - "success_backup", - "weekly_report", - "error_health_agent", - "error_health_database", -]); - -const NotifPolicySchema = z.object({ - channelId: z.string().min(1), - eventKinds: z.array(EventKindSchema), - enabled: z.boolean(), -}); - -const StoragePolicyInputSchema = z.object({ - channelId: z.string().min(1), - enabled: z.boolean(), -}); +import { ApplyDbSettingsSchema } from "@/features/onboarding/schemas/db-settings.schema"; export const applyOnboardingDbSettingsAction = userAction - .schema( - z.object({ - databaseId: z.string().min(1), - section: z.enum([ - "retention", - "scheduling", - "notifications", - "storage", - "all", - ]), - retention: RetentionSchema.optional(), - backupMethod: z.enum(["manual", "automatic"]).optional(), - backupCron: z.string().optional(), - notificationPolicies: z.array(NotifPolicySchema).optional(), - storagePolicies: z.array(StoragePolicyInputSchema).optional(), - }), - ) + .schema(ApplyDbSettingsSchema) .action(async ({ parsedInput }) => { const { databaseId, diff --git a/src/features/onboarding/components/backup-schedule-selector.tsx b/src/features/onboarding/components/backup-schedule-selector.tsx index 7747310b..3d76d222 100644 --- a/src/features/onboarding/components/backup-schedule-selector.tsx +++ b/src/features/onboarding/components/backup-schedule-selector.tsx @@ -1,17 +1,11 @@ "use client"; import { useState } from "react"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; -import { AdvancedCronSelect } from "@/features/database/cron-advanced-select"; +import { cn } from "@/lib/utils"; +import { isValidCronPart } from "@/utils/cron"; export type BackupScheduleValue = { method: "manual" | "automatic"; @@ -19,34 +13,38 @@ export type BackupScheduleValue = { }; const PRESETS = [ - { label: "Every hour", cron: "0 * * * *" }, - { label: "Every day", cron: "0 0 * * *" }, - { label: "Every week", cron: "0 0 * * 0" }, - { label: "Custom", cron: "custom" }, + { label: "Hourly", sub: "Every hour", cron: "0 * * * *" }, + { label: "Every 6h", sub: "4× per day", cron: "0 */6 * * *" }, + { label: "Every 12h", sub: "2× per day", cron: "0 */12 * * *"}, + { label: "Daily", sub: "Every day at midnight",cron: "0 0 * * *" }, + { label: "Weekly", sub: "Every Sunday", cron: "0 0 * * 0" }, + { label: "Monthly", sub: "1st of each month", cron: "0 0 1 * *" }, ] as const; type PresetCron = (typeof PRESETS)[number]["cron"]; -function detectPreset(cron: string | undefined): PresetCron { - const match = PRESETS.find((p) => p.cron !== "custom" && p.cron === cron); - return match ? match.cron : "custom"; +function isPresetCron(cron: string | undefined): cron is PresetCron { + return PRESETS.some((p) => p.cron === cron); } -type BackupScheduleSelectorProps = { +function validateCron(expr: string): boolean { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return false; + const types = ["minute", "hour", "day-of-month", "month", "day-of-week"] as const; + return parts.every((p, i) => isValidCronPart(types[i], p)); +} + +type Props = { value: BackupScheduleValue; onChange: (value: BackupScheduleValue) => void; }; -export const BackupScheduleSelector = ({ - value, - onChange, -}: BackupScheduleSelectorProps) => { - const [customCron, setCustomCron] = useState( - value.cron ?? "0 0 * * *", +export const BackupScheduleSelector = ({ value, onChange }: Props) => { + const isCustom = !!value.cron && !isPresetCron(value.cron); + const [customInput, setCustomInput] = useState( + isCustom ? (value.cron ?? "") : "", ); - - const selectedPreset = detectPreset(value.cron); - const isCustom = selectedPreset === "custom"; + const [customError, setCustomError] = useState(null); const handleMethodChange = (method: "manual" | "automatic") => { onChange({ @@ -55,180 +53,110 @@ export const BackupScheduleSelector = ({ }); }; - const handlePresetChange = (preset: PresetCron) => { - if (preset === "custom") { - onChange({ ...value, cron: customCron }); + const handlePresetClick = (cron: PresetCron) => { + setCustomError(null); + onChange({ ...value, cron }); + }; + + const handleCustomChange = (raw: string) => { + setCustomInput(raw); + if (validateCron(raw)) { + setCustomError(null); + onChange({ ...value, cron: raw.trim() }); } else { - onChange({ ...value, cron: preset }); + setCustomError("Invalid cron expression"); } }; - const handleCronPartChange = ( - type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week", - part: string, - ) => { - const indexMap: Record = { - minute: 0, - hour: 1, - "day-of-month": 2, - month: 3, - "day-of-week": 4, - }; - const parts = (customCron || "0 0 * * *").split(" "); - parts[indexMap[type]] = part; - const newCron = parts.join(" "); - setCustomCron(newCron); - onChange({ ...value, cron: newCron }); + const handleCustomFocus = () => { + if (!customInput && value.cron && isPresetCron(value.cron)) { + setCustomInput(value.cron); + } }; - const cronParts = (value.cron ?? customCron).split(" "); - return (
handleMethodChange(m as "manual" | "automatic")} - className="grid grid-cols-1 gap-3" + className="grid grid-cols-2 gap-3" > {( [ - { - id: "manual", - label: "Manual", - desc: "Backups triggered manually only", - }, - { - id: "automatic", - label: "Automatic", - desc: "Scheduled via cron expression", - }, + { id: "manual", label: "Manual", desc: "Trigger backups manually" }, + { id: "automatic", label: "Automatic", desc: "Scheduled via cron" }, ] as const ).map((opt) => (
); diff --git a/src/features/onboarding/components/db-settings/db-detail.tsx b/src/features/onboarding/components/db-settings/db-detail.tsx index 50ac8d0b..9cd43482 100644 --- a/src/features/onboarding/components/db-settings/db-detail.tsx +++ b/src/features/onboarding/components/db-settings/db-detail.tsx @@ -16,29 +16,37 @@ import type { SectionKind, } from "@/features/onboarding/types"; -const SECTIONS: { kind: SectionKind; label: string; icon: React.ReactNode }[] = - [ - { - kind: "retention", - label: "Retention Policy", - icon: , - }, - { - kind: "scheduling", - label: "Scheduling", - icon: , - }, - { - kind: "notifications", - label: "Notifications", - icon: , - }, - { - kind: "storage", - label: "Storage", - icon: , - }, - ]; +const SECTIONS: { + kind: SectionKind; + label: string; + description: string; + icon: React.ReactNode; +}[] = [ + { + kind: "retention", + label: "Retention Policy", + description: "Configure how long your data is retained", + icon: , + }, + { + kind: "scheduling", + label: "Scheduling", + description: "Set up backup schedules for your database", + icon: , + }, + { + kind: "notifications", + label: "Notifications", + description: "Get notified about backup status and issues", + icon: , + }, + { + kind: "storage", + label: "Storage", + description: "Choose where to store your backups", + icon: , + }, +]; type DbDetailProps = { db: OnboardingDatabase | undefined; @@ -80,8 +88,8 @@ export const DbDetail = ({
-
- {SECTIONS.map(({ kind, label, icon }) => ( +
+ {SECTIONS.map(({ kind, label, description, icon }) => (
- {label} +
+
+ {label} + + {description} + +
+
{isSectionConfigured(kind) && (
-
+
{databaseIds.map((dbId) => { const db = getDb(dbId); return ( diff --git a/src/features/onboarding/components/db-settings/db-section.tsx b/src/features/onboarding/components/db-settings/db-section.tsx index 3e65a58d..a56ea1f7 100644 --- a/src/features/onboarding/components/db-settings/db-section.tsx +++ b/src/features/onboarding/components/db-settings/db-section.tsx @@ -16,10 +16,10 @@ import type { import type { useApplyDbSettings } from "@/features/onboarding/hooks/use-apply-db-settings"; const SECTION_LABELS: Record = { - retention: "Retention Policy", - scheduling: "Scheduling", + retention: "Retention Policy", + scheduling: "Scheduling", notifications: "Notifications", - storage: "Storage", + storage: "Storage", }; type DbSectionProps = { @@ -32,7 +32,10 @@ type DbSectionProps = { storages: OnboardingChannel[]; onBack: () => void; onSaved: () => void; - updateDbSettings: (dbId: string, patch: Partial) => Promise; + updateDbSettings: ( + dbId: string, + patch: Partial, + ) => Promise; }; export const DbSection = ({ @@ -51,7 +54,9 @@ export const DbSection = ({

{SECTION_LABELS[section]}{" "} - — {db?.name ?? dbId} + + — {db?.name ?? dbId} +

-
- ); - } - - return ( -
-
- - -
- - {policies.length === 0 ? ( -
-

- Click "Add Policy" to start receiving notifications. -

-
- ) : ( -
- {policies.map((policy, index) => { - const available = notifiers.filter( - (n) => - n.id === policy.channelId || !selectedChannelIds.includes(n.id), - ); - const selected = notifiers.find((n) => n.id === policy.channelId); - - return ( - -
-
- - -
- -
- -
- - - updatePolicy(index, { enabled: v }) - } - className="scale-75 origin-right" - /> -
-
- - -
- -
- - - updatePolicy(index, { eventKinds: v as EventKind[] }) - } - defaultValue={policy.eventKinds} - placeholder="Select events…" - variant="inverted" - animation={0} - className="bg-background/50 w-full" - /> -
-
- ); - })} -
- )} - -
- - -
-
- ); -}; +export const NotificationsSection = ({ initial, notifiers, onSave, isPending }: NotificationsSectionProps) => ( + + onSave( + policies.map((p) => ({ + channelId: p.channelId, + eventKinds: (p.eventKinds ?? []) as EventKind[], + enabled: p.enabled, + })), + ) + } + noChannelsMessage={ +

+ Go back and configure notifiers in the "Connect a notifier" step first. +

+ } + /> +); diff --git a/src/features/onboarding/components/db-settings/retention-section.tsx b/src/features/onboarding/components/db-settings/retention-section.tsx index 90ddc0eb..e9079bdb 100644 --- a/src/features/onboarding/components/db-settings/retention-section.tsx +++ b/src/features/onboarding/components/db-settings/retention-section.tsx @@ -1,232 +1,31 @@ "use client"; -import { useState } from "react"; -import { ArrowLeft } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { BackupRetentionSettingsForm } from "@/features/database/retention-policy-form"; import { DEFAULT_RETENTION } from "@/features/onboarding/constants/db-settings"; import type { OnboardingDbSettings } from "@/features/onboarding/types"; type RetentionSectionProps = { - initial: OnboardingDbSettings["retention"]; - onSave: ( - value: NonNullable, - ) => Promise; - onBack: () => void; - isPending: boolean; + initial: OnboardingDbSettings["retention"]; + onSave: (value: NonNullable) => Promise; + isPending: boolean; }; -export const RetentionSection = ({ - initial, - onSave, - onBack, - isPending, -}: RetentionSectionProps) => { - const [settings, setSettings] = useState< - NonNullable - >(initial ?? DEFAULT_RETENTION); - - const totalFiles = () => { - if (settings.type === "gfs") { - return ( - settings.gfs.daily + - settings.gfs.weekly + - settings.gfs.monthly + - settings.gfs.yearly - ); - } - return settings.type === "count" ? settings.count : settings.days; - }; - - const storageEstimate = () => { - const t = totalFiles(); - if (t <= 10) return "Low"; - if (t <= 30) return "Medium"; - return "High"; - }; - - return ( -
-
- - - setSettings((prev) => ({ - ...prev, - type: v as "count" | "days" | "gfs", - })) - } - className="grid grid-cols-1 gap-4" - > - {[ - { - id: "count", - label: "Keep last N backups", - desc: "Simple count-based retention (e.g., keep last 10 backups)", - }, - { - id: "days", - label: "Keep backups for X days", - desc: "Time-based retention (e.g., keep backups for 30 days)", - }, - { - id: "gfs", - label: "GFS Rotation", - desc: "Grandfather-Father-Son rotation for enterprise/critical systems", - badge: "Recommended", - }, - ].map((opt) => ( - - ))} - -
- - {settings.type && } - - {settings.type === "count" && ( -
- - - setSettings((prev) => ({ - ...prev, - count: parseInt(e.target.value) || 1, - })) - } - /> -

- Older backups beyond this count will be automatically deleted. -

-
- )} - - {settings.type === "days" && ( -
- - - setSettings((prev) => ({ - ...prev, - days: parseInt(e.target.value) || 1, - })) - } - /> -

- Backups older than {settings.days} days will be automatically - deleted. -

-
- )} - - {settings.type === "gfs" && ( -
- {( - [ - { key: "daily", label: "Daily backups", min: 1, max: 31 }, - { key: "weekly", label: "Weekly backups", min: 0, max: 52 }, - { key: "monthly", label: "Monthly backups", min: 0, max: 120 }, - { key: "yearly", label: "Yearly backups", min: 0, max: 50 }, - ] as const - ).map(({ key, label, min, max }) => ( -
- - - setSettings((prev) => ({ - ...prev, - gfs: { ...prev.gfs, [key]: parseInt(e.target.value) || 0 }, - })) - } - /> -

- Keep N {key} backups -

-
- ))} -
- )} - - {settings.type && ( - <> - -
-
- Storage Impact - - {storageEstimate()} Usage - -
-

- ~{totalFiles()} backup files per database -

-
- - )} - -
- - -
-
- ); -}; +export const RetentionSection = ({ initial, onSave, isPending }: RetentionSectionProps) => ( + + onSave({ + type: values.type, + count: values.count ?? DEFAULT_RETENTION.count, + days: values.days ?? DEFAULT_RETENTION.days, + gfs: { + daily: values.gfs?.daily ?? DEFAULT_RETENTION.gfs.daily, + weekly: values.gfs?.weekly ?? DEFAULT_RETENTION.gfs.weekly, + monthly: values.gfs?.monthly ?? DEFAULT_RETENTION.gfs.monthly, + yearly: values.gfs?.yearly ?? DEFAULT_RETENTION.gfs.yearly, + }, + }) + } + /> +); diff --git a/src/features/onboarding/components/db-settings/scheduling-section.tsx b/src/features/onboarding/components/db-settings/scheduling-section.tsx index be40701d..2833774e 100644 --- a/src/features/onboarding/components/db-settings/scheduling-section.tsx +++ b/src/features/onboarding/components/db-settings/scheduling-section.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { BackupScheduleSelector, @@ -13,14 +12,12 @@ import type { OnboardingDbSettings } from "@/features/onboarding/types"; type SchedulingSectionProps = { initial: Pick; onSave: (method: "manual" | "automatic", cron?: string) => Promise; - onBack: () => void; isPending: boolean; }; export const SchedulingSection = ({ initial, onSave, - onBack, isPending, }: SchedulingSectionProps) => { const [schedule, setSchedule] = useState({ @@ -32,10 +29,6 @@ export const SchedulingSection = ({
- -
- ); - } - - return ( -
-
- - -
- - {policies.length === 0 ? ( -
-

- Click "Add Policy" to assign a storage to this database. -

-
- ) : ( -
- {policies.map((policy, index) => { - const available = storages.filter( - (s) => - s.id === policy.channelId || !selectedChannelIds.includes(s.id), - ); - const selected = storages.find((s) => s.id === policy.channelId); - - return ( - -
- - -
- -
- -
- - - updatePolicy(index, { enabled: v }) - } - className="scale-75 origin-right" - /> -
-
- - -
- ); - })} -
- )} - -
- - -
-
- ); -}; +export const StorageSection = ({ initial, storages, onSave, isPending }: StorageSectionProps) => ( + + onSave( + policies.map((p) => ({ + channelId: p.channelId, + enabled: p.enabled, + })), + ) + } + noChannelsMessage={ +

+ Go back and configure storages in the "Connect a storage" step first. +

+ } + /> +); diff --git a/src/features/onboarding/hooks/use-add-storage.ts b/src/features/onboarding/hooks/use-add-storage.ts index 54b271ea..bfde5e21 100644 --- a/src/features/onboarding/hooks/use-add-storage.ts +++ b/src/features/onboarding/hooks/use-add-storage.ts @@ -40,6 +40,7 @@ export const useAddStorage = () => { label, name, config, + organizationId: orgId ?? null, }; const storages = [ ...((state?.context.flowData.storages ?? []) as OnboardingChannel[]), diff --git a/src/features/onboarding/hooks/use-create-org.ts b/src/features/onboarding/hooks/use-create-org.ts index f97d8c06..2cbbe6a9 100644 --- a/src/features/onboarding/hooks/use-create-org.ts +++ b/src/features/onboarding/hooks/use-create-org.ts @@ -5,6 +5,7 @@ import { useOnboarding } from "@onboardjs/react"; import { toast } from "sonner"; import { createOrganizationAction, + getMyOrganizationAction, updateOrganizationAction, } from "@/features/organizations/organization.action"; import { slugify } from "@/utils/slugify"; @@ -16,7 +17,18 @@ export const useCreateOrg = () => { mutationFn: async (name: string) => { const trimmed = name.trim(); if (!trimmed) throw new Error("Organisation name is required"); - const existingOrg = state?.context.flowData.org; + + let existingOrg = state?.context.flowData.org as + | { id: string; name: string } + | undefined; + + if (!existingOrg) { + const fetchResult = await getMyOrganizationAction({}); + const fetchData = fetchResult?.data; + if (fetchData?.success && fetchData.value) { + existingOrg = { id: fetchData.value.id, name: fetchData.value.name }; + } + } if (existingOrg) { const result = await updateOrganizationAction({ @@ -25,23 +37,34 @@ export const useCreateOrg = () => { }); const updateData = result?.data; if (!updateData?.success) { - throw new Error(updateData?.actionError?.message ?? "Failed to update organisation"); + throw new Error( + updateData?.actionError?.message ?? "Failed to update organisation", + ); } await updateContext({ - flowData: { ...state?.context.flowData, org: { id: existingOrg.id, name: trimmed } }, + flowData: { + ...state?.context.flowData, + org: { id: existingOrg.id, name: trimmed }, + }, }); } else { const result = await createOrganizationAction({ name: trimmed }); const createData = result?.data; if (!createData?.success) { - throw new Error(createData?.actionError?.message ?? "Failed to create organisation"); + throw new Error( + createData?.actionError?.message ?? "Failed to create organisation", + ); } const org = createData.value; if (!org) throw new Error("Failed to create organisation"); await updateContext({ - flowData: { ...state?.context.flowData, org: { id: org.id, name: org.name } }, + flowData: { + ...state?.context.flowData, + org: { id: org.id, name: org.name }, + }, }); } + await next(); }, onError: (err: Error) => toast.error(err.message), diff --git a/src/features/onboarding/hooks/use-create-project.ts b/src/features/onboarding/hooks/use-create-project.ts index 49fecf71..aba21906 100644 --- a/src/features/onboarding/hooks/use-create-project.ts +++ b/src/features/onboarding/hooks/use-create-project.ts @@ -9,13 +9,13 @@ import { } from "@/features/projects/projects.action"; import type { OnboardingProjectData } from "@/features/onboarding/types"; -type ProjectInput = { name: string; description: string; databaseIds: string[] }; +type ProjectInput = { name: string; databaseIds: string[] }; export const useCreateProject = () => { const { state, updateContext } = useOnboarding(); return useMutation({ - mutationFn: async ({ name, description, databaseIds }: ProjectInput) => { + mutationFn: async ({ name, databaseIds }: ProjectInput) => { const trimmed = name.trim(); if (!trimmed) throw new Error("Project name is required"); const orgId = (state?.context.flowData.org as any)?.id as string | undefined; @@ -35,7 +35,7 @@ export const useCreateProject = () => { await updateContext({ flowData: { ...state?.context.flowData, - project: { id: existingProject.id, name: trimmed, description, databaseIds }, + project: { id: existingProject.id, name: trimmed, databaseIds }, }, }); } else { @@ -52,7 +52,7 @@ export const useCreateProject = () => { await updateContext({ flowData: { ...state?.context.flowData, - project: { id: project.id, name: project.name, description, databaseIds }, + project: { id: project.id, name: project.name, databaseIds }, }, }); } diff --git a/src/features/onboarding/hooks/use-remove-notifier.ts b/src/features/onboarding/hooks/use-remove-notifier.ts index 836a9bbf..76b22621 100644 --- a/src/features/onboarding/hooks/use-remove-notifier.ts +++ b/src/features/onboarding/hooks/use-remove-notifier.ts @@ -1,4 +1,3 @@ -// src/features/onboarding/hooks/use-remove-notifier.ts "use client"; import { useMutation } from "@tanstack/react-query"; @@ -12,16 +11,21 @@ export const useRemoveNotifier = () => { return useMutation({ mutationFn: async (id: string) => { - const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + const orgId = (state?.context.flowData.org as any)?.id as + | string + | undefined; const result = await removeNotificationChannelAction({ organizationId: orgId, notificationChannelId: id, }); - if (result?.data?.success === false) throw new Error("Failed to remove channel"); + if (result?.data?.success === false) + throw new Error("Failed to remove channel"); const notifiers = ( (state?.context.flowData.notifiers ?? []) as OnboardingChannel[] ).filter((c) => c.id !== id); - await updateContext({ flowData: { ...state?.context.flowData, notifiers } }); + await updateContext({ + flowData: { ...state?.context.flowData, notifiers }, + }); }, onError: (err: Error) => toast.error(err.message), }); diff --git a/src/features/onboarding/hooks/use-remove-storage.ts b/src/features/onboarding/hooks/use-remove-storage.ts index b017e631..5261bfa1 100644 --- a/src/features/onboarding/hooks/use-remove-storage.ts +++ b/src/features/onboarding/hooks/use-remove-storage.ts @@ -1,4 +1,3 @@ -// src/features/onboarding/hooks/use-remove-storage.ts "use client"; import { useMutation } from "@tanstack/react-query"; @@ -12,13 +11,21 @@ export const useRemoveStorage = () => { return useMutation({ mutationFn: async (id: string) => { - const orgId = (state?.context.flowData.org as any)?.id as string | undefined; - const result = await removeStorageChannelAction({ organizationId: orgId, id }); - if (result?.data?.success === false) throw new Error("Failed to remove storage"); + const orgId = (state?.context.flowData.org as any)?.id as + | string + | undefined; + const result = await removeStorageChannelAction({ + organizationId: orgId, + id, + }); + if (result?.data?.success === false) + throw new Error("Failed to remove storage"); const storages = ( (state?.context.flowData.storages ?? []) as OnboardingChannel[] ).filter((c) => c.id !== id); - await updateContext({ flowData: { ...state?.context.flowData, storages } }); + await updateContext({ + flowData: { ...state?.context.flowData, storages }, + }); }, onError: (err: Error) => toast.error(err.message), }); diff --git a/src/features/onboarding/onboarding-shell.tsx b/src/features/onboarding/onboarding-shell.tsx index 21d41a3d..d59cf9f7 100644 --- a/src/features/onboarding/onboarding-shell.tsx +++ b/src/features/onboarding/onboarding-shell.tsx @@ -28,7 +28,7 @@ export const OnboardingShell = () => { const isGoingBack = currentIndex < latestIndex; - const BLOCKED_STEPS = ["login", "account-info", "security"]; + const BLOCKED_STEPS = ["security"]; const prevStepId = STEP_ORDER[currentIndex - 1] ?? ""; const canGoBack = !BLOCKED_STEPS.includes(currentStepId) && @@ -38,7 +38,7 @@ export const OnboardingShell = () => { return (
-
+
{renderStep()}
@@ -60,11 +60,18 @@ export const OnboardingShell = () => { prevId = "storage"; } } else if (currentStepId === "finish") { - const agents = (state.context.flowData.agents as any[]) || []; - const isAgentConnected = agents.some((a) => a.connected); - const databaseIds = (state.context.flowData.project as any)?.databaseIds || []; - if (!isAgentConnected || databaseIds.length === 0) { - prevId = "project-create"; + const agents = + (state.context.flowData.agents as any[]) || []; + if (agents.length === 0) { + prevId = "agent-create"; + } else { + const isAgentConnected = agents.some((a) => a.connected); + const databaseIds = + (state.context.flowData.project as any)?.databaseIds || + []; + if (!isAgentConnected || databaseIds.length === 0) { + prevId = "project-create"; + } } } if (prevId) goToStep(prevId); diff --git a/src/features/onboarding/onboarding-state.ts b/src/features/onboarding/onboarding-state.ts index d6baeecb..245b9fea 100644 --- a/src/features/onboarding/onboarding-state.ts +++ b/src/features/onboarding/onboarding-state.ts @@ -1,11 +1,16 @@ import "server-only"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { organization } from "@/db/schema/03_organization"; +import { member } from "@/db/schema/04_member"; import { currentUser } from "@/lib/auth/current-user"; import { getSettings } from "@/db/services/setting"; import { hasUsers } from "@/db/services/user"; import { getUserOrganization } from "@/db/services/organization"; import { getOrganizationProject } from "@/db/services/project"; import { getOrganizationAgents } from "@/db/services/agent"; +import { getDatabasesSettings } from "@/db/services/database"; import { getOrganizationChannels } from "@/db/services/notification-channel"; import { getOrganizationStorageChannels } from "@/db/services/storage-channel"; import type { AgentWith } from "@/db/schema/08_agent"; @@ -51,10 +56,22 @@ export async function resolveOnboardingState(): Promise return { stepId: "login", flowData: { meta } }; } - const org = await getUserOrganization(user.id); + let org = await getUserOrganization(user.id); if (!org) { - meta.resumeStepId = "preferences"; - return { stepId: "preferences", flowData: { meta } }; + const defaultOrg = await db.query.organization.findFirst({ + where: eq(organization.slug, "default"), + }); + if (defaultOrg) { + await db.insert(member).values({ + userId: user.id, + organizationId: defaultOrg.id, + role: "owner", + }); + org = defaultOrg; + } else { + meta.resumeStepId = "preferences"; + return { stepId: "preferences", flowData: { meta } }; + } } const orgData = { id: org.id, name: org.name }; @@ -73,6 +90,7 @@ export async function resolveOnboardingState(): Promise label: n.provider, name: n.name, config: (n.config as Record) ?? {}, + organizationId: n.organizationId ?? null, })); const storages = storageChannels.map((s) => ({ @@ -81,11 +99,14 @@ export async function resolveOnboardingState(): Promise label: s.provider, name: s.name, config: (s.config as Record) ?? {}, + organizationId: s.organizationId ?? null, })); const defaults = { notifierId: settings?.defaultNotificationChannelId ?? undefined, storageId: settings?.defaultStorageChannelId ?? undefined, + avatarMode: settings?.avatarMode ?? "internal", + dicebearStyle: settings?.dicebearStyle ?? "thumbs", }; const agentData = await Promise.all( @@ -94,7 +115,7 @@ export async function resolveOnboardingState(): Promise name: a.name, edgeKey: await generateEdgeKey(getServerUrl(), a.id), connected: !!a.lastContact, - })) + })), ); const databases = (agents as AgentWith[]).flatMap((a) => @@ -107,6 +128,8 @@ export async function resolveOnboardingState(): Promise })), ); + const dbSettings = await getDatabasesSettings(databases.map((d) => d.id)); + const fullData: Partial = { meta, org: orgData, @@ -115,13 +138,15 @@ export async function resolveOnboardingState(): Promise defaults, agents: agentData, databases, + dbSettings, ...(project ? { project: { id: project.id, name: project.name, description: "", - databaseIds: (project as any).databases?.map((db: any) => db.id) ?? [], + databaseIds: + (project as any).databases?.map((db: any) => db.id) ?? [], }, } : {}), @@ -129,10 +154,8 @@ export async function resolveOnboardingState(): Promise const hasAgents = agents && agents.length > 0; - // Has project → late stage (project was created after agent-key) if (project) { if (!hasAgents) { - // Project without agents: missed earlier steps if (notifiers.length === 0) { meta.resumeStepId = "notifier"; return { stepId: "notifier", flowData: fullData }; @@ -151,11 +174,16 @@ export async function resolveOnboardingState(): Promise return { stepId: "agent-key", flowData: fullData }; } - meta.resumeStepId = "finish"; - return { stepId: "finish", flowData: fullData }; + const projectDatabaseIds: string[] = + (project as any).databases?.map((db: any) => db.id) ?? []; + if (projectDatabaseIds.length > 0) { + meta.resumeStepId = "db-settings"; + return { stepId: "db-settings", flowData: fullData }; + } + meta.resumeStepId = "project-create"; + return { stepId: "project-create", flowData: fullData }; } - // Has agents but no project → past notifier/storage, waiting on project if (hasAgents) { const agentHasPinged = !!agents[0]?.lastContact; const stepId = agentHasPinged ? "project-create" : "agent-key"; @@ -163,7 +191,6 @@ export async function resolveOnboardingState(): Promise return { stepId, flowData: fullData }; } - // No agents, no project → check earlier steps in order if (notifiers.length === 0) { meta.resumeStepId = "notifier"; return { stepId: "notifier", flowData: fullData }; diff --git a/src/features/onboarding/onboarding-steps.tsx b/src/features/onboarding/onboarding-steps.tsx index 4e2d1b47..92b3a47c 100644 --- a/src/features/onboarding/onboarding-steps.tsx +++ b/src/features/onboarding/onboarding-steps.tsx @@ -120,14 +120,14 @@ export const onboardingSteps: OnboardingStep[] = [ isSkippable: true, skipToStep: (ctx: any) => { const agents = (ctx.flowData?.agents as any[]) || []; - if (agents.length === 0) return "agent-create"; + if (agents.length === 0) return "finish"; const isAgentConnected = agents.some((a) => a.connected); const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || []; return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings"; }, nextStep: (ctx: any) => { const agents = (ctx.flowData?.agents as any[]) || []; - if (agents.length === 0) return "agent-create"; + if (agents.length === 0) return "finish"; const isAgentConnected = agents.some((a) => a.connected); const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || []; return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings"; diff --git a/src/features/onboarding/steps/step-agent-key.tsx b/src/features/onboarding/steps/step-agent-key.tsx index 34cdcedd..ba1b9490 100644 --- a/src/features/onboarding/steps/step-agent-key.tsx +++ b/src/features/onboarding/steps/step-agent-key.tsx @@ -1,17 +1,22 @@ "use client"; +import { useState } from "react"; import { useOnboarding } from "@onboardjs/react"; -import { Loader2 } from "lucide-react"; +import { Server } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { CodeSnippet } from "@/components/common/code-snippet"; +import { AgentCardKey } from "@/features/agents/agent-card-key"; import type { OnboardingAgent } from "@/features/onboarding/types"; +import { cn } from "@/lib/utils"; export const StepAgentKey = () => { const { next, state } = useOnboarding(); const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; + const [selectedId, setSelectedId] = useState(agents[0]?.id ?? ""); + + const selected = agents.find((a) => a.id === selectedId) ?? agents[0]; return ( -
+

Connect your agent

@@ -19,33 +24,40 @@ export const StepAgentKey = () => { agent to connect.

- {agents.map((agent) => ( - - ))} + + {agents.length > 1 && ( +
+

+ Agents +

+
+ {agents.map((agent) => ( + + ))} +
+
+ )} + + {selected?.edgeKey && ( + + )} +
); }; - -const AgentKeyBlock = ({ agent }: { agent: OnboardingAgent }) => { - if (!agent.edgeKey) { - return ( -
- - Generating key for {agent.name}… -
- ); - } - - const command = `portabase agent "${agent.name}" --key ${agent.edgeKey}`; - - return ( -
-

{agent.name}

- - -
- ); -}; diff --git a/src/features/onboarding/steps/step-agent-waiting.tsx b/src/features/onboarding/steps/step-agent-waiting.tsx index d742ff5c..9a0e6e8e 100644 --- a/src/features/onboarding/steps/step-agent-waiting.tsx +++ b/src/features/onboarding/steps/step-agent-waiting.tsx @@ -1,21 +1,23 @@ "use client"; import { useEffect } from "react"; +import { useRouter } from "next/navigation"; import { useOnboarding } from "@onboardjs/react"; import { Loader2 } from "lucide-react"; import { useAgentStatus } from "@/features/onboarding/hooks/use-agent-status"; import type { OnboardingAgent } from "@/features/onboarding/types"; export const StepAgentWaiting = () => { - const { next, state } = useOnboarding(); + const { state } = useOnboarding(); + const router = useRouter(); const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; const { data, isLoading } = useAgentStatus(); useEffect(() => { if (data?.connected) { - next(); + router.refresh(); } - }, [data?.connected, next]); + }, [data?.connected, router]); if (isLoading || data?.connected) return null; diff --git a/src/features/onboarding/steps/step-db-settings.tsx b/src/features/onboarding/steps/step-db-settings.tsx index 4a668a21..df616609 100644 --- a/src/features/onboarding/steps/step-db-settings.tsx +++ b/src/features/onboarding/steps/step-db-settings.tsx @@ -52,11 +52,11 @@ export const StepDbSettings = () => { case "retention": return !!s.retention; case "scheduling": - return s.backupMethod !== undefined; + return s.backupMethod === "automatic"; case "notifications": - return s.notificationPolicies !== undefined; + return (s.notificationPolicies?.length ?? 0) > 0; case "storage": - return s.storagePolicies !== undefined; + return (s.storagePolicies?.length ?? 0) > 0; } }; @@ -131,7 +131,7 @@ export const StepDbSettings = () => { getDb={getDb} isDbConfigured={isDbConfigured} onSelectDb={(dbId) => setPhase({ kind: "db", dbId })} - onContinue={next} + onContinue={() => next()} /> ); diff --git a/src/features/onboarding/steps/step-defaults.tsx b/src/features/onboarding/steps/step-defaults.tsx index 5ef9879f..4be58bb1 100644 --- a/src/features/onboarding/steps/step-defaults.tsx +++ b/src/features/onboarding/steps/step-defaults.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { HardDrive } from "lucide-react"; import { useOnboarding } from "@onboardjs/react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -12,12 +11,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { +import type { + AvatarMode, OnboardingChannel, OnboardingDefaultsData, } from "@/features/onboarding/types"; +import { getChannelIcon } from "@/features/channel/channels-helpers"; import { updateNotificationSettingsAction } from "@/features/settings/notification.action"; import { updateStorageSettingsAction } from "@/features/settings/storage.action"; +import { updateAvatarModeAction } from "@/features/settings/avatar.action"; +import { AvatarModeSelector } from "@/features/settings/avatar-mode-selector"; +import { DicebearStylePicker } from "@/features/settings/dicebear-style-picker"; export const StepDefaults = () => { const { next, updateContext, state } = useOnboarding(); @@ -27,12 +31,19 @@ export const StepDefaults = () => { []) as OnboardingChannel[]; const existingDefaults = (state?.context.flowData.defaults ?? {}) as OnboardingDefaultsData; + const [notifierId, setNotifierId] = useState( existingDefaults.notifierId || undefined, ); const [storageId, setStorageId] = useState( existingDefaults.storageId || undefined, ); + const [avatarMode, setAvatarMode] = useState( + existingDefaults.avatarMode ?? "internal", + ); + const [dicebearStyle, setDicebearStyle] = useState( + existingDefaults.dicebearStyle ?? "thumbs", + ); const selectNotifier = async (value: string) => { setNotifierId(value); @@ -43,22 +54,12 @@ export const StepDefaults = () => { await updateContext({ flowData: { ...state?.context.flowData, - defaults: { notifierId: value, storageId }, + defaults: { notifierId: value, storageId, avatarMode }, }, }); }; const selectStorage = async (value: string) => { - if (value === "filesystem") { - setStorageId(undefined); - await updateContext({ - flowData: { - ...state?.context.flowData, - defaults: { notifierId, storageId: undefined }, - }, - }); - return; - } setStorageId(value); await updateStorageSettingsAction({ name: "system", @@ -67,7 +68,37 @@ export const StepDefaults = () => { await updateContext({ flowData: { ...state?.context.flowData, - defaults: { notifierId, storageId: value }, + defaults: { notifierId, storageId: value, avatarMode }, + }, + }); + }; + + const selectAvatarMode = async (mode: AvatarMode) => { + setAvatarMode(mode); + await updateAvatarModeAction({ + name: "system", + avatarMode: mode, + dicebearStyle, + }); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId, storageId, avatarMode: mode, dicebearStyle }, + }, + }); + }; + + const selectDicebearStyle = async (style: string) => { + setDicebearStyle(style); + await updateAvatarModeAction({ + name: "system", + avatarMode: "dicebear", + dicebearStyle: style, + }); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId, storageId, avatarMode, dicebearStyle: style }, }, }); }; @@ -76,26 +107,28 @@ export const StepDefaults = () => { await updateContext({ flowData: { ...state?.context.flowData, - defaults: { notifierId, storageId }, + defaults: { notifierId, storageId, avatarMode, dicebearStyle }, }, }); await next(); }; + const selectedNotifier = notifiers.find((n) => n.id === notifierId); + const selectedStorage = storages.find((s) => s.id === storageId); + return (

Set your defaults

- Optional — choose the default notifier and storage for new agents. + Optional — choose the default notifier, storage and avatar mode.

+
+
+ + + + {avatarMode === "dicebear" && ( + + )} + diff --git a/src/features/onboarding/steps/step-finish.tsx b/src/features/onboarding/steps/step-finish.tsx index e238c1d5..45e1d5ae 100644 --- a/src/features/onboarding/steps/step-finish.tsx +++ b/src/features/onboarding/steps/step-finish.tsx @@ -1,16 +1,17 @@ "use client"; -import { useEffect, useRef } from "react"; -import { useOnboarding } from "@onboardjs/react"; +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import confetti from "canvas-confetti"; import { CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useMarkOnboardingDone } from "@/features/onboarding/hooks/use-mark-onboarding-done"; export const StepFinish = () => { - const { next } = useOnboarding(); + const router = useRouter(); const fired = useRef(false); const mutation = useMarkOnboardingDone(); + const [isRedirecting, setIsRedirecting] = useState(false); useEffect(() => { if (fired.current) return; @@ -27,10 +28,11 @@ export const StepFinish = () => {

-
- -