Merge pull request #360 from Portabase/fix/remove-database-link-to-agent

fix: remove-database-link-to-agent
This commit is contained in:
Charles GTE
2026-07-05 18:32:58 +02:00
committed by GitHub
25 changed files with 3321 additions and 48 deletions
@@ -81,7 +81,7 @@ export default async function RoutePage(
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey} />
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={true} />
</PageContent>
</Page>
);
@@ -94,7 +94,7 @@ export default async function RoutePage(
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={!!agent.organizationId}/>
</PageContent>
</Page>
);
@@ -77,10 +77,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
})
.returning();
if (databaseCreated) {
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
+10 -6
View File
@@ -1,6 +1,4 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import {useState} from "react";
import {Card} from "@/components/ui/card";
@@ -16,16 +14,17 @@ export type DatabaseCardProps = {
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (databaseId: string) => void;
deleteButton?: React.ReactNode;
};
export const DatabaseCard = (props: DatabaseCardProps) => {
const {data: database, withDetails = true, selectable = false, selected = false, onToggleSelect} = props;
const {data: database, withDetails = true, selectable = false, selected = false, onToggleSelect, deleteButton} = props;
const [isCopied, setIsCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(database.agentDatabaseId);
navigator.clipboard.writeText(database.agentDatabaseId ?? "");
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
@@ -107,7 +106,12 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
</div>
</div>
{withDetails && (
{deleteButton ? (
<div className="mt-4 flex items-center justify-end pt-3 border-t border-border/50"
onClick={(e) => e.preventDefault()}>
{deleteButton}
</div>
) : withDetails ? (
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
<span
className="text-[10px] font-black uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">Open</span>
@@ -118,7 +122,7 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
</div>
</div>
)}
) : null}
</Card>
);
};
@@ -0,0 +1,2 @@
ALTER TABLE "databases" ALTER COLUMN "agent_database_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "databases" ALTER COLUMN "agent_id" DROP NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1782937885439,
"tag": "0069_worried_alex_wilder",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1783264084846,
"tag": "0070_illegal_joseph",
"breakpoints": true
}
]
}
+1 -2
View File
@@ -13,7 +13,7 @@ import {JobLog, jobLog} from "@/db/schema/17_job-log";
export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(),
agentDatabaseId: uuid("agent_database_id").notNull().defaultRandom(),
agentDatabaseId: uuid("agent_database_id").defaultRandom(),
name: text("name").notNull(),
dbms: dbmsEnum("dbms").notNull(),
description: text("description"),
@@ -22,7 +22,6 @@ export const database = pgTable("databases", {
backupToRestore: text("backup_to_restore"),
healthErrorCount: integer("health_error_count"),
agentId: uuid("agent_id")
.notNull()
.references(() => agent.id, {onDelete: "cascade"}),
lastContact: timestamp("last_contact"),
projectId: uuid("project_id")
+7 -4
View File
@@ -16,10 +16,13 @@ export async function getOrganizationAvailableDatabases(
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),
where: (db, { eq, or, and, isNull }) =>
and(
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
isNull(db.deletedAt),
),
with: {
agent: {
with: {
+5 -2
View File
@@ -2,7 +2,7 @@
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
import { and, eq, gte, isNotNull, isNull, lt } from "drizzle-orm";
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
import { EventPayload } from "@/features/notifications/types";
import { logger } from "@/lib/logger";
@@ -113,7 +113,10 @@ export async function checkAgentsHealthError() {
export async function checkDatabasesHealthError() {
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
where: and(
isNotNull(drizzleDb.schemas.database.lastContact),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
agent: true,
alertPolicies: true,
+7 -3
View File
@@ -2,7 +2,7 @@
import { getOrganization } from "@/lib/auth/auth";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { project } from "@/db/schema/06_project";
@@ -10,7 +10,9 @@ export async function getOrganizationProject(organizationId: string) {
return db.query.project.findFirst({
where: eq(project.organizationId, organizationId),
with: {
databases: true,
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
},
});
}
@@ -39,7 +41,9 @@ export const getOrganizationProjectDatabases = async ({
eq(drizzleDb.schemas.project.id, projectId),
),
with: {
databases: true,
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
},
});
@@ -3,7 +3,7 @@ import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {and, eq, inArray, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {AgentWith} from "@/db/schema/08_agent";
import {withUpdatedAt} from "@/db/utils";
@@ -25,7 +25,9 @@ export const updateAgentOrganizationsAction = userAction
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
organizations: true,
databases: true
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
}
}) as AgentWith;
@@ -70,7 +72,7 @@ export const updateAgentOrganizationsAction = userAction
if (projectIds.length > 0) {
const databases = await db.query.database.findMany({
where: (db, { inArray }) => inArray(db.projectId, projectIds),
where: (db, { inArray, and, isNull }) => and(inArray(db.projectId, projectIds), isNull(db.deletedAt)),
columns: { id: true }
});
+4 -2
View File
@@ -2,7 +2,7 @@
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/schemas/agents.schema";
import {z} from "zod";
import {eq, and, ne, count, desc} from "drizzle-orm";
import {eq, and, ne, count, desc, isNull} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {slugify} from "@/utils/slugify";
@@ -87,7 +87,9 @@ export const getAgentAction = userAction.schema(z.string()).action(async ({parse
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, parsedInput),
with: {
databases: true
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
}
});
@@ -23,9 +23,10 @@ import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
type AgentContentPageProps = {
edgeKey: string;
agent: AgentWithDatabases
canDeleteDatabases?: boolean
}
export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
export const AgentContentPage = ({edgeKey, agent: initialAgent, canDeleteDatabases = false}: AgentContentPageProps) => {
const {data} = useQuery({
queryKey: ["agent-data", initialAgent.id],
@@ -121,6 +122,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
cardItem={AgentDatabaseCard}
canDeleteDatabases={canDeleteDatabases}
/>
</div>
)}
@@ -1,14 +1,29 @@
"use client";
import {Database} from "@/db/schema/07_database";
import {DatabaseCard} from "@/components/common/database-card";
import { Database } from "@/db/schema/07_database";
import { DatabaseCard } from "@/components/common/database-card";
import { DatabaseDeleteButton } from "@/features/agents/components/database-delete-button";
export type agentDatabaseCardProps = {
export type AgentDatabaseCardProps = {
data: Database;
canDeleteDatabases?: boolean;
};
export const AgentDatabaseCard = (props: agentDatabaseCardProps) => {
const {data: database} = props;
export const AgentDatabaseCard = (props: AgentDatabaseCardProps) => {
const { data: database, canDeleteDatabases = false } = props;
return <DatabaseCard withDetails={false} data={database}/>;
};
return (
<DatabaseCard
withDetails={false}
data={database}
deleteButton={
canDeleteDatabases && database.agentId ? (
<DatabaseDeleteButton
databaseId={database.id}
agentId={database.agentId}
/>
) : undefined
}
/>
);
};
@@ -0,0 +1,61 @@
"use client";
import {Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {toast} from "sonner";
import {deleteDatabaseAction} from "@/features/database/actions/database-delete.action";
export type DatabaseDeleteButtonProps = {
databaseId: string;
agentId: string;
};
export const DatabaseDeleteButton = (props: DatabaseDeleteButtonProps) => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => deleteDatabaseAction({databaseId: props.databaseId}),
onSuccess: async (result: any) => {
if (result.data?.success) {
toast.success(result.data.actionSuccess.message);
await queryClient.invalidateQueries({queryKey: ["agent-data", props.agentId]});
} else {
toast.error(result.data?.actionError?.message || "Unknown error occurred.");
}
},
onError: () => {
toast.error("Failed to delete database.");
},
});
return (
<ButtonWithConfirm
title="Delete database"
description="Are you sure you want to delete this database? It will be unlinked from this agent."
button={{
main: {
text: "",
variant: "outline",
size: "sm",
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: () => {
mutation.mutate();
},
},
cancel: {
className: "w-full",
text: "Cancel",
variant: "outline",
},
}}
isPending={mutation.isPending}
/>
);
};
@@ -2,7 +2,7 @@
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {and, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
@@ -19,7 +19,10 @@ export const getDatabaseDataAction = userAction
const {databaseId} = parsedInput;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
where: and(
eq(drizzleDb.schemas.database.id, databaseId),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
project: true,
retentionPolicy: true,
@@ -17,7 +17,7 @@ export async function assertDatabasesInOrgProject(projectId: string, databaseIds
eq(drizzleDb.schemas.project.id, projectId),
eq(drizzleDb.schemas.project.organizationId, organization.id),
),
with: {databases: true},
with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}},
});
if (!project) throw new ActionError("Project not found.");
@@ -40,7 +40,7 @@ export type RestorePreviewRow = {
async function resolveLatestRestorable(projectId: string, databaseIds: string[]): Promise<RestorePreviewRow[]> {
const project = await db.query.project.findFirst({
where: eq(drizzleDb.schemas.project.id, projectId),
with: {databases: true},
with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}},
});
const nameById = new Map((project?.databases ?? []).map((d) => [d.id, d.name] as const));
@@ -0,0 +1,174 @@
"use server";
import {z} from "zod";
import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Database} from "@/db/schema/07_database";
import {ServerActionResult} from "@/types/action-type";
import {userAction} from "@/lib/safe-actions/actions";
import {zString} from "@/lib/zod";
import {withUpdatedAt} from "@/db/utils";
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
type DeleteDatabaseInput = {
databaseId: string;
};
class DatabaseNotFoundError extends Error {
constructor(databaseId: string) {
super(`Database not found or update failed: ${databaseId}`);
this.name = "DatabaseNotFoundError";
}
}
class UnauthorizedError extends Error {
constructor(databaseId: string) {
super(`Not authorized to delete this database: ${databaseId}`);
this.name = "UnauthorizedError";
}
}
async function assertCanDeleteDatabase(databaseId: string): Promise<void> {
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
agent: true,
},
});
if (!database || !database.agent) {
throw new DatabaseNotFoundError(databaseId);
}
const user = await currentUser();
if (!user) {
throw new UnauthorizedError(databaseId);
}
const agent = database.agent;
const isAdmin = user.role === "superadmin" || user.role === "admin";
let authorized: boolean;
if (agent.organizationId === null) {
authorized = isAdmin;
} else {
const organization = await getOrganization({});
const activeMember = await getActiveMember();
const canManage = activeMember
? computeOrganizationPermissions(activeMember).canManageAgents
: false;
const hasAccess =
!!organization && agent.organizationId === organization.id;
authorized = canManage && hasAccess;
}
if (!authorized) {
throw new UnauthorizedError(databaseId);
}
}
export async function deleteDatabaseService(input: DeleteDatabaseInput): Promise<Database> {
const {databaseId} = input;
await assertCanDeleteDatabase(databaseId);
await db
.delete(drizzleDb.schemas.retentionPolicy)
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
.execute();
await db
.delete(drizzleDb.schemas.alertPolicy)
.where(eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId))
.execute();
await db
.delete(drizzleDb.schemas.storagePolicy)
.where(eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId))
.execute();
const [updatedDatabase] = await db
.update(drizzleDb.schemas.database)
.set(
withUpdatedAt({
agentId: null,
agentDatabaseId: null,
projectId: null,
backupPolicy: null,
deletedAt: new Date(),
})
)
.where(eq(drizzleDb.schemas.database.id, databaseId))
.returning();
if (!updatedDatabase) {
throw new DatabaseNotFoundError(databaseId);
}
return updatedDatabase;
}
export const deleteDatabaseAction = userAction
.schema(
z.object({
databaseId: zString(),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Database>> => {
try {
const deletedDatabase = await deleteDatabaseService(parsedInput);
return {
success: true,
value: deletedDatabase,
actionSuccess: {
message: "Database has been successfully deleted.",
messageParams: {
databaseId: parsedInput.databaseId,
},
},
};
} catch (error) {
if (error instanceof UnauthorizedError) {
return {
success: false,
actionError: {
message: "Not authorized to delete this database.",
status: 403,
messageParams: {
databaseId: parsedInput.databaseId,
},
},
};
}
if (error instanceof DatabaseNotFoundError) {
return {
success: false,
actionError: {
message: "Database not found or update failed",
status: 404,
messageParams: {
databaseId: parsedInput.databaseId,
},
},
};
}
return {
success: false,
actionError: {
message: "Failed to delete database.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {
databaseId: parsedInput.databaseId,
},
},
};
}
});
@@ -5,7 +5,7 @@ import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Backup} from "@/db/schema/07_database";
import {v4 as uuidv4} from "uuid";
import {eq} from "drizzle-orm";
import {and, eq, isNull} from "drizzle-orm";
import {z} from "zod";
import {storeBackupFiles} from "@/features/storages/utils/storages.helpers";
import {getFileExtension} from "@/utils/common";
@@ -19,7 +19,10 @@ export const uploadBackupAction = userAction
const databaseId = formData.get("databaseId") as string;
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
where: and(
eq(drizzleDb.schemas.database.id, databaseId),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
project: true,
alertPolicies: true,
@@ -4,7 +4,7 @@ import {ServerActionResult} from "@/types/action-type";
import {z} from "zod";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq, inArray} from "drizzle-orm";
import {and, eq, inArray, isNull} from "drizzle-orm";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {v4 as uuidv4} from "uuid";
import {getTodayISODate} from "@/utils/date-formatting";
@@ -24,7 +24,10 @@ export const migrationAction = userAction.schema(
try {
const targetDatabase = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, targetDatabaseId),
where: and(
eq(drizzleDb.schemas.database.id, targetDatabaseId),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {
project: true,
retentionPolicy: true,
@@ -5,7 +5,7 @@ import {ProjectSchema} from "@/features/projects/schemas/projects.schema";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {and, eq, inArray, isNull} from "drizzle-orm";
import {Project} from "@/db/schema/06_project";
import * as drizzleDb from "@/db";
import {Database} from "@/db/schema/07_database";
@@ -88,7 +88,9 @@ export const updateProjectAction = userAction
const existing = await db.query.project.findFirst({
where: eq(drizzleDb.schemas.project.id, parsedInput.projectId),
with: {
databases: true,
databases: {
where: isNull(drizzleDb.schemas.database.deletedAt),
},
},
});
+5 -2
View File
@@ -1,14 +1,17 @@
import {ApiKeyContextUser} from "@/lib/api-v1/types";
import {getAccessibleDatabaseIds} from "@/lib/api-v1/services/databases";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {and, eq, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
export async function resolveDatabaseAccess(id: string, user: ApiKeyContextUser) {
const accessibleIds = await getAccessibleDatabaseIds(user);
if (accessibleIds.includes(id)) return "ok";
const exists = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, id),
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt),
),
columns: { id: true },
});
return exists ? "forbidden" : "not_found";
+4 -1
View File
@@ -44,7 +44,10 @@ export const backupCleanTask = async () => {
try {
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, backup.databaseId),
where: and(
eq(drizzleDb.schemas.database.id, backup.databaseId),
isNull(drizzleDb.schemas.database.deletedAt),
),
with: {alertPolicies: true},
});
if (database) {
+1
View File
@@ -12,6 +12,7 @@ const log = logger.child({ module: "tasks/database" });
export const retentionCleanTask = async () => {
try {
const databases = await db.query.database.findMany({
where: isNull(drizzleDb.schemas.database.deletedAt),
with: {
retentionPolicy: true,
backups: {