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 {eq, isNull} from "drizzle-orm";
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 { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/features/agents/agent-content";
import { AgentDialog } from "@/features/agents/agent-dialog";
import { AgentContentPage } from "@/features/agents/components/agent-content";
import { AgentDialog } from "@/features/agents/components/agent-dialog";
export default async function RoutePage(
@@ -1,5 +1,5 @@
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 {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
@@ -7,7 +7,7 @@ import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
export const metadata: Metadata = {
title: "Agents",
@@ -9,12 +9,12 @@ import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
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 {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/features/agents/agent-content";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentContentPage} from "@/features/agents/components/agent-content";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user";
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 {ApiKeyContext} from "@/lib/api-v1/types";
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]"});
+1 -1
View File
@@ -5,7 +5,7 @@ import * as drizzleDb from "@/db";
import { inArray, eq, and, or, isNull } from "drizzle-orm";
import { z } from "zod";
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 {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
import {ApiKeyContext} from "@/lib/api-v1/types";
@@ -1,6 +1,6 @@
"use server";
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 {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db";
@@ -11,7 +11,7 @@ import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
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 = {
data: AgentWith;
@@ -3,10 +3,10 @@
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Server} from "lucide-react";
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 {useQuery} from "@tanstack/react-query";
import {getAgentAction} from "@/features/agents/agents.action";
import {getAgentAction} from "@/features/agents/actions/agents.action";
import {
Accordion,
AccordionContent,
@@ -16,7 +16,7 @@ import {
import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
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 {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 {useRouter} from "next/navigation";
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";
export type ButtonDeleteAgentProps = {
@@ -7,7 +7,7 @@ import {
DialogTitle,
DialogTrigger,
} 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 {Plus} from "lucide-react";
import {GearIcon} from "@radix-ui/react-icons";
@@ -16,7 +16,7 @@ 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/agent-organizations.form";
import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form";
import {AgentWith} from "@/db/schema/08_agent";
type AgentDialogProps = {
@@ -15,9 +15,9 @@ import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
import {useMutation, useQueryClient} from "@tanstack/react-query";
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 {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
import {createAgentAction, updateAgentAction} from "@/features/agents/actions/agents.action";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type agentFormProps = {
@@ -8,8 +8,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {MultiSelect} from "@/components/common/multi-select";
import {toast} from "sonner";
import {AgentWith} from "@/db/schema/08_agent";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/agent-organizations.action";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/schemas/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/actions/agent-organizations.action";
type AgentOrganisationFormProps = {
@@ -2,7 +2,7 @@
import { userAction } from "@/lib/safe-actions/actions";
import { z } from "zod";
import { getAgentAction } from "@/features/agents/agents.action";
import { getAgentAction } from "@/features/agents/actions/agents.action";
export const getAgentStatusAction = userAction
.schema(z.object({ agentId: z.string() }))
@@ -3,7 +3,7 @@
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
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 { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
@@ -3,7 +3,7 @@
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
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";
export const useDeleteAgent = () => {
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { Server } from "lucide-react";
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 { cn } from "@/lib/utils";
@@ -2,8 +2,8 @@ import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {cn} from "@/lib/utils";
import {Agent} from "@/db/schema/08_agent";
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
import {AgentCard} from "@/features/agents/agent-card";
import {AgentDialog} from "@/features/agents/agent-dialog";
import {AgentCard} from "@/features/agents/components/agent-card";
import {AgentDialog} from "@/features/agents/components/agent-dialog";
export type OrganizationAgentsTabProps = {
organization: OrganizationWithMembers;
+1 -1
View File
@@ -26,7 +26,7 @@ import {
} from "@/features/settings/storage.action";
import {toast} from "sonner";
import {Switch} from "@/components/ui/switch";
import {downloadMasterKeyAction} from "@/features/agents/keys.action";
import {downloadMasterKeyAction} from "@/features/agents/actions/keys.action";
export type SettingsStorageSectionProps = {
settings: Setting;
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
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";
const UuidParam = z
+8 -8
View File
@@ -1,16 +1,16 @@
"use server";
import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers";
import {User} from "@/db/schema/02_user";
import { User } from "@/db/schema/02_user";
export const currentUser = async () => {
const session = await auth.api.getSession({
headers: await headers(),
});
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return null;
}
if (!session?.user) {
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";
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 sig = createHmac("sha256", env.PROJECT_SECRET).update(payload).digest("base64url");
return `${payload}.${sig}`;
const payload = Buffer.from(
JSON.stringify({ name, email, exp: Date.now() + 5 * 60 * 1000 }),
).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 {
const dot = token.lastIndexOf(".");
if (dot === -1) return null;
const payload = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = createHmac("sha256", env.PROJECT_SECRET).update(payload).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 };
export function verifyPasskeyContext(
token: string,
): { name: string; email: string } | null {
const dot = token.lastIndexOf(".");
if (dot === -1) return null;
const payload = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = createHmac("sha256", env.PROJECT_SECRET)
.update(payload)
.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: [],
});
//org
const orgMember = ac.newRole({
project: ["list"],
database: ["list"],
+13 -13
View File
@@ -1,16 +1,16 @@
import nodemailer from "nodemailer";
import {Server} from "./types"
import { Server } from "./types";
export const createTransporter = (server: Server) => {
const portNumber = Number(server.port);
return nodemailer.createTransport({
pool: true,
host: server.host,
port: portNumber,
secure: server.secure ?? portNumber === 465,
auth: {
user: server.user,
pass: server.pass,
},
});
};
const portNumber = Number(server.port);
return nodemailer.createTransport({
pool: true,
host: server.host,
port: portNumber,
secure: server.secure ?? portNumber === 465,
auth: {
user: server.user,
pass: server.pass,
},
});
};
+38 -33
View File
@@ -1,42 +1,47 @@
"use server"
import {db} from "@/db";
import {eq} from "drizzle-orm";
"use server";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {createTransporter} from "@/lib/email/helpers";
import {Payload} from "@/lib/email/types";
import { createTransporter } from "@/lib/email/helpers";
import { Payload } from "@/lib/email/types";
export const sendEmail = async (data: Payload) => {
const settings = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.then((res) => res[0]);
const settings = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.then((res) => res[0]);
if (!settings) {
throw new Error("SMTP system settings not found.");
}
if (!settings) {
throw new Error("SMTP system settings not found.");
}
if (!settings.smtpHost || !settings.smtpPort || !settings.smtpUser || !settings.smtpPassword || !settings.smtpFrom) {
console.warn("Incomplete SMTP settings. Email not sent.");
return;
}
if (
!settings.smtpHost ||
!settings.smtpPort ||
!settings.smtpUser ||
!settings.smtpPassword ||
!settings.smtpFrom
) {
console.warn("Incomplete SMTP settings. Email not sent.");
return;
}
const emailsArray = data.to.split(",")
.map(email => email.trim());
const emailsArray = data.to.split(",").map((email) => email.trim());
const transporter = createTransporter({
host: settings.smtpHost,
port: Number(settings.smtpPort),
user: settings.smtpUser,
pass: settings.smtpPassword,
from: settings.smtpFrom,
secure: settings.smtpSecure ?? false,
});
await transporter.verify();
const transporter = createTransporter({
host: settings.smtpHost,
port: Number(settings.smtpPort),
user: settings.smtpUser,
pass: settings.smtpPassword,
from: settings.smtpFrom,
secure: settings.smtpSecure ?? false,
});
await transporter.verify();
return await transporter.sendMail({
...data,
to: emailsArray,
from: settings.smtpFrom ?? undefined,
});
return await transporter.sendMail({
...data,
to: emailsArray,
from: settings.smtpFrom ?? undefined,
});
};
+10 -12
View File
@@ -1,19 +1,17 @@
"use server";
export type Payload = {
to: string;
from?: string;
subject: string;
html: any;
to: string;
from?: string;
subject: string;
html: any;
};
export type Server = {
host: string;
port: number;
user: string;
pass: string;
from: string;
secure: boolean;
host: string;
port: number;
user: string;
pass: string;
from: string;
secure: boolean;
};
+2 -9
View File
@@ -4,14 +4,10 @@ export type ApiV1Result =
| { ok: true; status: number; data: unknown }
| { 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(
path: string,
options: RequestInit,
apiKey: string
apiKey: string,
): Promise<ApiV1Result> {
const url = `${getServerUrl()}${path}`;
@@ -29,7 +25,6 @@ export async function apiV1Fetch(
return { ok: false, status: 0, error: "Failed to reach internal API" };
}
// 204 No Content has no body
if (res.status === 204) {
return { ok: true, status: 204, data: null };
}
@@ -53,9 +48,7 @@ export async function apiV1Fetch(
}
const data =
typeof body === "object" &&
body !== null &&
"data" in body
typeof body === "object" && body !== null && "data" in body
? (body as { data: unknown }).data
: body;
+1 -1
View File
@@ -14,7 +14,7 @@ import { registerBackupTools } from "./tools/backups";
*/
export function createPortabaseMcpServer(
_ctx: ApiKeyContext,
apiKey: string
apiKey: string,
): McpServer {
const server = new McpServer({
name: "portabase",
+28 -14
View File
@@ -1,8 +1,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {apiV1Fetch} from "@/lib/mcp/http-client";
import {err, ok} from "@/lib/mcp/tools/response";
import { apiV1Fetch } from "@/lib/mcp/http-client";
import { err, ok } from "@/lib/mcp/tools/response";
export function registerAgentTools(server: McpServer, apiKey: string) {
server.tool(
@@ -10,9 +9,13 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
"List all agents accessible to the authenticated user",
{},
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);
}
},
);
server.tool(
@@ -20,9 +23,13 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
"Get details for a specific agent, including its associated databases",
{ id: z.string().describe("Agent 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);
}
},
);
server.tool(
@@ -31,7 +38,6 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
{
name: z.string().min(1).describe("Agent name"),
organizationId: z
.string()
.uuid()
.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(
"/api/v1/agents",
{ method: "POST", body: JSON.stringify({ name, organizationId }) },
apiKey
apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
}
},
);
server.tool(
@@ -51,11 +57,15 @@ export function registerAgentTools(server: McpServer, apiKey: string) {
"Delete an agent by ID",
{ id: z.string().describe("Agent 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
? ok({ message: `Agent ${id} deleted successfully` })
: err(result.error);
}
},
);
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)",
{ id: z.string().describe("Agent 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);
}
},
);
}
+10 -11
View File
@@ -1,7 +1,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {err, ok} from "@/lib/mcp/tools/response";
import {apiV1Fetch} from "@/lib/mcp/http-client";
import { err, ok } from "@/lib/mcp/tools/response";
import { apiV1Fetch } from "@/lib/mcp/http-client";
export function registerBackupTools(server: McpServer, apiKey: string) {
server.tool(
@@ -12,10 +12,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup`,
{ method: "GET" },
apiKey
apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
}
},
);
server.tool(
@@ -29,10 +29,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup/${backupId}`,
{ method: "GET" },
apiKey
apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
}
},
);
server.tool(
@@ -43,10 +43,10 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup`,
{ method: "POST", body: JSON.stringify({}) },
apiKey
apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
}
},
);
server.tool(
@@ -56,7 +56,6 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
databaseId: z.string().describe("Database ID"),
backupId: z.string().uuid().describe("Backup ID"),
backupStorageId: z
.string()
.uuid()
.describe("Backup storage ID (from get_backup storages list)"),
},
@@ -67,9 +66,9 @@ export function registerBackupTools(server: McpServer, apiKey: string) {
method: "POST",
body: JSON.stringify({ backupId, backupStorageId }),
},
apiKey
apiKey,
);
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 {z} from "zod";
import {err, ok} from "@/lib/mcp/tools/response";
import {apiV1Fetch} from "@/lib/mcp/http-client";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { err, ok } from "@/lib/mcp/tools/response";
import { apiV1Fetch } from "@/lib/mcp/http-client";
export function registerDatabaseTools(server: McpServer, apiKey: string) {
server.tool(
"list_databases",
"List all databases accessible to the authenticated user",
{},
async () => {
const result = await apiV1Fetch("/api/v1/databases", {method: "GET"}, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"list_databases",
"List all databases accessible to the authenticated user",
{},
async () => {
const result = await apiV1Fetch(
"/api/v1/databases",
{ method: "GET" },
apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
},
);
server.tool(
"get_database",
"Get details for a specific database",
{id: z.string().describe("Database ID")},
async ({id}) => {
const result = await apiV1Fetch(`/api/v1/databases/${id}`, {method: "GET"}, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_database",
"Get details for a specific database",
{ id: z.string().describe("Database ID") },
async ({ id }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${id}`,
{ method: "GET" },
apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
},
);
server.tool(
"get_database_status",
"Get the current status of a database, including latest backup and restoration state",
{id: z.string().describe("Database ID")},
async ({id}) => {
const result = await apiV1Fetch(
`/api/v1/databases/${id}/status`,
{method: "GET"},
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_database_status",
"Get the current status of a database, including latest backup and restoration state",
{ id: z.string().describe("Database ID") },
async ({ id }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${id}/status`,
{ method: "GET" },
apiKey,
);
return result.ok ? ok(result.data) : err(result.error);
},
);
}
+8 -8
View File
@@ -1,12 +1,12 @@
export function ok(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
};
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
};
}
export function err(message: string) {
return {
content: [{ type: "text" as const, text: message }],
isError: true as const,
};
}
return {
content: [{ type: "text" as const, text: message }],
isError: true as const,
};
}
+18 -18
View File
@@ -1,29 +1,29 @@
import {createSafeActionClient} from "next-safe-action";
import {currentUser} from "@/lib/auth/current-user";
import { createSafeActionClient } from "next-safe-action";
import { currentUser } from "@/lib/auth/current-user";
export class ActionError extends Error {
constructor(message: string) {
super(message);
this.name = "ActionError";
}
constructor(message: string) {
super(message);
this.name = "ActionError";
}
}
const handleReturnedServerError = (error: Error) => {
if (error instanceof ActionError) {
return error.message;
} else {
return "An unexpected error occurred.";
}
if (error instanceof ActionError) {
return error.message;
} else {
return "An unexpected error occurred.";
}
};
export const action = createSafeActionClient({
handleServerError: handleReturnedServerError,
handleServerError: handleReturnedServerError,
});
export const userAction = action.use(async ({next, ctx}) => {
const user = await currentUser();
if (!user) {
throw new ActionError("You must be logged in");
}
return next({ctx: {user}});
export const userAction = action.use(async ({ next }) => {
const user = await currentUser();
if (!user) {
throw new ActionError("You must be logged in");
}
return next({ ctx: { user } });
});
+40 -34
View File
@@ -1,45 +1,51 @@
import {db} from "@/db";
import {eq, lt, and, desc, isNull} from "drizzle-orm";
import { db } from "@/db";
import { eq, lt, and, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
import {logger} from "@/lib/logger";
import { deleteBackupCronAction } from "@/lib/tasks/database/utils/delete";
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) {
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({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
lt(drizzleDb.schemas.backup.createdAt, cutoff),
isNull(drizzleDb.schemas.backup.deletedAt)
),
const expiredBackups = await db.query.backup.findMany({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
lt(drizzleDb.schemas.backup.createdAt, cutoff),
isNull(drizzleDb.schemas.backup.deletedAt),
),
with: {
database: {
with: {
database: {
with: {
project: true
}
}
}
project: true,
},
},
},
});
for (const backup of expiredBackups) {
const result = await deleteBackupCronAction({
backupId: backup.id,
databaseId: backup.databaseId,
});
for (const backup of expiredBackups) {
const result = await deleteBackupCronAction({
backupId: backup.id,
databaseId: backup.databaseId,
});
const inner = result?.data;
if (inner?.success) {
log.info({ name: "enforceRetentionDays"}, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`);
} else {
log.info({ name: "enforceRetentionDays"}, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`);
}
const inner = result?.data;
if (inner?.success) {
log.info(
{ name: "enforceRetentionDays" },
`(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`,
);
} 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 {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns";
import {eq, desc, isNull, and} from "drizzle-orm";
import { db } from "@/db";
import {
subDays,
subWeeks,
subMonths,
subYears,
startOfWeek,
startOfMonth,
startOfYear,
} from "date-fns";
import { eq, desc, isNull, and } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
import {logger} from "@/lib/logger";
import { deleteBackupCronAction } from "@/lib/tasks/database/utils/delete";
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;
weekly: number;
monthly: 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({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
orderBy: desc(drizzleDb.schemas.backup.createdAt),
const backups = await db.query.backup.findMany({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
isNull(drizzleDb.schemas.backup.deletedAt),
),
orderBy: desc(drizzleDb.schemas.backup.createdAt),
with: {
database: {
with: {
database: {
with: {
project: true
}
}
}
});
project: true,
},
},
},
});
const now = new Date();
const toKeep: Set<string> = new Set();
const now = new Date();
const toKeep: Set<string> = new Set();
// DAILY
backups.forEach((b) => {
if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id);
});
backups.forEach((b) => {
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}));
weekStartDates.forEach((weekStart) => {
const backupOfWeek = backups.find(
(b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1)
const weekStartDates = Array.from({ length: gfsSettings.weekly }, (_, i) =>
startOfWeek(subWeeks(now, i), { weekStartsOn: 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);
});
// MONTHLY
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)
} else {
log.info(
{ name: "enforceRetentionGFS" },
`(databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`,
);
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"
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> {
const masterKey = await getMasterServerKeyContent()