Files
pulse-crm-backend/src/routes/pipelines.ts
Flux_bot 085b83e429 feat(deals): Pipeline & Deal Management implementiert
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
2026-02-11 11:08:29 +00:00

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 };