refactor(agents): reorganize into actions/components/schemas/hooks

This commit is contained in:
Théo LAGACHE
2026-06-23 16:06:07 +02:00
parent 0dfbe39ccd
commit b9d9f42ec7
45 changed files with 361 additions and 307 deletions
@@ -9,12 +9,12 @@ import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq, isNull} from "drizzle-orm"; import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ButtonDeleteAgent } from "@/features/agents/agent-delete-button"; import { ButtonDeleteAgent } from "@/features/agents/components/agent-delete-button";
import { capitalizeFirstLetter } from "@/utils/text"; import { capitalizeFirstLetter } from "@/utils/text";
import { generateEdgeKey } from "@/utils/edge_key"; import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url"; import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/features/agents/agent-content"; import { AgentContentPage } from "@/features/agents/components/agent-content";
import { AgentDialog } from "@/features/agents/agent-dialog"; import { AgentDialog } from "@/features/agents/components/agent-dialog";
export default async function RoutePage( export default async function RoutePage(
@@ -1,5 +1,5 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {AgentCard} from "@/features/agents/agent-card"; import {AgentCard} from "@/features/agents/components/agent-card";
import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
@@ -7,7 +7,7 @@ import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {and, desc, eq, isNull, not} from "drizzle-orm"; import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next"; import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/agent-dialog"; import {AgentDialog} from "@/features/agents/components/agent-dialog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Agents", title: "Agents",
@@ -9,12 +9,12 @@ import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/features/agents/agent-delete-button"; import {ButtonDeleteAgent} from "@/features/agents/components/agent-delete-button";
import {capitalizeFirstLetter} from "@/utils/text"; import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key"; import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url"; import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/features/agents/agent-content"; import {AgentContentPage} from "@/features/agents/components/agent-content";
import {AgentDialog} from "@/features/agents/agent-dialog"; import {AgentDialog} from "@/features/agents/components/agent-dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
+1 -1
View File
@@ -3,7 +3,7 @@ import {withApiKey} from "@/lib/api-v1/middleware";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
import {ApiKeyContext} from "@/lib/api-v1/types"; import {ApiKeyContext} from "@/lib/api-v1/types";
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents"; import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
import {deleteAgentService} from "@/features/agents/agent-delete.action"; import {deleteAgentService} from "@/features/agents/actions/agent-delete.action";
const log = logger.child({module: "api/v1/agents/[id]"}); const log = logger.child({module: "api/v1/agents/[id]"});
+1 -1
View File
@@ -5,7 +5,7 @@ import * as drizzleDb from "@/db";
import { inArray, eq, and, or, isNull } from "drizzle-orm"; import { inArray, eq, and, or, isNull } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import {createAgentService} from "@/features/agents/agents.action"; import {createAgentService} from "@/features/agents/actions/agents.action";
import { ActionError } from "@/lib/safe-actions/actions"; import { ActionError } from "@/lib/safe-actions/actions";
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents"; import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
import {ApiKeyContext} from "@/lib/api-v1/types"; import {ApiKeyContext} from "@/lib/api-v1/types";
@@ -1,6 +1,6 @@
"use server"; "use server";
import {action, ActionError, userAction} from "@/lib/safe-actions/actions"; import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/agents.schema"; import {AgentSchema} from "@/features/agents/schemas/agents.schema";
import {z} from "zod"; import {z} from "zod";
import {eq, and, ne, count, desc} from "drizzle-orm"; import {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db"; import {db} from "@/db";
@@ -11,7 +11,7 @@ import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text"; import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip"; import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {useAgentUpdateCheck} from "@/features/agents/use-agent-update-check"; import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check";
export type agentCardProps = { export type agentCardProps = {
data: AgentWith; data: AgentWith;
@@ -3,10 +3,10 @@
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react"; import {Server} from "lucide-react";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentCardKey} from "@/features/agents/agent-card-key"; import {AgentCardKey} from "@/features/agents/components/agent-card-key";
import {AgentWithDatabases} from "@/db/schema/08_agent"; import {AgentWithDatabases} from "@/db/schema/08_agent";
import {useQuery} from "@tanstack/react-query"; import {useQuery} from "@tanstack/react-query";
import {getAgentAction} from "@/features/agents/agents.action"; import {getAgentAction} from "@/features/agents/actions/agents.action";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -16,7 +16,7 @@ import {
import {Separator} from "@/components/ui/separator"; import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/features/agents/agent-database-card"; import {AgentDatabaseCard} from "@/features/agents/components/agent-database-card";
import {HealthCheckGraph} from "@/features/database/health-grid"; import {HealthCheckGraph} from "@/features/database/health-grid";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"; import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
@@ -5,7 +5,7 @@ import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {toast} from "sonner"; import {toast} from "sonner";
import {deleteAgentAction} from "@/features/agents/agent-delete.action"; import {deleteAgentAction} from "@/features/agents/actions/agent-delete.action";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = { export type ButtonDeleteAgentProps = {
@@ -7,7 +7,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {AgentForm} from "@/features/agents/agent-form"; import {AgentForm} from "@/features/agents/components/agent-form";
import {Button, buttonVariants} from "@/components/ui/button"; import {Button, buttonVariants} from "@/components/ui/button";
import {Plus} from "lucide-react"; import {Plus} from "lucide-react";
import {GearIcon} from "@radix-ui/react-icons"; import {GearIcon} from "@radix-ui/react-icons";
@@ -16,7 +16,7 @@ import {useState} from "react";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {AgentOrganisationForm} from "@/features/agents/agent-organizations.form"; import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form";
import {AgentWith} from "@/db/schema/08_agent"; import {AgentWith} from "@/db/schema/08_agent";
type AgentDialogProps = { type AgentDialogProps = {
@@ -15,9 +15,9 @@ import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import {useMutation, useQueryClient} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip"; import {TooltipProvider} from "@/components/ui/tooltip";
import {AgentSchema, AgentType} from "@/features/agents/agents.schema"; import {AgentSchema, AgentType} from "@/features/agents/schemas/agents.schema";
import {toast} from "sonner"; import {toast} from "sonner";
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action"; import {createAgentAction, updateAgentAction} from "@/features/agents/actions/agents.action";
import {OrganizationWithMembers} from "@/db/schema/03_organization"; import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type agentFormProps = { export type agentFormProps = {
@@ -8,8 +8,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {MultiSelect} from "@/components/common/multi-select"; import {MultiSelect} from "@/components/common/multi-select";
import {toast} from "sonner"; import {toast} from "sonner";
import {AgentWith} from "@/db/schema/08_agent"; import {AgentWith} from "@/db/schema/08_agent";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/agent-organizations.schema"; import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/schemas/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/agent-organizations.action"; import {updateAgentOrganizationsAction} from "@/features/agents/actions/agent-organizations.action";
type AgentOrganisationFormProps = { type AgentOrganisationFormProps = {
@@ -2,7 +2,7 @@
import { userAction } from "@/lib/safe-actions/actions"; import { userAction } from "@/lib/safe-actions/actions";
import { z } from "zod"; import { z } from "zod";
import { getAgentAction } from "@/features/agents/agents.action"; import { getAgentAction } from "@/features/agents/actions/agents.action";
export const getAgentStatusAction = userAction export const getAgentStatusAction = userAction
.schema(z.object({ agentId: z.string() })) .schema(z.object({ agentId: z.string() }))
@@ -3,7 +3,7 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createAgentAction } from "@/features/agents/agents.action"; import { createAgentAction } from "@/features/agents/actions/agents.action";
import type { OnboardingAgent, OnboardingDefaultsData } from "@/features/onboarding/types"; import type { OnboardingAgent, OnboardingDefaultsData } from "@/features/onboarding/types";
import { generateEdgeKey } from "@/utils/edge_key"; import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url"; import { getServerUrl } from "@/utils/get-server-url";
@@ -3,7 +3,7 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteAgentAction } from "@/features/agents/agent-delete.action"; import { deleteAgentAction } from "@/features/agents/actions/agent-delete.action";
import type { OnboardingAgent } from "@/features/onboarding/types"; import type { OnboardingAgent } from "@/features/onboarding/types";
export const useDeleteAgent = () => { export const useDeleteAgent = () => {
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AgentCardKey } from "@/features/agents/agent-card-key"; import { AgentCardKey } from "@/features/agents/components/agent-card-key";
import type { OnboardingAgent } from "@/features/onboarding/types"; import type { OnboardingAgent } from "@/features/onboarding/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -2,8 +2,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {Agent} from "@/db/schema/08_agent"; import {Agent} from "@/db/schema/08_agent";
import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {AgentCard} from "@/features/agents/agent-card"; import {AgentCard} from "@/features/agents/components/agent-card";
import {AgentDialog} from "@/features/agents/agent-dialog"; import {AgentDialog} from "@/features/agents/components/agent-dialog";
export type OrganizationAgentsTabProps = { export type OrganizationAgentsTabProps = {
organization: OrganizationWithMembers; organization: OrganizationWithMembers;
+1 -1
View File
@@ -26,7 +26,7 @@ import {
} from "@/features/settings/storage.action"; } from "@/features/settings/storage.action";
import {toast} from "sonner"; import {toast} from "sonner";
import {Switch} from "@/components/ui/switch"; import {Switch} from "@/components/ui/switch";
import {downloadMasterKeyAction} from "@/features/agents/keys.action"; import {downloadMasterKeyAction} from "@/features/agents/actions/keys.action";
export type SettingsStorageSectionProps = { export type SettingsStorageSectionProps = {
settings: Setting; settings: Setting;
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
import "@/lib/api-v1/openapi/registry"; import "@/lib/api-v1/openapi/registry";
import {AgentSchema} from "@/features/agents/agents.schema"; import {AgentSchema} from "@/features/agents/schemas/agents.schema";
import {agentSchema} from "@/db/schema/08_agent"; import {agentSchema} from "@/db/schema/08_agent";
const UuidParam = z const UuidParam = z
+8 -8
View File
@@ -1,16 +1,16 @@
"use server"; "use server";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import {User} from "@/db/schema/02_user"; import { User } from "@/db/schema/02_user";
export const currentUser = async () => { export const currentUser = async () => {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });
if (!session?.user) { if (!session?.user) {
return null; return null;
} }
return session.user as User; return session.user as User;
}; };
+21 -13
View File
@@ -2,19 +2,27 @@ import { createHmac } from "crypto";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
export function signPasskeyContext(name: string, email: string): string { export function signPasskeyContext(name: string, email: string): string {
const payload = Buffer.from(JSON.stringify({ name, email, exp: Date.now() + 5 * 60 * 1000 })).toString("base64url"); const payload = Buffer.from(
const sig = createHmac("sha256", env.PROJECT_SECRET).update(payload).digest("base64url"); JSON.stringify({ name, email, exp: Date.now() + 5 * 60 * 1000 }),
return `${payload}.${sig}`; ).toString("base64url");
const sig = createHmac("sha256", env.PROJECT_SECRET)
.update(payload)
.digest("base64url");
return `${payload}.${sig}`;
} }
export function verifyPasskeyContext(token: string): { name: string; email: string } | null { export function verifyPasskeyContext(
const dot = token.lastIndexOf("."); token: string,
if (dot === -1) return null; ): { name: string; email: string } | null {
const payload = token.slice(0, dot); const dot = token.lastIndexOf(".");
const sig = token.slice(dot + 1); if (dot === -1) return null;
const expected = createHmac("sha256", env.PROJECT_SECRET).update(payload).digest("base64url"); const payload = token.slice(0, dot);
if (sig !== expected) return null; const sig = token.slice(dot + 1);
const data = JSON.parse(Buffer.from(payload, "base64url").toString()); const expected = createHmac("sha256", env.PROJECT_SECRET)
if (data.exp < Date.now()) return null; .update(payload)
return { name: data.name, email: data.email }; .digest("base64url");
if (sig !== expected) return null;
const data = JSON.parse(Buffer.from(payload, "base64url").toString());
if (data.exp < Date.now()) return null;
return { name: data.name, email: data.email };
} }
-1
View File
@@ -48,7 +48,6 @@ const pending = ac.newRole({
agent: [], agent: [],
}); });
//org
const orgMember = ac.newRole({ const orgMember = ac.newRole({
project: ["list"], project: ["list"],
database: ["list"], database: ["list"],
+13 -13
View File
@@ -1,16 +1,16 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import {Server} from "./types" import { Server } from "./types";
export const createTransporter = (server: Server) => { export const createTransporter = (server: Server) => {
const portNumber = Number(server.port); const portNumber = Number(server.port);
return nodemailer.createTransport({ return nodemailer.createTransport({
pool: true, pool: true,
host: server.host, host: server.host,
port: portNumber, port: portNumber,
secure: server.secure ?? portNumber === 465, secure: server.secure ?? portNumber === 465,
auth: { auth: {
user: server.user, user: server.user,
pass: server.pass, pass: server.pass,
}, },
}); });
}; };
+38 -33
View File
@@ -1,42 +1,47 @@
"use server" "use server";
import {db} from "@/db"; import { db } from "@/db";
import {eq} from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {createTransporter} from "@/lib/email/helpers"; import { createTransporter } from "@/lib/email/helpers";
import {Payload} from "@/lib/email/types"; import { Payload } from "@/lib/email/types";
export const sendEmail = async (data: Payload) => { export const sendEmail = async (data: Payload) => {
const settings = await db const settings = await db
.select() .select()
.from(drizzleDb.schemas.setting) .from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system")) .where(eq(drizzleDb.schemas.setting.name, "system"))
.then((res) => res[0]); .then((res) => res[0]);
if (!settings) { if (!settings) {
throw new Error("SMTP system settings not found."); throw new Error("SMTP system settings not found.");
} }
if (!settings.smtpHost || !settings.smtpPort || !settings.smtpUser || !settings.smtpPassword || !settings.smtpFrom) { if (
console.warn("Incomplete SMTP settings. Email not sent."); !settings.smtpHost ||
return; !settings.smtpPort ||
} !settings.smtpUser ||
!settings.smtpPassword ||
!settings.smtpFrom
) {
console.warn("Incomplete SMTP settings. Email not sent.");
return;
}
const emailsArray = data.to.split(",") const emailsArray = data.to.split(",").map((email) => email.trim());
.map(email => email.trim());
const transporter = createTransporter({ const transporter = createTransporter({
host: settings.smtpHost, host: settings.smtpHost,
port: Number(settings.smtpPort), port: Number(settings.smtpPort),
user: settings.smtpUser, user: settings.smtpUser,
pass: settings.smtpPassword, pass: settings.smtpPassword,
from: settings.smtpFrom, from: settings.smtpFrom,
secure: settings.smtpSecure ?? false, secure: settings.smtpSecure ?? false,
}); });
await transporter.verify(); await transporter.verify();
return await transporter.sendMail({ return await transporter.sendMail({
...data, ...data,
to: emailsArray, to: emailsArray,
from: settings.smtpFrom ?? undefined, from: settings.smtpFrom ?? undefined,
}); });
}; };
+10 -12
View File
@@ -1,19 +1,17 @@
"use server"; "use server";
export type Payload = { export type Payload = {
to: string; to: string;
from?: string; from?: string;
subject: string; subject: string;
html: any; html: any;
}; };
export type Server = { export type Server = {
host: string; host: string;
port: number; port: number;
user: string; user: string;
pass: string; pass: string;
from: string; from: string;
secure: boolean; secure: boolean;
}; };
+2 -9
View File
@@ -4,14 +4,10 @@ export type ApiV1Result =
| { ok: true; status: number; data: unknown } | { ok: true; status: number; data: unknown }
| { ok: false; status: number; error: string }; | { ok: false; status: number; error: string };
/**
* Thin fetch wrapper that proxies calls to the existing /api/v1 REST layer.
* Forwards the caller's API key so the REST route runs its own auth + permission checks.
*/
export async function apiV1Fetch( export async function apiV1Fetch(
path: string, path: string,
options: RequestInit, options: RequestInit,
apiKey: string apiKey: string,
): Promise<ApiV1Result> { ): Promise<ApiV1Result> {
const url = `${getServerUrl()}${path}`; const url = `${getServerUrl()}${path}`;
@@ -29,7 +25,6 @@ export async function apiV1Fetch(
return { ok: false, status: 0, error: "Failed to reach internal API" }; return { ok: false, status: 0, error: "Failed to reach internal API" };
} }
// 204 No Content has no body
if (res.status === 204) { if (res.status === 204) {
return { ok: true, status: 204, data: null }; return { ok: true, status: 204, data: null };
} }
@@ -53,9 +48,7 @@ export async function apiV1Fetch(
} }
const data = const data =
typeof body === "object" && typeof body === "object" && body !== null && "data" in body
body !== null &&
"data" in body
? (body as { data: unknown }).data ? (body as { data: unknown }).data
: body; : body;
+1 -1
View File
@@ -14,7 +14,7 @@ import { registerBackupTools } from "./tools/backups";
*/ */
export function createPortabaseMcpServer( export function createPortabaseMcpServer(
_ctx: ApiKeyContext, _ctx: ApiKeyContext,
apiKey: string apiKey: string,
): McpServer { ): McpServer {
const server = new McpServer({ const server = new McpServer({
name: "portabase", name: "portabase",
+28 -14
View File
@@ -1,8 +1,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import {apiV1Fetch} from "@/lib/mcp/http-client"; import { apiV1Fetch } from "@/lib/mcp/http-client";
import {err, ok} from "@/lib/mcp/tools/response"; import { err, ok } from "@/lib/mcp/tools/response";
export function registerAgentTools(server: McpServer, apiKey: string) { export function registerAgentTools(server: McpServer, apiKey: string) {
server.tool( server.tool(
@@ -10,9 +9,13 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
"List all agents accessible to the authenticated user", "List all agents accessible to the authenticated user",
{}, {},
async () => { async () => {
const result = await apiV1Fetch("/api/v1/agents", { method: "GET" }, apiKey); const result = await apiV1Fetch(
"/api/v1/agents",
{ method: "GET" },
apiKey,
);
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
server.tool( server.tool(
@@ -20,9 +23,13 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
"Get details for a specific agent, including its associated databases", "Get details for a specific agent, including its associated databases",
{ id: z.string().describe("Agent ID") }, { id: z.string().describe("Agent ID") },
async ({ id }) => { async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}`, { method: "GET" }, apiKey); const result = await apiV1Fetch(
`/api/v1/agents/${id}`,
{ method: "GET" },
apiKey,
);
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
server.tool( server.tool(
@@ -31,7 +38,6 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
{ {
name: z.string().min(1).describe("Agent name"), name: z.string().min(1).describe("Agent name"),
organizationId: z organizationId: z
.string()
.uuid() .uuid()
.optional() .optional()
.describe("Organization ID to scope the agent to (optional)"), .describe("Organization ID to scope the agent to (optional)"),
@@ -40,10 +46,10 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
const result = await apiV1Fetch( const result = await apiV1Fetch(
"/api/v1/agents", "/api/v1/agents",
{ method: "POST", body: JSON.stringify({ name, organizationId }) }, { method: "POST", body: JSON.stringify({ name, organizationId }) },
apiKey apiKey,
); );
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
server.tool( server.tool(
@@ -51,11 +57,15 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
"Delete an agent by ID", "Delete an agent by ID",
{ id: z.string().describe("Agent ID") }, { id: z.string().describe("Agent ID") },
async ({ id }) => { async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}`, { method: "DELETE" }, apiKey); const result = await apiV1Fetch(
`/api/v1/agents/${id}`,
{ method: "DELETE" },
apiKey,
);
return result.ok return result.ok
? ok({ message: `Agent ${id} deleted successfully` }) ? ok({ message: `Agent ${id} deleted successfully` })
: err(result.error); : err(result.error);
} },
); );
server.tool( server.tool(
@@ -63,8 +73,12 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
"Get the edge key for an agent (used by the agent to authenticate with Portabase)", "Get the edge key for an agent (used by the agent to authenticate with Portabase)",
{ id: z.string().describe("Agent ID") }, { id: z.string().describe("Agent ID") },
async ({ id }) => { async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}/key`, { method: "GET" }, apiKey); const result = await apiV1Fetch(
`/api/v1/agents/${id}/key`,
{ method: "GET" },
apiKey,
);
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
} }
+10 -11
View File
@@ -1,7 +1,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import {err, ok} from "@/lib/mcp/tools/response"; import { err, ok } from "@/lib/mcp/tools/response";
import {apiV1Fetch} from "@/lib/mcp/http-client"; import { apiV1Fetch } from "@/lib/mcp/http-client";
export function registerBackupTools(server: McpServer, apiKey: string) { export function registerBackupTools(server: McpServer, apiKey: string) {
server.tool( server.tool(
@@ -12,10 +12,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
const result = await apiV1Fetch( const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup`, `/api/v1/databases/${databaseId}/backup`,
{ method: "GET" }, { method: "GET" },
apiKey apiKey,
); );
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
server.tool( server.tool(
@@ -29,10 +29,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
const result = await apiV1Fetch( const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup/${backupId}`, `/api/v1/databases/${databaseId}/backup/${backupId}`,
{ method: "GET" }, { method: "GET" },
apiKey apiKey,
); );
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
server.tool( server.tool(
@@ -43,10 +43,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
const result = await apiV1Fetch( const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup`, `/api/v1/databases/${databaseId}/backup`,
{ method: "POST", body: JSON.stringify({}) }, { method: "POST", body: JSON.stringify({}) },
apiKey apiKey,
); );
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
server.tool( server.tool(
@@ -56,7 +56,6 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
databaseId: z.string().describe("Database ID"), databaseId: z.string().describe("Database ID"),
backupId: z.string().uuid().describe("Backup ID"), backupId: z.string().uuid().describe("Backup ID"),
backupStorageId: z backupStorageId: z
.string()
.uuid() .uuid()
.describe("Backup storage ID (from get_backup storages list)"), .describe("Backup storage ID (from get_backup storages list)"),
}, },
@@ -67,9 +66,9 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
method: "POST", method: "POST",
body: JSON.stringify({ backupId, backupStorageId }), body: JSON.stringify({ backupId, backupStorageId }),
}, },
apiKey apiKey,
); );
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
} }
+43 -36
View File
@@ -1,41 +1,48 @@
import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {z} from "zod"; import { z } from "zod";
import {err, ok} from "@/lib/mcp/tools/response"; import { err, ok } from "@/lib/mcp/tools/response";
import {apiV1Fetch} from "@/lib/mcp/http-client"; import { apiV1Fetch } from "@/lib/mcp/http-client";
export function registerDatabaseTools(server: McpServer, apiKey: string) { export function registerDatabaseTools(server: McpServer, apiKey: string) {
server.tool( server.tool(
"list_databases", "list_databases",
"List all databases accessible to the authenticated user", "List all databases accessible to the authenticated user",
{}, {},
async () => { async () => {
const result = await apiV1Fetch("/api/v1/databases", {method: "GET"}, apiKey); const result = await apiV1Fetch(
return result.ok ? ok(result.data) : err(result.error); "/api/v1/databases",
} { method: "GET" },
); apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
},
);
server.tool( server.tool(
"get_database", "get_database",
"Get details for a specific database", "Get details for a specific database",
{id: z.string().describe("Database ID")}, { id: z.string().describe("Database ID") },
async ({id}) => { async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/databases/${id}`, {method: "GET"}, apiKey); const result = await apiV1Fetch(
return result.ok ? ok(result.data) : err(result.error); `/api/v1/databases/${id}`,
} { method: "GET" },
); apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
},
);
server.tool( server.tool(
"get_database_status", "get_database_status",
"Get the current status of a database, including latest backup and restoration state", "Get the current status of a database, including latest backup and restoration state",
{id: z.string().describe("Database ID")}, { id: z.string().describe("Database ID") },
async ({id}) => { async ({ id }) => {
const result = await apiV1Fetch( const result = await apiV1Fetch(
`/api/v1/databases/${id}/status`, `/api/v1/databases/${id}/status`,
{method: "GET"}, { method: "GET" },
apiKey apiKey,
); );
return result.ok ? ok(result.data) : err(result.error); return result.ok ? ok(result.data) : err(result.error);
} },
); );
} }
+8 -8
View File
@@ -1,12 +1,12 @@
export function ok(data: unknown) { export function ok(data: unknown) {
return { return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
}; };
} }
export function err(message: string) { export function err(message: string) {
return { return {
content: [{ type: "text" as const, text: message }], content: [{ type: "text" as const, text: message }],
isError: true as const, isError: true as const,
}; };
} }
+18 -18
View File
@@ -1,29 +1,29 @@
import {createSafeActionClient} from "next-safe-action"; import { createSafeActionClient } from "next-safe-action";
import {currentUser} from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
export class ActionError extends Error { export class ActionError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = "ActionError"; this.name = "ActionError";
} }
} }
const handleReturnedServerError = (error: Error) => { const handleReturnedServerError = (error: Error) => {
if (error instanceof ActionError) { if (error instanceof ActionError) {
return error.message; return error.message;
} else { } else {
return "An unexpected error occurred."; return "An unexpected error occurred.";
} }
}; };
export const action = createSafeActionClient({ export const action = createSafeActionClient({
handleServerError: handleReturnedServerError, handleServerError: handleReturnedServerError,
}); });
export const userAction = action.use(async ({next, ctx}) => { export const userAction = action.use(async ({ next }) => {
const user = await currentUser(); const user = await currentUser();
if (!user) { if (!user) {
throw new ActionError("You must be logged in"); throw new ActionError("You must be logged in");
} }
return next({ctx: {user}}); return next({ ctx: { user } });
}); });
+40 -34
View File
@@ -1,45 +1,51 @@
import {db} from "@/db"; import { db } from "@/db";
import {eq, lt, and, desc, isNull} from "drizzle-orm"; import { eq, lt, and, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; import { deleteBackupCronAction } from "@/lib/tasks/database/utils/delete";
import {logger} from "@/lib/logger"; import { logger } from "@/lib/logger";
const log = logger.child({module: "tasks/database/retention-days"}); const log = logger.child({ module: "tasks/database/retention-days" });
export async function enforceRetentionDays(databaseId: string, days: number) { export async function enforceRetentionDays(databaseId: string, days: number) {
log.info({ name: "enforceRetentionDays"}, `Enforce Retention Days starting for ${databaseId}`); log.info(
{ name: "enforceRetentionDays" },
`Enforce Retention Days starting for ${databaseId}`,
);
const cutoff = new Date(Date.now() - days * 86400000); const cutoff = new Date(Date.now() - days * 86400000);
const expiredBackups = await db.query.backup.findMany({ const expiredBackups = await db.query.backup.findMany({
where: and( where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseId), eq(drizzleDb.schemas.backup.databaseId, databaseId),
lt(drizzleDb.schemas.backup.createdAt, cutoff), lt(drizzleDb.schemas.backup.createdAt, cutoff),
isNull(drizzleDb.schemas.backup.deletedAt) isNull(drizzleDb.schemas.backup.deletedAt),
), ),
with: {
database: {
with: { with: {
database: { project: true,
with: { },
project: true },
} },
} });
}
for (const backup of expiredBackups) {
const result = await deleteBackupCronAction({
backupId: backup.id,
databaseId: backup.databaseId,
}); });
for (const backup of expiredBackups) { const inner = result?.data;
if (inner?.success) {
const result = await deleteBackupCronAction({ log.info(
backupId: backup.id, { name: "enforceRetentionDays" },
databaseId: backup.databaseId, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`,
}); );
} else {
const inner = result?.data; log.info(
if (inner?.success) { { name: "enforceRetentionDays" },
log.info({ name: "enforceRetentionDays"}, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`); `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`,
} else { );
log.info({ name: "enforceRetentionDays"}, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`);
}
} }
}
} }
+90 -65
View File
@@ -1,81 +1,106 @@
import {db} from "@/db"; import { db } from "@/db";
import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns"; import {
import {eq, desc, isNull, and} from "drizzle-orm"; subDays,
subWeeks,
subMonths,
subYears,
startOfWeek,
startOfMonth,
startOfYear,
} from "date-fns";
import { eq, desc, isNull, and } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; import { deleteBackupCronAction } from "@/lib/tasks/database/utils/delete";
import {logger} from "@/lib/logger"; import { logger } from "@/lib/logger";
const log = logger.child({module: "tasks/database/retention-gsf"}); const log = logger.child({ module: "tasks/database/retention-gsf" });
export async function enforceRetentionGFS(databaseId: string, gfsSettings: { export async function enforceRetentionGFS(
databaseId: string,
gfsSettings: {
daily: number; daily: number;
weekly: number; weekly: number;
monthly: number; monthly: number;
yearly: number; yearly: number;
}) { },
log.info({ name: "enforceRetentionGFS"}, `Retention GFS started for databaseId: ${databaseId}`); ) {
log.info(
{ name: "enforceRetentionGFS" },
`Retention GFS started for databaseId: ${databaseId}`,
);
const backups = await db.query.backup.findMany({ const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)), where: and(
orderBy: desc(drizzleDb.schemas.backup.createdAt), eq(drizzleDb.schemas.backup.databaseId, databaseId),
isNull(drizzleDb.schemas.backup.deletedAt),
),
orderBy: desc(drizzleDb.schemas.backup.createdAt),
with: {
database: {
with: { with: {
database: { project: true,
with: { },
project: true },
} },
} });
}
});
const now = new Date(); const now = new Date();
const toKeep: Set<string> = new Set(); const toKeep: Set<string> = new Set();
// DAILY backups.forEach((b) => {
backups.forEach((b) => { if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id);
if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id); });
});
const weekStartDates = Array.from({length: gfsSettings.weekly}, (_, i) => startOfWeek(subWeeks(now, i), {weekStartsOn: 1})); const weekStartDates = Array.from({ length: gfsSettings.weekly }, (_, i) =>
weekStartDates.forEach((weekStart) => { startOfWeek(subWeeks(now, i), { weekStartsOn: 1 }),
const backupOfWeek = backups.find( );
(b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1) weekStartDates.forEach((weekStart) => {
const backupOfWeek = backups.find(
(b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1),
);
if (backupOfWeek) toKeep.add(backupOfWeek.id);
});
const monthStartDates = Array.from({ length: gfsSettings.monthly }, (_, i) =>
startOfMonth(subMonths(now, i)),
);
monthStartDates.forEach((monthStart) => {
const backupOfMonth = backups.find(
(b) =>
b.createdAt >= monthStart && b.createdAt < subMonths(monthStart, -1),
);
if (backupOfMonth) toKeep.add(backupOfMonth.id);
});
const yearStartDates = Array.from({ length: gfsSettings.yearly }, (_, i) =>
startOfYear(subYears(now, i)),
);
yearStartDates.forEach((yearStart) => {
const backupOfYear = backups.find(
(b) => b.createdAt >= yearStart && b.createdAt < subYears(yearStart, -1),
);
if (backupOfYear) toKeep.add(backupOfYear.id);
});
for (const b of backups) {
if (!toKeep.has(b.id)) {
const result = await deleteBackupCronAction({
backupId: b.id,
databaseId: b.databaseId,
});
const inner = result?.data;
if (inner?.success) {
log.info(
{ name: "enforceRetentionGFS" },
`(databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`,
); );
if (backupOfWeek) toKeep.add(backupOfWeek.id); } else {
}); log.info(
{ name: "enforceRetentionGFS" },
// MONTHLY `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`,
const monthStartDates = Array.from({length: gfsSettings.monthly}, (_, i) => startOfMonth(subMonths(now, i)));
monthStartDates.forEach((monthStart) => {
const backupOfMonth = backups.find(
(b) => b.createdAt >= monthStart && b.createdAt < subMonths(monthStart, -1)
); );
if (backupOfMonth) toKeep.add(backupOfMonth.id); }
});
// YEARLY
const yearStartDates = Array.from({length: gfsSettings.yearly}, (_, i) => startOfYear(subYears(now, i)));
yearStartDates.forEach((yearStart) => {
const backupOfYear = backups.find(
(b) => b.createdAt >= yearStart && b.createdAt < subYears(yearStart, -1)
);
if (backupOfYear) toKeep.add(backupOfYear.id);
});
// Delete backups not in `toKeep`
for (const b of backups) {
if (!toKeep.has(b.id)) {
const result = await deleteBackupCronAction({
backupId: b.id,
databaseId: b.databaseId,
});
const inner = result?.data;
if (inner?.success) {
log.info({ name: "enforceRetentionGFS"}, `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
log.info({ name: "enforceRetentionGFS"}, `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
}
} }
}
} }
+1 -1
View File
@@ -1,5 +1,5 @@
"use server" "use server"
import {getMasterServerKeyContent} from "@/features/agents/keys.action"; import {getMasterServerKeyContent} from "@/features/agents/actions/keys.action";
export async function generateEdgeKey(serverUrl: string, agentId: string): Promise<string> { export async function generateEdgeKey(serverUrl: string, agentId: string): Promise<string> {
const masterKey = await getMasterServerKeyContent() const masterKey = await getMasterServerKeyContent()