mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
feat: org agent management (#259)
* feat: org agent management * feat: org agent management * fix: adding migrations for legacy and refactoring. * fix: refactoring * fix: delete agent * fix: refactoring --------- Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
co-authored by
charles-gauthereau
parent
a620d7a9f7
commit
e38519aec2
@@ -10,18 +10,30 @@ import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||
|
||||
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
|
||||
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
|
||||
|
||||
const [countResult] = await db.select({count: count()}).from(drizzleDb.schemas.agent).where(conditions);
|
||||
|
||||
if (countResult.count > 0) {
|
||||
throw new ActionError("Slug already exists");
|
||||
}
|
||||
};
|
||||
|
||||
export const createAgentAction = userAction.schema(AgentSchema).action(async ({parsedInput}) => {
|
||||
const slug = slugify(parsedInput.name);
|
||||
export const createAgentAction = userAction.schema(
|
||||
z.object({
|
||||
organizationId: z.string().optional(),
|
||||
data: AgentSchema,
|
||||
})
|
||||
).action(async ({parsedInput}) => {
|
||||
const slug = slugify(parsedInput.data.name);
|
||||
await verifySlugUniqueness(slug);
|
||||
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput, slug: slug}).returning();
|
||||
|
||||
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput.data, slug: slug, organizationId: parsedInput.organizationId}).returning();
|
||||
|
||||
if (createdAgent && parsedInput.organizationId){
|
||||
await db.insert(drizzleDb.schemas.organizationAgent).values({
|
||||
organizationId: parsedInput.organizationId,
|
||||
agentId: createdAgent.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: createdAgent,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
export const AgentSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
description: z.string(),
|
||||
|
||||
});
|
||||
|
||||
export type AgentType = z.infer<typeof AgentSchema>;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use server"
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {db} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {AgentWith} from "@/db/schema/08_agent";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
|
||||
export const updateAgentOrganizationsAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
data: z.array(z.string()),
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput , ctx}): Promise<ServerActionResult<null>> => {
|
||||
try {
|
||||
const organizationsIds = parsedInput.data;
|
||||
const agentId = parsedInput.id;
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
organizations: true,
|
||||
databases: true
|
||||
}
|
||||
}) as AgentWith;
|
||||
|
||||
|
||||
if (!agent) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Agent not found.",
|
||||
status: 404,
|
||||
cause: "not_found",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const existingItemIds = agent.organizations.map((organization) => organization.organizationId);
|
||||
|
||||
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
|
||||
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
|
||||
|
||||
if (organizationsToAdd.length > 0) {
|
||||
for (const organizationToAdd of organizationsToAdd) {
|
||||
await db.insert(drizzleDb.schemas.organizationAgent).values({
|
||||
organizationId: organizationToAdd,
|
||||
agentId: agentId
|
||||
});
|
||||
}
|
||||
}
|
||||
if (organizationsToRemove.length > 0) {
|
||||
await db.delete(drizzleDb.schemas.organizationAgent).where(and(inArray(drizzleDb.schemas.organizationAgent.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationAgent.agentId,agentId))).execute();
|
||||
|
||||
const organizationsToRemoveDetails = await db.query.organization.findMany({
|
||||
where: inArray(drizzleDb.schemas.organization.id, organizationsToRemove),
|
||||
with: {
|
||||
projects: true
|
||||
}
|
||||
});
|
||||
|
||||
const projectIds = organizationsToRemoveDetails.flatMap(org =>
|
||||
org.projects.map(project => project.id)
|
||||
);
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
const databases = await db.query.database.findMany({
|
||||
where: (db, { inArray }) => inArray(db.projectId, projectIds),
|
||||
columns: { id: true }
|
||||
});
|
||||
|
||||
const databaseIds = databases.map(d => d.id);
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set(withUpdatedAt({
|
||||
backupPolicy: null,
|
||||
projectId: null
|
||||
}))
|
||||
.where(inArray(drizzleDb.schemas.database.projectId, projectIds))
|
||||
.execute();
|
||||
|
||||
await db.delete(drizzleDb.schemas.retentionPolicy)
|
||||
.where(inArray(drizzleDb.schemas.retentionPolicy.databaseId, databaseIds))
|
||||
.execute();
|
||||
|
||||
await db.delete(drizzleDb.schemas.alertPolicy)
|
||||
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databaseIds))
|
||||
.execute();
|
||||
|
||||
await db.delete(drizzleDb.schemas.storagePolicy)
|
||||
.where(inArray(drizzleDb.schemas.storagePolicy.databaseId, databaseIds))
|
||||
.execute();
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: null,
|
||||
actionSuccess: {
|
||||
message: "Agent organizations has been successfully updated.",
|
||||
messageParams: {agentId: agentId},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating agent organizations:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update agent organizations.",
|
||||
status: 500,
|
||||
cause: "server_error",
|
||||
messageParams: {message: "Error updating the agent organizations"},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/ui/form";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
|
||||
import {toast} from "sonner";
|
||||
import {AgentWith} from "@/db/schema/08_agent";
|
||||
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/components/agent-organizations.schema";
|
||||
import {updateAgentOrganizationsAction} from "@/features/agents/components/agent-organizations.action";
|
||||
|
||||
|
||||
type AgentOrganisationFormProps = {
|
||||
organizations?: OrganizationWithMembers[];
|
||||
defaultValues?: AgentWith
|
||||
};
|
||||
|
||||
export const AgentOrganisationForm = ({
|
||||
organizations,
|
||||
defaultValues,
|
||||
}: AgentOrganisationFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
|
||||
|
||||
|
||||
const form = useZodForm({
|
||||
schema: AgentOrganizationSchema,
|
||||
// @ts-ignore
|
||||
defaultValues: {
|
||||
organizations: defaultOrganizationIds
|
||||
},
|
||||
});
|
||||
|
||||
const formatOrganizationsList = (organizations: OrganizationWithMembers[]) => {
|
||||
return organizations
|
||||
.map((organization) => ({
|
||||
value: organization.id,
|
||||
label: `${organization.name}`,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: AgentOrganizationType) => {
|
||||
|
||||
const payload = {
|
||||
data: values.organizations,
|
||||
id: defaultValues?.id ?? ""
|
||||
};
|
||||
|
||||
const result = await updateAgentOrganizationsAction(payload)
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`organizations`}
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<MultiSelect
|
||||
options={formatOrganizationsList(organizations ?? [])}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value ?? []}
|
||||
placeholder="Select organization(s)"
|
||||
variant="inverted"
|
||||
animation={0}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<ButtonWithLoading isPending={mutation.isPending}>
|
||||
Save
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const AgentOrganizationSchema = z.object({
|
||||
organizations: z.array(z.string().uuid())
|
||||
});
|
||||
|
||||
export type AgentOrganizationType = z.infer<typeof AgentOrganizationSchema>;
|
||||
@@ -10,19 +10,24 @@ import {
|
||||
import {AgentForm} from "@/features/agents/components/agent.form";
|
||||
import {Button, buttonVariants} from "@/components/ui/button";
|
||||
import {Plus} from "lucide-react";
|
||||
import {AgentType} from "@/features/agents/agents.schema";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {useState} from "react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form";
|
||||
import {AgentWith} from "@/db/schema/08_agent";
|
||||
|
||||
type AgentDialogProps = {
|
||||
agent?: AgentType & { id: string };
|
||||
agent?: AgentWith;
|
||||
typeTrigger: "edit" | "empty" | "create";
|
||||
organization?: OrganizationWithMembers;
|
||||
adminView?: boolean,
|
||||
organizations?: OrganizationWithMembers[];
|
||||
};
|
||||
|
||||
|
||||
export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
|
||||
export const AgentDialog = ({agent, typeTrigger, organization, adminView, organizations}: AgentDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isEdit = !!agent;
|
||||
const router = useRouter();
|
||||
@@ -36,7 +41,7 @@ export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
|
||||
</div>
|
||||
);
|
||||
case "empty":
|
||||
return <EmptyStatePlaceholder text="Create new Agent"/>;
|
||||
return <EmptyStatePlaceholder className="h-full" text="Create new Agent"/>;
|
||||
case "create":
|
||||
return <Button><Plus className="mr-2 h-4 w-4"/> Create Agent</Button>;
|
||||
default:
|
||||
@@ -53,14 +58,45 @@ export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? `Edit ${agent.name}` : "Create new agent"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AgentForm
|
||||
onSuccess={() => {
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
}}
|
||||
defaultValues={agent}
|
||||
agentId={agent?.id}
|
||||
/>
|
||||
<>
|
||||
{adminView ?
|
||||
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="configuration">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="organizations">Organizations</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-full justify-between" value="configuration">
|
||||
<AgentForm
|
||||
organization={organization}
|
||||
onSuccess={() => {
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
}}
|
||||
defaultValues={agent}
|
||||
agentId={agent?.id}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent className="h-full justify-between" value="organizations">
|
||||
<AgentOrganisationForm
|
||||
defaultValues={agent}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
:
|
||||
<>
|
||||
<AgentForm
|
||||
organization={organization}
|
||||
onSuccess={() => {
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
}}
|
||||
defaultValues={agent}
|
||||
agentId={agent?.id}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -18,11 +18,14 @@ import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {AgentSchema, AgentType} from "@/features/agents/agents.schema";
|
||||
import {toast} from "sonner";
|
||||
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
|
||||
export type agentFormProps = {
|
||||
defaultValues?: AgentType;
|
||||
agentId?: string;
|
||||
onSuccess?: (data: any) => void;
|
||||
organization?: OrganizationWithMembers;
|
||||
|
||||
};
|
||||
|
||||
export const AgentForm = (props: agentFormProps) => {
|
||||
@@ -40,7 +43,10 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
mutationFn: async (values: AgentType) => {
|
||||
|
||||
const createAgent = isCreate
|
||||
? await createAgentAction(values)
|
||||
? await createAgentAction({
|
||||
organizationId: props.organization?.id ?? undefined,
|
||||
data: values
|
||||
})
|
||||
: await updateAgentAction({
|
||||
id: props.agentId ?? "-",
|
||||
data: values,
|
||||
@@ -61,7 +67,7 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess(data);
|
||||
} else {
|
||||
router.push(`/dashboard/agents/${data.id}`);
|
||||
router.push(props.organization ?`/dashboard/settings/agents/${data.id}` : `/dashboard/agents/${data.id}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user