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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user