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", + }, +});