Pipelines: - CRUD mit Stage-Verwaltung - Default Pipeline auto-create - Konfigurierbare Stages (Name, Order, Probability, Color) - Deal-Count & Value Stats Deals: - CRUD mit Filtering & Pagination - Kanban Board View (grouped by stage) - Move between stages - Mark Won/Lost/Reopen - Sales Forecast (weighted pipeline) - Statistics (win rate, avg deal size) - Contact & Company Relations Task: #10 Pipeline & Deal Management
245 lines
6.7 KiB
TypeScript
245 lines
6.7 KiB
TypeScript
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<AuthState>({ 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 };
|