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
This commit is contained in:
2026-02-11 11:08:29 +00:00
parent 1725783404
commit 085b83e429
5 changed files with 1301 additions and 256 deletions

View File

@@ -1,109 +1,244 @@
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" });
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);
// GET /api/v1/pipelines - List pipelines
router.get("/", async (ctx) => {
ctx.response.body = {
success: true,
data: [
{
id: "pipeline-1",
name: "Sales Pipeline",
isDefault: true,
stages: [
{ id: "lead", name: "Lead", order: 1, probability: 10 },
{ id: "qualified", name: "Qualifiziert", order: 2, probability: 25 },
{ id: "proposal", name: "Angebot", order: 3, probability: 50 },
{ id: "negotiation", name: "Verhandlung", order: 4, probability: 75 },
{ id: "closed_won", name: "Gewonnen", order: 5, probability: 100 },
{ id: "closed_lost", name: "Verloren", order: 6, probability: 0 },
],
dealsCount: 15,
totalValue: 250000,
},
],
data: pipelines.map(formatPipeline),
};
});
// GET /api/v1/pipelines/:id
router.get("/:id", async (ctx) => {
const { id } = ctx.params;
// 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: {
id,
name: "Sales Pipeline",
isDefault: true,
stages: [
{ id: "lead", name: "Lead", order: 1, probability: 10 },
{ id: "qualified", name: "Qualifiziert", order: 2, probability: 25 },
{ id: "proposal", name: "Angebot", order: 3, probability: 50 },
{ id: "negotiation", name: "Verhandlung", order: 4, probability: 75 },
{ id: "closed_won", name: "Gewonnen", order: 5, probability: 100 },
{ id: "closed_lost", name: "Verloren", order: 6, probability: 0 },
],
},
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("/", async (ctx) => {
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,
message: "Pipeline created",
data: {
id: "new-pipeline-uuid",
...body,
},
data: formatPipeline(pipeline as pipelineRepo.PipelineWithStats),
};
});
// PUT /api/v1/pipelines/:id - Update pipeline
router.put("/:id", async (ctx) => {
const { id } = ctx.params;
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,
message: "Pipeline updated",
data: {
id,
...body,
},
data: formatPipeline(pipeline as pipelineRepo.PipelineWithStats),
};
});
// PUT /api/v1/pipelines/:id/stages - Update stages (reorder, add, remove)
router.put("/:id/stages", async (ctx) => {
const { id } = ctx.params;
// 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 { stages } = body;
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,
message: "Stages updated",
data: {
id,
stages,
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,
};
});
// DELETE /api/v1/pipelines/:id
router.delete("/:id", async (ctx) => {
const { id } = ctx.params;
// TODO: Check if pipeline has deals
ctx.response.body = {
success: true,
message: "Pipeline deleted",
};
});
}
export { router as pipelinesRouter };