From aa9dbf37a77e9d3e65e5f101357260628478ae87 Mon Sep 17 00:00:00 2001 From: Grishma Dhakal Date: Sun, 26 Jul 2026 11:19:33 +0545 Subject: [PATCH 1/2] feat(project): add project JSON export and import procedures --- src/server/api/routers/project.test.ts | 123 ++++++++++++++ src/server/api/routers/project.ts | 222 +++++++++++++++++++++++++ 2 files changed, 345 insertions(+) diff --git a/src/server/api/routers/project.test.ts b/src/server/api/routers/project.test.ts index 7938382..e296368 100644 --- a/src/server/api/routers/project.test.ts +++ b/src/server/api/routers/project.test.ts @@ -125,4 +125,127 @@ describe("projectRouter", () => { expect(deleteProjectData).toHaveBeenCalledWith(id); }); }); + + describe("exportProject and importProject", () => { + beforeEach(() => { + ctx.db.run(` + CREATE TABLE IF NOT EXISTS voidend_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL, + parentId TEXT, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); + ctx.db.run(` + CREATE TABLE IF NOT EXISTS voidend_endpoints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT NOT NULL, + folderId TEXT, + method TEXT NOT NULL DEFAULT 'GET', + path TEXT NOT NULL, + statusCode INTEGER NOT NULL DEFAULT 200, + responseHeaders TEXT DEFAULT '{}', + delayMs INTEGER NOT NULL DEFAULT 0, + failureRate REAL NOT NULL DEFAULT 0, + responseSchema TEXT NOT NULL DEFAULT '{}', + responseCount INTEGER NOT NULL DEFAULT 1, + errorSchema TEXT DEFAULT NULL, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); + ctx.db.run(` + CREATE TABLE IF NOT EXISTS voidend_auth_configs ( + id TEXT PRIMARY KEY, + endpointId TEXT NOT NULL, + isLoginEndpoint INTEGER NOT NULL DEFAULT 0, + requiresAuth INTEGER NOT NULL DEFAULT 0, + tokenExpirySeconds INTEGER NOT NULL DEFAULT 3600, + createdAt INTEGER NOT NULL DEFAULT (unixepoch()), + updatedAt INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); + }); + + it("exports and imports a full project graph with folders, endpoints, schemas, and auth configs", async () => { + const proj = await caller.create({ + title: "Original Project", + description: "Test description", + basePath: "/api/v1", + }); + + const parentFolderId = crypto.randomUUID(); + await ctx.db.insert(schema.folders_table).values({ + id: parentFolderId, + name: "Parent Folder", + projectId: proj!.id, + }); + + const childFolderId = crypto.randomUUID(); + await ctx.db.insert(schema.folders_table).values({ + id: childFolderId, + name: "Child Folder", + projectId: proj!.id, + parentId: parentFolderId, + }); + + const endpointId = crypto.randomUUID(); + await ctx.db.insert(schema.endpoints_table).values({ + id: endpointId, + name: "Get Users", + projectId: proj!.id, + folderId: childFolderId, + method: "GET", + path: "/users", + statusCode: 200, + responseSchema: { type: "array", items: { type: "string" } }, + responseCount: 5, + }); + + await ctx.db.insert(schema.auth_configs_table).values({ + endpointId: endpointId, + isLoginEndpoint: false, + requiresAuth: true, + tokenExpirySeconds: 1800, + }); + + const exportedData = await caller.exportProject({ id: proj!.id }); + + expect(exportedData.version).toBe("1.0"); + expect(exportedData.project.title).toBe("Original Project"); + expect(exportedData.folders).toHaveLength(2); + expect(exportedData.endpoints).toHaveLength(1); + expect(exportedData.endpoints[0]?.authConfig?.requiresAuth).toBe(true); + + const importResult = await caller.importProject(exportedData); + + expect(importResult.projectId).not.toBe(proj!.id); + expect(importResult.foldersCreated).toBe(2); + expect(importResult.endpointsCreated).toBe(1); + + const importedProj = await caller.getById({ id: importResult.projectId }); + expect(importedProj?.title).toBe("Original Project"); + expect(importedProj?.basePath).toBe("/api/v1"); + + const importedFolders = await ctx.db.query.folders_table.findMany({ + where: (f, { eq }) => eq(f.projectId, importResult.projectId), + }); + expect(importedFolders).toHaveLength(2); + + const importedEndpoints = await ctx.db.query.endpoints_table.findMany({ + where: (e, { eq }) => eq(e.projectId, importResult.projectId), + }); + expect(importedEndpoints).toHaveLength(1); + expect(importedEndpoints[0]?.name).toBe("Get Users"); + + const importedAuthConfig = await ctx.db.query.auth_configs_table.findFirst({ + where: (a, { eq }) => eq(a.endpointId, importedEndpoints[0]!.id), + }); + expect(importedAuthConfig?.requiresAuth).toBe(true); + expect(importedAuthConfig?.tokenExpirySeconds).toBe(1800); + }); + }); }); diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index dd94553..5b4ef7e 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -61,4 +61,226 @@ export const projectRouter = createTRPCRouter({ deleteProjectData(input.id); await ctx.db.delete(projects_table).where(eq(projects_table.id, input.id)); }), + + exportProject: publicProcedure + .input(z.object({ id: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const { TRPCError } = await import("@trpc/server"); + + const project = await ctx.db.query.projects_table.findFirst({ + where: (p, { eq }) => eq(p.id, input.id), + }); + + if (!project) { + throw new TRPCError({ code: "NOT_FOUND", message: "Project not found." }); + } + + const folders = await ctx.db.query.folders_table.findMany({ + where: (f, { eq }) => eq(f.projectId, input.id), + orderBy: (f, { asc }) => [asc(f.createdAt)], + }); + + const endpoints = await ctx.db.query.endpoints_table.findMany({ + where: (e, { eq }) => eq(e.projectId, input.id), + orderBy: (e, { asc }) => [asc(e.createdAt)], + }); + + const endpointIds = endpoints.map((e) => e.id); + const authConfigs = + endpointIds.length > 0 + ? await ctx.db.query.auth_configs_table.findMany({ + where: (a, { inArray }) => inArray(a.endpointId, endpointIds), + }) + : []; + const authConfigMap = new Map(authConfigs.map((a) => [a.endpointId, a])); + + return { + version: "1.0" as const, + exportedAt: new Date().toISOString(), + project: { + title: project.title, + description: project.description ?? null, + basePath: project.basePath, + secret: project.secret, + }, + folders: folders.map((f) => ({ + _ref: f.id, + name: f.name, + parentRef: f.parentId ?? null, + })), + endpoints: endpoints.map((e) => { + const auth = authConfigMap.get(e.id); + return { + _ref: e.id, + name: e.name, + folderRef: e.folderId ?? null, + method: e.method, + path: e.path, + statusCode: e.statusCode, + responseHeaders: (e.responseHeaders as Record | null) ?? {}, + delayMs: e.delayMs, + failureRate: e.failureRate, + responseSchema: e.responseSchema as Record, + responseCount: e.responseCount, + errorSchema: (e.errorSchema as Record | null) ?? null, + authConfig: auth + ? { + isLoginEndpoint: auth.isLoginEndpoint, + requiresAuth: auth.requiresAuth, + tokenExpirySeconds: auth.tokenExpirySeconds, + } + : null, + }; + }), + }; + }), + + importProject: publicProcedure + .input( + z.object({ + version: z.literal("1.0"), + exportedAt: z.string(), + project: z.object({ + title: z.string().min(1), + description: z.string().nullable().optional(), + basePath: z.string().default("/"), + secret: z.string().optional(), + }), + folders: z.array( + z.object({ + _ref: z.string(), + name: z.string().min(1), + parentRef: z.string().nullable(), + }), + ), + endpoints: z.array( + z.object({ + _ref: z.string(), + name: z.string().min(1), + folderRef: z.string().nullable(), + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), + path: z.string().min(1), + statusCode: z.number().int().min(100).max(599).default(200), + responseHeaders: z.record(z.string(), z.string()).optional(), + delayMs: z.number().int().min(0).default(0), + failureRate: z.number().min(0).max(1).default(0), + responseSchema: z.record(z.string(), z.unknown()).default({}), + responseCount: z.number().int().min(1).max(100).default(1), + errorSchema: z.record(z.string(), z.unknown()).nullable().optional(), + authConfig: z + .object({ + isLoginEndpoint: z.boolean().default(false), + requiresAuth: z.boolean().default(false), + tokenExpirySeconds: z.number().int().min(1).default(3600), + }) + .nullable() + .optional(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const { folders_table, endpoints_table, auth_configs_table } = + await import("~/server/db/schema"); + const { generateAndSaveData } = await import("~/lib/endpoint-data-store"); + + const [project] = await ctx.db + .insert(projects_table) + .values({ + title: input.project.title, + description: input.project.description ?? undefined, + basePath: input.project.basePath, + secret: input.project.secret, + }) + .returning(); + + if (!project) throw new Error("Failed to create project during import."); + + const folderRefToId = new Map(); + + const folderMap = new Map(input.folders.map((f) => [f._ref, f])); + const visited = new Set(); + const sorted: typeof input.folders = []; + + function visitFolder(ref: string) { + if (visited.has(ref)) return; + visited.add(ref); + const folder = folderMap.get(ref); + if (!folder) return; + if (folder.parentRef) visitFolder(folder.parentRef); + sorted.push(folder); + } + + for (const f of input.folders) visitFolder(f._ref); + + for (const folder of sorted) { + const parentId = folder.parentRef + ? (folderRefToId.get(folder.parentRef) ?? null) + : null; + + // eslint-disable-next-line no-await-in-loop -- sequential: child folders need parent id from prior iteration + const [created] = await ctx.db + .insert(folders_table) + .values({ + name: folder.name, + projectId: project.id, + parentId: parentId ?? undefined, + }) + .returning(); + + if (created) folderRefToId.set(folder._ref, created.id); + } + + const endpointRefToId = new Map(); + + for (const ep of input.endpoints) { + const folderId = ep.folderRef ? (folderRefToId.get(ep.folderRef) ?? null) : null; + + // eslint-disable-next-line no-await-in-loop -- sequential: child folders need parent id from prior iteration + const [created] = await ctx.db + .insert(endpoints_table) + .values({ + name: ep.name, + projectId: project.id, + folderId: folderId ?? undefined, + method: ep.method, + path: ep.path, + statusCode: ep.statusCode, + responseHeaders: ep.responseHeaders ?? {}, + delayMs: ep.delayMs, + failureRate: ep.failureRate, + responseSchema: ep.responseSchema, + responseCount: ep.responseCount, + errorSchema: ep.errorSchema ?? null, + }) + .returning(); + + if (!created) continue; + + endpointRefToId.set(ep._ref, created.id); + + generateAndSaveData( + project.id, + created.id, + created.responseSchema, + created.responseCount ?? 1, + ); + + if (ep.authConfig) { + // eslint-disable-next-line no-await-in-loop -- depends on created.id from this iteration + await ctx.db.insert(auth_configs_table).values({ + endpointId: created.id, + isLoginEndpoint: ep.authConfig.isLoginEndpoint, + requiresAuth: ep.authConfig.requiresAuth, + tokenExpirySeconds: ep.authConfig.tokenExpirySeconds, + }); + } + } + + return { + projectId: project.id, + foldersCreated: folderRefToId.size, + endpointsCreated: endpointRefToId.size, + }; + }), }); From 5d6585cf90bc570541eaad688680cc19e84b959e Mon Sep 17 00:00:00 2001 From: Grishma Dhakal Date: Sun, 26 Jul 2026 11:24:00 +0545 Subject: [PATCH 2/2] feat(project): add export and import feature with ui integration --- src/hooks/use-projects.ts | 15 + .../ui/components/sidebar/dialogs.tsx | 86 +++++ .../sidebar/project-settings-dialog.tsx | 330 +++++++++++------- .../ui/components/sidebar/sidebar-tree.tsx | 3 + .../ui/components/sidebar/tree-node-menu.tsx | 8 + .../workspace/ui/components/sidebar/types.ts | 1 + .../components/sidebar/workspace-sidebar.tsx | 80 ++++- .../workspace/ui/layouts/workspace-layout.tsx | 11 +- 8 files changed, 403 insertions(+), 131 deletions(-) diff --git a/src/hooks/use-projects.ts b/src/hooks/use-projects.ts index 7e2a772..fb60798 100644 --- a/src/hooks/use-projects.ts +++ b/src/hooks/use-projects.ts @@ -52,3 +52,18 @@ export function useDeleteProject() { }, }); } + +export function useImportProject() { + const utils = api.useUtils(); + return api.project.importProject.useMutation({ + onSuccess: (data) => { + invalidateProjectQueries(utils); + toast.success( + `Project imported successfully (${data.foldersCreated} folders, ${data.endpointsCreated} endpoints)`, + ); + }, + onError: (error) => { + toast.error(error.message || "Failed to import project"); + }, + }); +} diff --git a/src/modules/workspace/ui/components/sidebar/dialogs.tsx b/src/modules/workspace/ui/components/sidebar/dialogs.tsx index d38259f..ec88b49 100644 --- a/src/modules/workspace/ui/components/sidebar/dialogs.tsx +++ b/src/modules/workspace/ui/components/sidebar/dialogs.tsx @@ -227,3 +227,89 @@ export function CreateEndpointDialog({ ); } + +export function ImportProjectDialog({ + open, + onOpenChange, + onImport, + isImporting, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onImport: (data: unknown) => void; + isImporting: boolean; +}) { + const [jsonContent, setJsonContent] = useState(""); + const [error, setError] = useState(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.addEventListener("load", (event) => { + const content = event.target?.result as string; + setJsonContent(content); + setError(null); + }); + reader.readAsText(file); + }; + + const handleSubmit = () => { + try { + setError(null); + const parsed = JSON.parse(jsonContent); + onImport(parsed); + } catch { + setError("Invalid JSON format. Please select or paste a valid exported project JSON."); + } + }; + + return ( + + + + Import Project + + +
+
+ + +
+ +
+ +