import { Router } from "@oak/oak"; import { z } from "zod"; import * as pipelineRepo from "../repositories/pipeline.ts"; import { requireAuth, requireRole } from "../middleware/auth.ts"; import type { AuthState } from "../types/index.ts"; const router = new Router({ prefix: "/api/v1/pipelines" }); // ============================================ // VALIDATION SCHEMAS // ============================================ const stageSchema = z.object({ id: z.string().uuid(), name: z.string().min(1).max(50), order: z.number().int().min(1), probability: z.number().int().min(0).max(100), color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).default("#6366f1"), }); const createPipelineSchema = z.object({ name: z.string().min(1).max(100), isDefault: z.boolean().optional().default(false), stages: z.array(stageSchema).min(1).max(20).optional(), }); const updatePipelineSchema = z.object({ name: z.string().min(1).max(100).optional(), isDefault: z.boolean().optional(), }); const updateStagesSchema = z.object({ stages: z.array(stageSchema).min(1).max(20), }); // ============================================ // ROUTES // ============================================ // GET /api/v1/pipelines - List all pipelines router.get("/", requireAuth, async (ctx) => { const pipelines = await pipelineRepo.findAll(ctx.state.orgId); ctx.response.body = { success: true, data: pipelines.map(formatPipeline), }; }); // GET /api/v1/pipelines/default - Get default pipeline router.get("/default", requireAuth, async (ctx) => { let pipeline = await pipelineRepo.getDefault(ctx.state.orgId); // Create default pipeline if none exists if (!pipeline) { pipeline = await pipelineRepo.createDefaultForOrg(ctx.state.orgId); } ctx.response.body = { success: true, data: formatPipeline(pipeline as pipelineRepo.PipelineWithStats), }; }); // GET /api/v1/pipelines/:id - Get single pipeline router.get("/:id", requireAuth, async (ctx) => { const pipeline = await pipelineRepo.findById(ctx.state.orgId, ctx.params.id); if (!pipeline) { ctx.response.status = 404; ctx.response.body = { success: false, error: { code: "NOT_FOUND", message: "Pipeline not found" }, }; return; } ctx.response.body = { success: true, data: formatPipeline(pipeline as pipelineRepo.PipelineWithStats), }; }); // POST /api/v1/pipelines - Create pipeline router.post("/", requireAuth, requireRole("owner", "admin"), async (ctx) => { const body = await ctx.request.body.json(); const result = createPipelineSchema.safeParse(body); if (!result.success) { ctx.response.status = 400; ctx.response.body = { success: false, error: { code: "VALIDATION_ERROR", message: "Invalid input", details: result.error.errors }, }; return; } const data = result.data; // Generate IDs for stages if not provided const stages = data.stages?.map((stage, index) => ({ ...stage, id: stage.id || crypto.randomUUID(), order: stage.order || index + 1, })); const pipeline = await pipelineRepo.create({ orgId: ctx.state.orgId, name: data.name, isDefault: data.isDefault, stages, }); ctx.response.status = 201; ctx.response.body = { success: true, data: formatPipeline(pipeline as pipelineRepo.PipelineWithStats), }; }); // PUT /api/v1/pipelines/:id - Update pipeline router.put("/:id", requireAuth, requireRole("owner", "admin"), async (ctx) => { const body = await ctx.request.body.json(); const result = updatePipelineSchema.safeParse(body); if (!result.success) { ctx.response.status = 400; ctx.response.body = { success: false, error: { code: "VALIDATION_ERROR", message: "Invalid input", details: result.error.errors }, }; return; } const pipeline = await pipelineRepo.update(ctx.state.orgId, ctx.params.id, result.data); if (!pipeline) { ctx.response.status = 404; ctx.response.body = { success: false, error: { code: "NOT_FOUND", message: "Pipeline not found" }, }; return; } ctx.response.body = { success: true, data: formatPipeline(pipeline as pipelineRepo.PipelineWithStats), }; }); // PUT /api/v1/pipelines/:id/stages - Update pipeline stages router.put("/:id/stages", requireAuth, requireRole("owner", "admin"), async (ctx) => { const body = await ctx.request.body.json(); const result = updateStagesSchema.safeParse(body); if (!result.success) { ctx.response.status = 400; ctx.response.body = { success: false, error: { code: "VALIDATION_ERROR", message: "Invalid input", details: result.error.errors }, }; return; } // Ensure stages have proper order const stages = result.data.stages.map((stage, index) => ({ ...stage, order: stage.order || index + 1, })); const pipeline = await pipelineRepo.updateStages(ctx.state.orgId, ctx.params.id, stages); if (!pipeline) { ctx.response.status = 404; ctx.response.body = { success: false, error: { code: "NOT_FOUND", message: "Pipeline not found" }, }; return; } ctx.response.body = { success: true, data: formatPipeline(pipeline as pipelineRepo.PipelineWithStats), }; }); // DELETE /api/v1/pipelines/:id - Delete pipeline router.delete("/:id", requireAuth, requireRole("owner", "admin"), async (ctx) => { try { const deleted = await pipelineRepo.softDelete(ctx.state.orgId, ctx.params.id); if (!deleted) { ctx.response.status = 404; ctx.response.body = { success: false, error: { code: "NOT_FOUND", message: "Pipeline not found" }, }; return; } ctx.response.body = { success: true, message: "Pipeline deleted", }; } catch (error) { if (error.message?.includes("active deals")) { ctx.response.status = 400; ctx.response.body = { success: false, error: { code: "HAS_DEALS", message: "Cannot delete pipeline with active deals" }, }; return; } throw error; } }); // ============================================ // HELPER FUNCTIONS // ============================================ function formatPipeline(pipeline: pipelineRepo.PipelineWithStats) { // Parse stages if it's a string const stages = typeof pipeline.stages === "string" ? JSON.parse(pipeline.stages) : pipeline.stages; return { id: pipeline.id, name: pipeline.name, isDefault: pipeline.is_default, stages: stages.sort((a: pipelineRepo.PipelineStage, b: pipelineRepo.PipelineStage) => a.order - b.order), stats: { dealCount: pipeline.deal_count || 0, totalValue: pipeline.total_value || 0, }, createdAt: pipeline.created_at, updatedAt: pipeline.updated_at, }; } export { router as pipelinesRouter };