From c9d7cbfe4489bb1506c596a4c3a64b184969b548 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Mon, 16 Feb 2026 13:31:58 -0600 Subject: [PATCH] Add API server with routes, services, and middleware Express server with CRUD routes for agents, goals, issues, projects, and activity log. Includes validation middleware, structured error handling, request logging, and health check endpoint with tests. Co-Authored-By: Claude Opus 4.6 --- server/package.json | 30 +++++++++++++++ server/src/__tests__/health.test.ts | 15 ++++++++ server/src/app.ts | 42 +++++++++++++++++++++ server/src/config.ts | 16 ++++++++ server/src/index.ts | 12 ++++++ server/src/middleware/error-handler.ts | 18 +++++++++ server/src/middleware/index.ts | 3 ++ server/src/middleware/logger.ts | 5 +++ server/src/middleware/validate.ts | 9 +++++ server/src/routes/activity.ts | 35 +++++++++++++++++ server/src/routes/agents.ts | 52 ++++++++++++++++++++++++++ server/src/routes/goals.ts | 52 ++++++++++++++++++++++++++ server/src/routes/health.ts | 11 ++++++ server/src/routes/index.ts | 6 +++ server/src/routes/issues.ts | 52 ++++++++++++++++++++++++++ server/src/routes/projects.ts | 52 ++++++++++++++++++++++++++ server/src/services/activity.ts | 42 +++++++++++++++++++++ server/src/services/agents.ts | 38 +++++++++++++++++++ server/src/services/goals.ts | 38 +++++++++++++++++++ server/src/services/index.ts | 5 +++ server/src/services/issues.ts | 38 +++++++++++++++++++ server/src/services/projects.ts | 38 +++++++++++++++++++ server/tsconfig.json | 8 ++++ server/vitest.config.ts | 7 ++++ 24 files changed, 624 insertions(+) create mode 100644 server/package.json create mode 100644 server/src/__tests__/health.test.ts create mode 100644 server/src/app.ts create mode 100644 server/src/config.ts create mode 100644 server/src/index.ts create mode 100644 server/src/middleware/error-handler.ts create mode 100644 server/src/middleware/index.ts create mode 100644 server/src/middleware/logger.ts create mode 100644 server/src/middleware/validate.ts create mode 100644 server/src/routes/activity.ts create mode 100644 server/src/routes/agents.ts create mode 100644 server/src/routes/goals.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/routes/index.ts create mode 100644 server/src/routes/issues.ts create mode 100644 server/src/routes/projects.ts create mode 100644 server/src/services/activity.ts create mode 100644 server/src/services/agents.ts create mode 100644 server/src/services/goals.ts create mode 100644 server/src/services/index.ts create mode 100644 server/src/services/issues.ts create mode 100644 server/src/services/projects.ts create mode 100644 server/tsconfig.json create mode 100644 server/vitest.config.ts diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..ee9939af --- /dev/null +++ b/server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@paperclip/server", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclip/db": "workspace:*", + "@paperclip/shared": "workspace:*", + "drizzle-orm": "^0.38.4", + "express": "^5.1.0", + "pino": "^9.6.0", + "pino-http": "^10.4.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/express-serve-static-core": "^5.0.0", + "@types/supertest": "^6.0.2", + "supertest": "^7.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts new file mode 100644 index 00000000..5583955f --- /dev/null +++ b/server/src/__tests__/health.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import express from "express"; +import request from "supertest"; +import { healthRoutes } from "../routes/health.js"; + +describe("GET /health", () => { + const app = express(); + app.use("/health", healthRoutes()); + + it("returns 200 with status ok", async () => { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "ok" }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 00000000..719fea74 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,42 @@ +import express, { Router } from "express"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Db } from "@paperclip/db"; +import { httpLogger, errorHandler } from "./middleware/index.js"; +import { healthRoutes } from "./routes/health.js"; +import { agentRoutes } from "./routes/agents.js"; +import { projectRoutes } from "./routes/projects.js"; +import { issueRoutes } from "./routes/issues.js"; +import { goalRoutes } from "./routes/goals.js"; +import { activityRoutes } from "./routes/activity.js"; + +export function createApp(db: Db, opts: { serveUi: boolean }) { + const app = express(); + + app.use(express.json()); + app.use(httpLogger); + + // Mount API routes + const api = Router(); + api.use("/health", healthRoutes()); + api.use("/agents", agentRoutes(db)); + api.use("/projects", projectRoutes(db)); + api.use("/issues", issueRoutes(db)); + api.use("/goals", goalRoutes(db)); + api.use("/activity", activityRoutes(db)); + app.use("/api", api); + + // SPA fallback for serving the UI build + if (opts.serveUi) { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const uiDist = path.resolve(__dirname, "../../ui/dist"); + app.use(express.static(uiDist)); + app.get("*", (_req, res) => { + res.sendFile(path.join(uiDist, "index.html")); + }); + } + + app.use(errorHandler); + + return app; +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 00000000..1875815d --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,16 @@ +export interface Config { + port: number; + databaseUrl: string; + serveUi: boolean; +} + +export function loadConfig(): Config { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("DATABASE_URL is required"); + + return { + port: Number(process.env.PORT) || 3100, + databaseUrl, + serveUi: process.env.SERVE_UI === "true", + }; +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 00000000..3f4cea99 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,12 @@ +import { createDb } from "@paperclip/db"; +import { createApp } from "./app.js"; +import { loadConfig } from "./config.js"; +import { logger } from "./middleware/logger.js"; + +const config = loadConfig(); +const db = createDb(config.databaseUrl); +const app = createApp(db, { serveUi: config.serveUi }); + +app.listen(config.port, () => { + logger.info(`Server listening on :${config.port}`); +}); diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts new file mode 100644 index 00000000..cb4d4fbf --- /dev/null +++ b/server/src/middleware/error-handler.ts @@ -0,0 +1,18 @@ +import type { Request, Response, NextFunction } from "express"; +import { ZodError } from "zod"; +import { logger } from "./logger.js"; + +export function errorHandler( + err: unknown, + _req: Request, + res: Response, + _next: NextFunction, +) { + if (err instanceof ZodError) { + res.status(400).json({ error: "Validation error", details: err.errors }); + return; + } + + logger.error(err, "Unhandled error"); + res.status(500).json({ error: "Internal server error" }); +} diff --git a/server/src/middleware/index.ts b/server/src/middleware/index.ts new file mode 100644 index 00000000..5988addd --- /dev/null +++ b/server/src/middleware/index.ts @@ -0,0 +1,3 @@ +export { logger, httpLogger } from "./logger.js"; +export { errorHandler } from "./error-handler.js"; +export { validate } from "./validate.js"; diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts new file mode 100644 index 00000000..5f2fbea8 --- /dev/null +++ b/server/src/middleware/logger.ts @@ -0,0 +1,5 @@ +import pino from "pino"; +import { pinoHttp } from "pino-http"; + +export const logger = pino(); +export const httpLogger = pinoHttp({ logger }); diff --git a/server/src/middleware/validate.ts b/server/src/middleware/validate.ts new file mode 100644 index 00000000..4acb53d0 --- /dev/null +++ b/server/src/middleware/validate.ts @@ -0,0 +1,9 @@ +import type { Request, Response, NextFunction } from "express"; +import type { ZodSchema } from "zod"; + +export function validate(schema: ZodSchema) { + return (req: Request, _res: Response, next: NextFunction) => { + req.body = schema.parse(req.body); + next(); + }; +} diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts new file mode 100644 index 00000000..0f614023 --- /dev/null +++ b/server/src/routes/activity.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { z } from "zod"; +import type { Db } from "@paperclip/db"; +import { validate } from "../middleware/validate.js"; +import { activityService } from "../services/activity.js"; + +const createActivitySchema = z.object({ + action: z.string().min(1), + entityType: z.string().min(1), + entityId: z.string().uuid(), + agentId: z.string().uuid().optional().nullable(), + details: z.record(z.unknown()).optional().nullable(), +}); + +export function activityRoutes(db: Db) { + const router = Router(); + const svc = activityService(db); + + router.get("/", async (req, res) => { + const filters = { + agentId: req.query.agentId as string | undefined, + entityType: req.query.entityType as string | undefined, + entityId: req.query.entityId as string | undefined, + }; + const result = await svc.list(filters); + res.json(result); + }); + + router.post("/", validate(createActivitySchema), async (req, res) => { + const event = await svc.create(req.body); + res.status(201).json(event); + }); + + return router; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts new file mode 100644 index 00000000..0d3184b9 --- /dev/null +++ b/server/src/routes/agents.ts @@ -0,0 +1,52 @@ +import { Router } from "express"; +import type { Db } from "@paperclip/db"; +import { createAgentSchema, updateAgentSchema } from "@paperclip/shared"; +import { validate } from "../middleware/validate.js"; +import { agentService } from "../services/agents.js"; + +export function agentRoutes(db: Db) { + const router = Router(); + const svc = agentService(db); + + router.get("/", async (_req, res) => { + const result = await svc.list(); + res.json(result); + }); + + router.get("/:id", async (req, res) => { + const id = req.params.id as string; + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + res.json(agent); + }); + + router.post("/", validate(createAgentSchema), async (req, res) => { + const agent = await svc.create(req.body); + res.status(201).json(agent); + }); + + router.patch("/:id", validate(updateAgentSchema), async (req, res) => { + const id = req.params.id as string; + const agent = await svc.update(id, req.body); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + res.json(agent); + }); + + router.delete("/:id", async (req, res) => { + const id = req.params.id as string; + const agent = await svc.remove(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + res.json(agent); + }); + + return router; +} diff --git a/server/src/routes/goals.ts b/server/src/routes/goals.ts new file mode 100644 index 00000000..e673235d --- /dev/null +++ b/server/src/routes/goals.ts @@ -0,0 +1,52 @@ +import { Router } from "express"; +import type { Db } from "@paperclip/db"; +import { createGoalSchema, updateGoalSchema } from "@paperclip/shared"; +import { validate } from "../middleware/validate.js"; +import { goalService } from "../services/goals.js"; + +export function goalRoutes(db: Db) { + const router = Router(); + const svc = goalService(db); + + router.get("/", async (_req, res) => { + const result = await svc.list(); + res.json(result); + }); + + router.get("/:id", async (req, res) => { + const id = req.params.id as string; + const goal = await svc.getById(id); + if (!goal) { + res.status(404).json({ error: "Goal not found" }); + return; + } + res.json(goal); + }); + + router.post("/", validate(createGoalSchema), async (req, res) => { + const goal = await svc.create(req.body); + res.status(201).json(goal); + }); + + router.patch("/:id", validate(updateGoalSchema), async (req, res) => { + const id = req.params.id as string; + const goal = await svc.update(id, req.body); + if (!goal) { + res.status(404).json({ error: "Goal not found" }); + return; + } + res.json(goal); + }); + + router.delete("/:id", async (req, res) => { + const id = req.params.id as string; + const goal = await svc.remove(id); + if (!goal) { + res.status(404).json({ error: "Goal not found" }); + return; + } + res.json(goal); + }); + + return router; +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 00000000..6a70ae2b --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; + +export function healthRoutes() { + const router = Router(); + + router.get("/", (_req, res) => { + res.json({ status: "ok" }); + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts new file mode 100644 index 00000000..a78ecd2a --- /dev/null +++ b/server/src/routes/index.ts @@ -0,0 +1,6 @@ +export { healthRoutes } from "./health.js"; +export { agentRoutes } from "./agents.js"; +export { projectRoutes } from "./projects.js"; +export { issueRoutes } from "./issues.js"; +export { goalRoutes } from "./goals.js"; +export { activityRoutes } from "./activity.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts new file mode 100644 index 00000000..04a33e9d --- /dev/null +++ b/server/src/routes/issues.ts @@ -0,0 +1,52 @@ +import { Router } from "express"; +import type { Db } from "@paperclip/db"; +import { createIssueSchema, updateIssueSchema } from "@paperclip/shared"; +import { validate } from "../middleware/validate.js"; +import { issueService } from "../services/issues.js"; + +export function issueRoutes(db: Db) { + const router = Router(); + const svc = issueService(db); + + router.get("/", async (_req, res) => { + const result = await svc.list(); + res.json(result); + }); + + router.get("/:id", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + res.json(issue); + }); + + router.post("/", validate(createIssueSchema), async (req, res) => { + const issue = await svc.create(req.body); + res.status(201).json(issue); + }); + + router.patch("/:id", validate(updateIssueSchema), async (req, res) => { + const id = req.params.id as string; + const issue = await svc.update(id, req.body); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + res.json(issue); + }); + + router.delete("/:id", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.remove(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + res.json(issue); + }); + + return router; +} diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts new file mode 100644 index 00000000..63ab47e7 --- /dev/null +++ b/server/src/routes/projects.ts @@ -0,0 +1,52 @@ +import { Router } from "express"; +import type { Db } from "@paperclip/db"; +import { createProjectSchema, updateProjectSchema } from "@paperclip/shared"; +import { validate } from "../middleware/validate.js"; +import { projectService } from "../services/projects.js"; + +export function projectRoutes(db: Db) { + const router = Router(); + const svc = projectService(db); + + router.get("/", async (_req, res) => { + const result = await svc.list(); + res.json(result); + }); + + router.get("/:id", async (req, res) => { + const id = req.params.id as string; + const project = await svc.getById(id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + res.json(project); + }); + + router.post("/", validate(createProjectSchema), async (req, res) => { + const project = await svc.create(req.body); + res.status(201).json(project); + }); + + router.patch("/:id", validate(updateProjectSchema), async (req, res) => { + const id = req.params.id as string; + const project = await svc.update(id, req.body); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + res.json(project); + }); + + router.delete("/:id", async (req, res) => { + const id = req.params.id as string; + const project = await svc.remove(id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + res.json(project); + }); + + return router; +} diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts new file mode 100644 index 00000000..4f59b652 --- /dev/null +++ b/server/src/services/activity.ts @@ -0,0 +1,42 @@ +import { eq, and, desc } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { activityLog } from "@paperclip/db"; + +export interface ActivityFilters { + agentId?: string; + entityType?: string; + entityId?: string; +} + +export function activityService(db: Db) { + return { + list: (filters?: ActivityFilters) => { + const conditions = []; + + if (filters?.agentId) { + conditions.push(eq(activityLog.agentId, filters.agentId)); + } + if (filters?.entityType) { + conditions.push(eq(activityLog.entityType, filters.entityType)); + } + if (filters?.entityId) { + conditions.push(eq(activityLog.entityId, filters.entityId)); + } + + const query = db.select().from(activityLog); + + if (conditions.length > 0) { + return query.where(and(...conditions)).orderBy(desc(activityLog.createdAt)); + } + + return query.orderBy(desc(activityLog.createdAt)); + }, + + create: (data: typeof activityLog.$inferInsert) => + db + .insert(activityLog) + .values(data) + .returning() + .then((rows) => rows[0]), + }; +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts new file mode 100644 index 00000000..ce3a0b67 --- /dev/null +++ b/server/src/services/agents.ts @@ -0,0 +1,38 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { agents } from "@paperclip/db"; + +export function agentService(db: Db) { + return { + list: () => db.select().from(agents), + + getById: (id: string) => + db + .select() + .from(agents) + .where(eq(agents.id, id)) + .then((rows) => rows[0] ?? null), + + create: (data: typeof agents.$inferInsert) => + db + .insert(agents) + .values(data) + .returning() + .then((rows) => rows[0]), + + update: (id: string, data: Partial) => + db + .update(agents) + .set({ ...data, updatedAt: new Date() }) + .where(eq(agents.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + + remove: (id: string) => + db + .delete(agents) + .where(eq(agents.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + }; +} diff --git a/server/src/services/goals.ts b/server/src/services/goals.ts new file mode 100644 index 00000000..32c5e0a0 --- /dev/null +++ b/server/src/services/goals.ts @@ -0,0 +1,38 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { goals } from "@paperclip/db"; + +export function goalService(db: Db) { + return { + list: () => db.select().from(goals), + + getById: (id: string) => + db + .select() + .from(goals) + .where(eq(goals.id, id)) + .then((rows) => rows[0] ?? null), + + create: (data: typeof goals.$inferInsert) => + db + .insert(goals) + .values(data) + .returning() + .then((rows) => rows[0]), + + update: (id: string, data: Partial) => + db + .update(goals) + .set({ ...data, updatedAt: new Date() }) + .where(eq(goals.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + + remove: (id: string) => + db + .delete(goals) + .where(eq(goals.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts new file mode 100644 index 00000000..ff6ab38e --- /dev/null +++ b/server/src/services/index.ts @@ -0,0 +1,5 @@ +export { agentService } from "./agents.js"; +export { projectService } from "./projects.js"; +export { issueService } from "./issues.js"; +export { goalService } from "./goals.js"; +export { activityService, type ActivityFilters } from "./activity.js"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts new file mode 100644 index 00000000..5a0fba53 --- /dev/null +++ b/server/src/services/issues.ts @@ -0,0 +1,38 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { issues } from "@paperclip/db"; + +export function issueService(db: Db) { + return { + list: () => db.select().from(issues), + + getById: (id: string) => + db + .select() + .from(issues) + .where(eq(issues.id, id)) + .then((rows) => rows[0] ?? null), + + create: (data: typeof issues.$inferInsert) => + db + .insert(issues) + .values(data) + .returning() + .then((rows) => rows[0]), + + update: (id: string, data: Partial) => + db + .update(issues) + .set({ ...data, updatedAt: new Date() }) + .where(eq(issues.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + + remove: (id: string) => + db + .delete(issues) + .where(eq(issues.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + }; +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts new file mode 100644 index 00000000..ff1608c3 --- /dev/null +++ b/server/src/services/projects.ts @@ -0,0 +1,38 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { projects } from "@paperclip/db"; + +export function projectService(db: Db) { + return { + list: () => db.select().from(projects), + + getById: (id: string) => + db + .select() + .from(projects) + .where(eq(projects.id, id)) + .then((rows) => rows[0] ?? null), + + create: (data: typeof projects.$inferInsert) => + db + .insert(projects) + .values(data) + .returning() + .then((rows) => rows[0]), + + update: (id: string, data: Partial) => + db + .update(projects) + .set({ ...data, updatedAt: new Date() }) + .where(eq(projects.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + + remove: (id: string) => + db + .delete(projects) + .where(eq(projects.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + }; +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..e4600622 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +});