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

@@ -160,14 +160,27 @@ app.use(async (ctx, next) => {
"DELETE /api/v1/companies/:id": "Delete company",
},
deals: {
"GET /api/v1/deals": "List deals",
"GET /api/v1/deals/pipeline": "Get pipeline view",
"GET /api/v1/deals": "List deals with filters",
"GET /api/v1/deals/stats": "Deal statistics",
"GET /api/v1/deals/forecast": "Sales forecast",
"GET /api/v1/deals/pipeline/:pipelineId": "Kanban board view",
"GET /api/v1/deals/:id": "Get deal",
"POST /api/v1/deals": "Create deal",
"PUT /api/v1/deals/:id": "Update deal",
"POST /api/v1/deals/:id/move": "Move to stage",
"POST /api/v1/deals/:id/won": "Mark as won",
"POST /api/v1/deals/:id/lost": "Mark as lost",
"POST /api/v1/deals/:id/reopen": "Reopen closed deal",
"DELETE /api/v1/deals/:id": "Delete deal",
},
pipelines: {
"GET /api/v1/pipelines": "List pipelines",
"GET /api/v1/pipelines/default": "Get/create default pipeline",
"GET /api/v1/pipelines/:id": "Get pipeline",
"POST /api/v1/pipelines": "Create pipeline",
"PUT /api/v1/pipelines/:id": "Update pipeline",
"PUT /api/v1/pipelines/:id/stages": "Update stages",
"DELETE /api/v1/pipelines/:id": "Delete pipeline",
},
activities: {
"GET /api/v1/activities": "List activities",

501
src/repositories/deal.ts Normal file
View File

@@ -0,0 +1,501 @@
import { query, queryOne, execute } from "../db/connection.ts";
// ============================================
// DEAL REPOSITORY
// ============================================
export interface Deal {
id: string;
org_id: string;
pipeline_id: string;
contact_id?: string;
company_id?: string;
title: string;
value?: number;
currency: string;
stage_id: string;
probability?: number;
expected_close_date?: Date;
actual_close_date?: Date;
status: "open" | "won" | "lost";
lost_reason?: string;
notes?: string;
custom_fields: Record<string, unknown>;
owner_id?: string;
created_by?: string;
created_at: Date;
updated_at: Date;
deleted_at?: Date;
}
export interface DealWithRelations extends Deal {
contact_name?: string;
contact_email?: string;
company_name?: string;
owner_name?: string;
stage_name?: string;
}
export interface DealFilters {
pipelineId?: string;
stageId?: string;
status?: "open" | "won" | "lost";
ownerId?: string;
contactId?: string;
companyId?: string;
minValue?: number;
maxValue?: number;
expectedCloseBefore?: Date;
expectedCloseAfter?: Date;
}
export interface PaginationOptions {
page: number;
limit: number;
sortBy?: string;
sortOrder?: "asc" | "desc";
}
/**
* List deals with filters and pagination
*/
export async function findAll(
orgId: string,
filters: DealFilters = {},
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<{ deals: DealWithRelations[]; total: number }> {
const { page, limit, sortBy = "created_at", sortOrder = "desc" } = pagination;
const offset = (page - 1) * limit;
const conditions: string[] = ["d.org_id = $1", "d.deleted_at IS NULL"];
const params: unknown[] = [orgId];
let paramIndex = 2;
if (filters.pipelineId) {
conditions.push(`d.pipeline_id = $${paramIndex}`);
params.push(filters.pipelineId);
paramIndex++;
}
if (filters.stageId) {
conditions.push(`d.stage_id = $${paramIndex}`);
params.push(filters.stageId);
paramIndex++;
}
if (filters.status) {
conditions.push(`d.status = $${paramIndex}`);
params.push(filters.status);
paramIndex++;
}
if (filters.ownerId) {
conditions.push(`d.owner_id = $${paramIndex}`);
params.push(filters.ownerId);
paramIndex++;
}
if (filters.contactId) {
conditions.push(`d.contact_id = $${paramIndex}`);
params.push(filters.contactId);
paramIndex++;
}
if (filters.companyId) {
conditions.push(`d.company_id = $${paramIndex}`);
params.push(filters.companyId);
paramIndex++;
}
if (filters.minValue !== undefined) {
conditions.push(`d.value >= $${paramIndex}`);
params.push(filters.minValue);
paramIndex++;
}
if (filters.maxValue !== undefined) {
conditions.push(`d.value <= $${paramIndex}`);
params.push(filters.maxValue);
paramIndex++;
}
if (filters.expectedCloseBefore) {
conditions.push(`d.expected_close_date <= $${paramIndex}`);
params.push(filters.expectedCloseBefore);
paramIndex++;
}
if (filters.expectedCloseAfter) {
conditions.push(`d.expected_close_date >= $${paramIndex}`);
params.push(filters.expectedCloseAfter);
paramIndex++;
}
const whereClause = conditions.join(" AND ");
// Whitelist sortBy
const allowedSorts = ["created_at", "updated_at", "value", "expected_close_date", "title"];
const safeSortBy = allowedSorts.includes(sortBy) ? `d.${sortBy}` : "d.created_at";
const safeSortOrder = sortOrder === "asc" ? "ASC" : "DESC";
// Get total count
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM deals d WHERE ${whereClause}`,
params
);
const total = parseInt(countResult?.count || "0");
// Get deals with relations
const deals = await query<DealWithRelations>(
`SELECT
d.*,
c.first_name || ' ' || c.last_name as contact_name,
c.email as contact_email,
co.name as company_name,
u.first_name || ' ' || u.last_name as owner_name
FROM deals d
LEFT JOIN contacts c ON c.id = d.contact_id
LEFT JOIN companies co ON co.id = d.company_id
LEFT JOIN users u ON u.id = d.owner_id
WHERE ${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return { deals, total };
}
/**
* Get pipeline view (deals grouped by stage)
*/
export async function getPipelineView(
orgId: string,
pipelineId: string
): Promise<Map<string, DealWithRelations[]>> {
const deals = await query<DealWithRelations>(
`SELECT
d.*,
c.first_name || ' ' || c.last_name as contact_name,
c.email as contact_email,
co.name as company_name,
u.first_name || ' ' || u.last_name as owner_name
FROM deals d
LEFT JOIN contacts c ON c.id = d.contact_id
LEFT JOIN companies co ON co.id = d.company_id
LEFT JOIN users u ON u.id = d.owner_id
WHERE d.org_id = $1 AND d.pipeline_id = $2 AND d.status = 'open' AND d.deleted_at IS NULL
ORDER BY d.created_at ASC`,
[orgId, pipelineId]
);
// Group by stage_id
const grouped = new Map<string, DealWithRelations[]>();
for (const deal of deals) {
const stageDeals = grouped.get(deal.stage_id) || [];
stageDeals.push(deal);
grouped.set(deal.stage_id, stageDeals);
}
return grouped;
}
/**
* Find deal by ID
*/
export async function findById(orgId: string, dealId: string): Promise<DealWithRelations | null> {
const rows = await query<DealWithRelations>(
`SELECT
d.*,
c.first_name || ' ' || c.last_name as contact_name,
c.email as contact_email,
co.name as company_name,
u.first_name || ' ' || u.last_name as owner_name
FROM deals d
LEFT JOIN contacts c ON c.id = d.contact_id
LEFT JOIN companies co ON co.id = d.company_id
LEFT JOIN users u ON u.id = d.owner_id
WHERE d.id = $1 AND d.org_id = $2 AND d.deleted_at IS NULL`,
[dealId, orgId]
);
return rows[0] || null;
}
/**
* Create a new deal
*/
export async function create(data: {
orgId: string;
pipelineId: string;
title: string;
stageId: string;
contactId?: string;
companyId?: string;
value?: number;
currency?: string;
probability?: number;
expectedCloseDate?: Date;
notes?: string;
customFields?: Record<string, unknown>;
ownerId?: string;
createdBy?: string;
}): Promise<Deal> {
const rows = await query<Deal>(
`INSERT INTO deals (
org_id, pipeline_id, title, stage_id, contact_id, company_id,
value, currency, probability, expected_close_date, notes,
custom_fields, owner_id, created_by, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'open')
RETURNING *`,
[
data.orgId,
data.pipelineId,
data.title,
data.stageId,
data.contactId || null,
data.companyId || null,
data.value || null,
data.currency || "EUR",
data.probability || null,
data.expectedCloseDate || null,
data.notes || null,
JSON.stringify(data.customFields || {}),
data.ownerId || null,
data.createdBy || null,
]
);
return rows[0];
}
/**
* Update deal
*/
export async function update(
orgId: string,
dealId: string,
data: Partial<{
title: string;
contactId: string | null;
companyId: string | null;
value: number;
currency: string;
probability: number;
expectedCloseDate: Date | null;
notes: string;
customFields: Record<string, unknown>;
ownerId: string | null;
}>
): Promise<Deal | null> {
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
const fieldMap: Record<string, string> = {
title: "title",
contactId: "contact_id",
companyId: "company_id",
value: "value",
currency: "currency",
probability: "probability",
expectedCloseDate: "expected_close_date",
notes: "notes",
customFields: "custom_fields",
ownerId: "owner_id",
};
for (const [key, dbField] of Object.entries(fieldMap)) {
if (key in data) {
const value = data[key as keyof typeof data];
if (key === "customFields") {
updates.push(`${dbField} = $${paramIndex}::jsonb`);
params.push(JSON.stringify(value));
} else {
updates.push(`${dbField} = $${paramIndex}`);
params.push(value);
}
paramIndex++;
}
}
if (updates.length === 0) {
return await findById(orgId, dealId) as Deal | null;
}
params.push(dealId, orgId);
const rows = await query<Deal>(
`UPDATE deals SET ${updates.join(", ")}
WHERE id = $${paramIndex} AND org_id = $${paramIndex + 1} AND deleted_at IS NULL
RETURNING *`,
params
);
return rows[0] || null;
}
/**
* Move deal to a different stage
*/
export async function moveToStage(
orgId: string,
dealId: string,
stageId: string,
probability?: number
): Promise<Deal | null> {
const rows = await query<Deal>(
`UPDATE deals
SET stage_id = $1, probability = COALESCE($2, probability)
WHERE id = $3 AND org_id = $4 AND deleted_at IS NULL
RETURNING *`,
[stageId, probability || null, dealId, orgId]
);
return rows[0] || null;
}
/**
* Mark deal as won
*/
export async function markWon(orgId: string, dealId: string): Promise<Deal | null> {
const rows = await query<Deal>(
`UPDATE deals
SET status = 'won', actual_close_date = NOW(), probability = 100
WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL
RETURNING *`,
[dealId, orgId]
);
return rows[0] || null;
}
/**
* Mark deal as lost
*/
export async function markLost(
orgId: string,
dealId: string,
reason?: string
): Promise<Deal | null> {
const rows = await query<Deal>(
`UPDATE deals
SET status = 'lost', actual_close_date = NOW(), probability = 0, lost_reason = $1
WHERE id = $2 AND org_id = $3 AND deleted_at IS NULL
RETURNING *`,
[reason || null, dealId, orgId]
);
return rows[0] || null;
}
/**
* Reopen a closed deal
*/
export async function reopen(orgId: string, dealId: string): Promise<Deal | null> {
const rows = await query<Deal>(
`UPDATE deals
SET status = 'open', actual_close_date = NULL, lost_reason = NULL
WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL
RETURNING *`,
[dealId, orgId]
);
return rows[0] || null;
}
/**
* Soft delete deal
*/
export async function softDelete(orgId: string, dealId: string): Promise<boolean> {
const count = await execute(
`UPDATE deals SET deleted_at = NOW() WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`,
[dealId, orgId]
);
return count > 0;
}
/**
* Get deal statistics
*/
export async function getStats(orgId: string, pipelineId?: string): Promise<{
totalOpen: number;
totalWon: number;
totalLost: number;
openValue: number;
wonValue: number;
avgDealSize: number;
winRate: number;
}> {
const pipelineCondition = pipelineId ? `AND pipeline_id = $2` : "";
const params = pipelineId ? [orgId, pipelineId] : [orgId];
const result = await queryOne<{
total_open: string;
total_won: string;
total_lost: string;
open_value: string;
won_value: string;
avg_deal_size: string;
}>(
`SELECT
COUNT(*) FILTER (WHERE status = 'open') as total_open,
COUNT(*) FILTER (WHERE status = 'won') as total_won,
COUNT(*) FILTER (WHERE status = 'lost') as total_lost,
COALESCE(SUM(value) FILTER (WHERE status = 'open'), 0) as open_value,
COALESCE(SUM(value) FILTER (WHERE status = 'won'), 0) as won_value,
COALESCE(AVG(value) FILTER (WHERE status = 'won'), 0) as avg_deal_size
FROM deals
WHERE org_id = $1 AND deleted_at IS NULL ${pipelineCondition}`,
params
);
const totalWon = parseInt(result?.total_won || "0");
const totalLost = parseInt(result?.total_lost || "0");
const winRate = totalWon + totalLost > 0
? (totalWon / (totalWon + totalLost)) * 100
: 0;
return {
totalOpen: parseInt(result?.total_open || "0"),
totalWon,
totalLost,
openValue: parseFloat(result?.open_value || "0"),
wonValue: parseFloat(result?.won_value || "0"),
avgDealSize: parseFloat(result?.avg_deal_size || "0"),
winRate: Math.round(winRate * 10) / 10,
};
}
/**
* Get forecast (weighted pipeline value)
*/
export async function getForecast(orgId: string, months: number = 3): Promise<{
month: string;
expectedValue: number;
weightedValue: number;
dealCount: number;
}[]> {
const rows = await query<{
month: string;
expected_value: string;
weighted_value: string;
deal_count: string;
}>(
`SELECT
TO_CHAR(expected_close_date, 'YYYY-MM') as month,
COALESCE(SUM(value), 0) as expected_value,
COALESCE(SUM(value * probability / 100), 0) as weighted_value,
COUNT(*) as deal_count
FROM deals
WHERE org_id = $1
AND status = 'open'
AND deleted_at IS NULL
AND expected_close_date >= CURRENT_DATE
AND expected_close_date < CURRENT_DATE + INTERVAL '${months} months'
GROUP BY TO_CHAR(expected_close_date, 'YYYY-MM')
ORDER BY month`,
[orgId]
);
return rows.map(r => ({
month: r.month,
expectedValue: parseFloat(r.expected_value),
weightedValue: parseFloat(r.weighted_value),
dealCount: parseInt(r.deal_count),
}));
}

View File

@@ -0,0 +1,212 @@
import { query, queryOne, execute } from "../db/connection.ts";
// ============================================
// PIPELINE REPOSITORY
// ============================================
export interface PipelineStage {
id: string;
name: string;
order: number;
probability: number;
color: string;
}
export interface Pipeline {
id: string;
org_id: string;
name: string;
is_default: boolean;
stages: PipelineStage[];
created_at: Date;
updated_at: Date;
deleted_at?: Date;
}
export interface PipelineWithStats extends Pipeline {
deal_count: number;
total_value: number;
}
/**
* List all pipelines for organization
*/
export async function findAll(orgId: string): Promise<PipelineWithStats[]> {
return await query<PipelineWithStats>(
`SELECT
p.*,
COALESCE(d.deal_count, 0)::int as deal_count,
COALESCE(d.total_value, 0)::numeric as total_value
FROM pipelines p
LEFT JOIN (
SELECT pipeline_id, COUNT(*) as deal_count, SUM(value) as total_value
FROM deals
WHERE deleted_at IS NULL AND status = 'open'
GROUP BY pipeline_id
) d ON d.pipeline_id = p.id
WHERE p.org_id = $1 AND p.deleted_at IS NULL
ORDER BY p.is_default DESC, p.name ASC`,
[orgId]
);
}
/**
* Find pipeline by ID
*/
export async function findById(orgId: string, pipelineId: string): Promise<Pipeline | null> {
return await queryOne<Pipeline>(
`SELECT * FROM pipelines WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`,
[pipelineId, orgId]
);
}
/**
* Get default pipeline for organization
*/
export async function getDefault(orgId: string): Promise<Pipeline | null> {
return await queryOne<Pipeline>(
`SELECT * FROM pipelines WHERE org_id = $1 AND is_default = TRUE AND deleted_at IS NULL`,
[orgId]
);
}
/**
* Create a new pipeline
*/
export async function create(data: {
orgId: string;
name: string;
isDefault?: boolean;
stages?: PipelineStage[];
}): Promise<Pipeline> {
// If this is default, unset other defaults
if (data.isDefault) {
await execute(
`UPDATE pipelines SET is_default = FALSE WHERE org_id = $1`,
[data.orgId]
);
}
// Default stages if not provided
const stages = data.stages || getDefaultStages();
const rows = await query<Pipeline>(
`INSERT INTO pipelines (org_id, name, is_default, stages)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[data.orgId, data.name, data.isDefault || false, JSON.stringify(stages)]
);
return rows[0];
}
/**
* Update pipeline
*/
export async function update(
orgId: string,
pipelineId: string,
data: Partial<{ name: string; isDefault: boolean }>
): Promise<Pipeline | null> {
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(data.name);
paramIndex++;
}
if (data.isDefault !== undefined) {
// Unset other defaults first
if (data.isDefault) {
await execute(
`UPDATE pipelines SET is_default = FALSE WHERE org_id = $1`,
[orgId]
);
}
updates.push(`is_default = $${paramIndex}`);
params.push(data.isDefault);
paramIndex++;
}
if (updates.length === 0) {
return await findById(orgId, pipelineId);
}
params.push(pipelineId, orgId);
const rows = await query<Pipeline>(
`UPDATE pipelines SET ${updates.join(", ")}
WHERE id = $${paramIndex} AND org_id = $${paramIndex + 1} AND deleted_at IS NULL
RETURNING *`,
params
);
return rows[0] || null;
}
/**
* Update pipeline stages
*/
export async function updateStages(
orgId: string,
pipelineId: string,
stages: PipelineStage[]
): Promise<Pipeline | null> {
const rows = await query<Pipeline>(
`UPDATE pipelines SET stages = $1
WHERE id = $2 AND org_id = $3 AND deleted_at IS NULL
RETURNING *`,
[JSON.stringify(stages), pipelineId, orgId]
);
return rows[0] || null;
}
/**
* Delete pipeline (soft delete)
*/
export async function softDelete(orgId: string, pipelineId: string): Promise<boolean> {
// Check if pipeline has deals
const dealCount = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM deals WHERE pipeline_id = $1 AND deleted_at IS NULL`,
[pipelineId]
);
if (parseInt(dealCount?.count || "0") > 0) {
throw new Error("Cannot delete pipeline with active deals");
}
const count = await execute(
`UPDATE pipelines SET deleted_at = NOW() WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`,
[pipelineId, orgId]
);
return count > 0;
}
/**
* Create default pipeline for new organization
*/
export async function createDefaultForOrg(orgId: string): Promise<Pipeline> {
return await create({
orgId,
name: "Sales Pipeline",
isDefault: true,
stages: getDefaultStages(),
});
}
/**
* Get default stage configuration
*/
function getDefaultStages(): PipelineStage[] {
return [
{ id: crypto.randomUUID(), name: "Lead", order: 1, probability: 10, color: "#94a3b8" },
{ id: crypto.randomUUID(), name: "Qualifiziert", order: 2, probability: 25, color: "#60a5fa" },
{ id: crypto.randomUUID(), name: "Angebot", order: 3, probability: 50, color: "#c084fc" },
{ id: crypto.randomUUID(), name: "Verhandlung", order: 4, probability: 75, color: "#fb923c" },
{ id: crypto.randomUUID(), name: "Abschluss", order: 5, probability: 100, color: "#4ade80" },
];
}
export { getDefaultStages };

View File

@@ -1,218 +1,403 @@
import { Router } from "@oak/oak";
import { z } from "zod";
import * as dealRepo from "../repositories/deal.ts";
import * as pipelineRepo from "../repositories/pipeline.ts";
import { requireAuth } from "../middleware/auth.ts";
import type { AuthState } from "../types/index.ts";
const router = new Router({ prefix: "/api/v1/deals" });
const router = new Router<AuthState>({ prefix: "/api/v1/deals" });
// ============================================
// VALIDATION SCHEMAS
// ============================================
const createDealSchema = z.object({
title: z.string().min(1).max(200),
pipelineId: z.string().uuid(),
stageId: z.string().uuid(),
contactId: z.string().uuid().optional().nullable(),
companyId: z.string().uuid().optional().nullable(),
value: z.number().min(0).optional().nullable(),
currency: z.string().length(3).default("EUR"),
probability: z.number().int().min(0).max(100).optional().nullable(),
expectedCloseDate: z.string().datetime().optional().nullable(),
notes: z.string().optional().nullable(),
customFields: z.record(z.unknown()).optional().default({}),
ownerId: z.string().uuid().optional().nullable(),
});
const updateDealSchema = z.object({
title: z.string().min(1).max(200).optional(),
contactId: z.string().uuid().optional().nullable(),
companyId: z.string().uuid().optional().nullable(),
value: z.number().min(0).optional().nullable(),
currency: z.string().length(3).optional(),
probability: z.number().int().min(0).max(100).optional().nullable(),
expectedCloseDate: z.string().datetime().optional().nullable(),
notes: z.string().optional().nullable(),
customFields: z.record(z.unknown()).optional(),
ownerId: z.string().uuid().optional().nullable(),
});
const moveDealSchema = z.object({
stageId: z.string().uuid(),
probability: z.number().int().min(0).max(100).optional(),
});
const lostReasonSchema = z.object({
reason: z.string().max(255).optional(),
});
const listQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
pipelineId: z.string().uuid().optional(),
stageId: z.string().uuid().optional(),
status: z.enum(["open", "won", "lost"]).optional(),
ownerId: z.string().uuid().optional(),
contactId: z.string().uuid().optional(),
companyId: z.string().uuid().optional(),
minValue: z.coerce.number().optional(),
maxValue: z.coerce.number().optional(),
sortBy: z.enum(["created_at", "updated_at", "value", "expected_close_date", "title"]).default("created_at"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
// ============================================
// ROUTES
// ============================================
// GET /api/v1/deals - List deals
router.get("/", async (ctx) => {
const query = ctx.request.url.searchParams;
const page = parseInt(query.get("page") || "1");
const limit = parseInt(query.get("limit") || "20");
const pipelineId = query.get("pipelineId");
const stageId = query.get("stageId");
const status = query.get("status"); // open, won, lost
const ownerId = query.get("ownerId");
router.get("/", requireAuth, async (ctx) => {
const queryParams = Object.fromEntries(ctx.request.url.searchParams);
const result = listQuerySchema.safeParse(queryParams);
if (!result.success) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid query parameters" },
};
return;
}
const { page, limit, sortBy, sortOrder, ...filters } = result.data;
const { deals, total } = await dealRepo.findAll(
ctx.state.orgId,
filters,
{ page, limit, sortBy, sortOrder }
);
ctx.response.body = {
success: true,
data: [
{
id: "deal-1",
title: "TechStart CRM Implementation",
value: 25000,
currency: "EUR",
stage: { id: "proposal", name: "Angebot" },
status: "open",
probability: 50,
expectedCloseDate: "2026-03-15",
contact: { id: "contact-1", name: "Sarah Müller" },
company: { id: "comp-1", name: "TechStart GmbH" },
owner: { id: "user-1", name: "Max Mustermann" },
createdAt: "2026-01-20T10:00:00Z",
data: deals.map(formatDeal),
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
],
meta: { page, limit, total: 45, totalPages: 3 },
};
});
// GET /api/v1/deals/pipeline - Get deals grouped by stage (Kanban)
router.get("/pipeline", async (ctx) => {
const pipelineId = ctx.request.url.searchParams.get("pipelineId");
// GET /api/v1/deals/stats - Get deal statistics
router.get("/stats", requireAuth, async (ctx) => {
const pipelineId = ctx.request.url.searchParams.get("pipelineId") || undefined;
const stats = await dealRepo.getStats(ctx.state.orgId, pipelineId);
ctx.response.body = {
success: true,
data: stats,
};
});
// GET /api/v1/deals/forecast - Get sales forecast
router.get("/forecast", requireAuth, async (ctx) => {
const months = parseInt(ctx.request.url.searchParams.get("months") || "3");
const forecast = await dealRepo.getForecast(ctx.state.orgId, Math.min(months, 12));
ctx.response.body = {
success: true,
data: forecast,
};
});
// GET /api/v1/deals/pipeline/:pipelineId - Get pipeline view (Kanban)
router.get("/pipeline/:pipelineId", requireAuth, async (ctx) => {
const pipeline = await pipelineRepo.findById(ctx.state.orgId, ctx.params.pipelineId);
if (!pipeline) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Pipeline not found" },
};
return;
}
const dealsByStage = await dealRepo.getPipelineView(ctx.state.orgId, ctx.params.pipelineId);
// Parse stages
const stages = typeof pipeline.stages === "string"
? JSON.parse(pipeline.stages)
: pipeline.stages;
// Build response with stages and their deals
const pipelineView = stages
.sort((a: pipelineRepo.PipelineStage, b: pipelineRepo.PipelineStage) => a.order - b.order)
.map((stage: pipelineRepo.PipelineStage) => {
const stageDeals = dealsByStage.get(stage.id) || [];
return {
...stage,
deals: stageDeals.map(formatDeal),
totalValue: stageDeals.reduce((sum, d) => sum + (d.value || 0), 0),
dealCount: stageDeals.length,
};
});
ctx.response.body = {
success: true,
data: {
pipeline: {
id: "pipeline-1",
name: "Sales Pipeline",
},
stages: [
{
id: "lead",
name: "Lead",
deals: [{ id: "deal-2", title: "New Lead", value: 10000 }],
totalValue: 10000,
count: 1,
},
{
id: "qualified",
name: "Qualifiziert",
deals: [{ id: "deal-3", title: "DataFlow", value: 15000 }],
totalValue: 15000,
count: 1,
},
{
id: "proposal",
name: "Angebot",
deals: [{ id: "deal-1", title: "TechStart", value: 25000 }],
totalValue: 25000,
count: 1,
},
{
id: "negotiation",
name: "Verhandlung",
deals: [{ id: "deal-4", title: "ScaleUp", value: 50000 }],
totalValue: 50000,
count: 1,
},
],
summary: {
totalValue: 100000,
weightedValue: 47500, // Based on probability
totalDeals: 4,
id: pipeline.id,
name: pipeline.name,
},
stages: pipelineView,
},
};
});
// GET /api/v1/deals/:id - Get single deal
router.get("/:id", async (ctx) => {
const { id } = ctx.params;
router.get("/:id", requireAuth, async (ctx) => {
const deal = await dealRepo.findById(ctx.state.orgId, ctx.params.id);
if (!deal) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Deal not found" },
};
return;
}
ctx.response.body = {
success: true,
data: {
id,
title: "TechStart CRM Implementation",
value: 25000,
currency: "EUR",
pipeline: { id: "pipeline-1", name: "Sales Pipeline" },
stage: { id: "proposal", name: "Angebot", probability: 50 },
status: "open",
expectedCloseDate: "2026-03-15",
contact: {
id: "contact-1",
firstName: "Sarah",
lastName: "Müller",
email: "sarah@techstart.de",
},
company: {
id: "comp-1",
name: "TechStart GmbH",
},
owner: {
id: "user-1",
firstName: "Max",
lastName: "Mustermann",
},
tags: ["Enterprise"],
customFields: {},
createdAt: "2026-01-20T10:00:00Z",
updatedAt: "2026-02-05T09:00:00Z",
},
data: formatDeal(deal),
};
});
// POST /api/v1/deals - Create deal
router.post("/", async (ctx) => {
router.post("/", requireAuth, async (ctx) => {
const body = await ctx.request.body.json();
const result = createDealSchema.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;
// Verify pipeline exists and stage is valid
const pipeline = await pipelineRepo.findById(ctx.state.orgId, data.pipelineId);
if (!pipeline) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
error: { code: "INVALID_PIPELINE", message: "Pipeline not found" },
};
return;
}
const stages = typeof pipeline.stages === "string"
? JSON.parse(pipeline.stages)
: pipeline.stages;
const validStage = stages.find((s: pipelineRepo.PipelineStage) => s.id === data.stageId);
if (!validStage) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
error: { code: "INVALID_STAGE", message: "Stage not found in pipeline" },
};
return;
}
const deal = await dealRepo.create({
orgId: ctx.state.orgId,
pipelineId: data.pipelineId,
title: data.title,
stageId: data.stageId,
contactId: data.contactId || undefined,
companyId: data.companyId || undefined,
value: data.value || undefined,
currency: data.currency,
probability: data.probability ?? validStage.probability,
expectedCloseDate: data.expectedCloseDate ? new Date(data.expectedCloseDate) : undefined,
notes: data.notes || undefined,
customFields: data.customFields,
ownerId: data.ownerId || ctx.state.user.id,
createdBy: ctx.state.user.id,
});
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Deal created",
data: {
id: "new-deal-uuid",
...body,
createdAt: new Date().toISOString(),
},
data: formatDeal(deal as dealRepo.DealWithRelations),
};
});
// PUT /api/v1/deals/:id - Update deal
router.put("/:id", async (ctx) => {
const { id } = ctx.params;
router.put("/:id", requireAuth, async (ctx) => {
const body = await ctx.request.body.json();
const result = updateDealSchema.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 existing = await dealRepo.findById(ctx.state.orgId, ctx.params.id);
if (!existing) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Deal not found" },
};
return;
}
const data = result.data;
const deal = await dealRepo.update(ctx.state.orgId, ctx.params.id, {
...data,
expectedCloseDate: data.expectedCloseDate ? new Date(data.expectedCloseDate) : undefined,
});
ctx.response.body = {
success: true,
message: "Deal updated",
data: {
id,
...body,
updatedAt: new Date().toISOString(),
},
data: formatDeal(deal as dealRepo.DealWithRelations),
};
});
// POST /api/v1/deals/:id/move - Move deal to different stage
router.post("/:id/move", async (ctx) => {
const { id } = ctx.params;
router.post("/:id/move", requireAuth, async (ctx) => {
const body = await ctx.request.body.json();
const { stageId } = body;
const result = moveDealSchema.safeParse(body);
// TODO: Implement stage move
// 1. Validate stage exists in pipeline
// 2. Update deal
// 3. Log activity
// 4. Trigger webhooks
if (!result.success) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid input" },
};
return;
}
const deal = await dealRepo.moveToStage(
ctx.state.orgId,
ctx.params.id,
result.data.stageId,
result.data.probability
);
if (!deal) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Deal not found" },
};
return;
}
ctx.response.body = {
success: true,
message: "Deal moved",
data: {
id,
stageId,
updatedAt: new Date().toISOString(),
},
data: formatDeal(deal as dealRepo.DealWithRelations),
};
});
// POST /api/v1/deals/:id/won - Mark deal as won
router.post("/:id/won", async (ctx) => {
const { id } = ctx.params;
router.post("/:id/won", requireAuth, async (ctx) => {
const deal = await dealRepo.markWon(ctx.state.orgId, ctx.params.id);
if (!deal) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Deal not found" },
};
return;
}
ctx.response.body = {
success: true,
message: "Deal marked as won",
data: {
id,
status: "won",
actualCloseDate: new Date().toISOString(),
},
data: formatDeal(deal as dealRepo.DealWithRelations),
message: "Deal marked as won! 🎉",
};
});
// POST /api/v1/deals/:id/lost - Mark deal as lost
router.post("/:id/lost", async (ctx) => {
const { id } = ctx.params;
const body = await ctx.request.body.json();
const { reason } = body;
router.post("/:id/lost", requireAuth, async (ctx) => {
const body = await ctx.request.body.json().catch(() => ({}));
const result = lostReasonSchema.safeParse(body);
const reason = result.success ? result.data.reason : undefined;
const deal = await dealRepo.markLost(ctx.state.orgId, ctx.params.id, reason);
if (!deal) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Deal not found" },
};
return;
}
ctx.response.body = {
success: true,
message: "Deal marked as lost",
data: {
id,
status: "lost",
lostReason: reason,
actualCloseDate: new Date().toISOString(),
},
data: formatDeal(deal as dealRepo.DealWithRelations),
};
});
// POST /api/v1/deals/:id/reopen - Reopen a closed deal
router.post("/:id/reopen", requireAuth, async (ctx) => {
const deal = await dealRepo.reopen(ctx.state.orgId, ctx.params.id);
if (!deal) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Deal not found" },
};
return;
}
ctx.response.body = {
success: true,
data: formatDeal(deal as dealRepo.DealWithRelations),
};
});
// DELETE /api/v1/deals/:id - Delete deal
router.delete("/:id", async (ctx) => {
const { id } = ctx.params;
router.delete("/:id", requireAuth, async (ctx) => {
const deleted = await dealRepo.softDelete(ctx.state.orgId, ctx.params.id);
if (!deleted) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Deal not found" },
};
return;
}
ctx.response.body = {
success: true,
@@ -220,44 +405,43 @@ router.delete("/:id", async (ctx) => {
};
});
// GET /api/v1/deals/:id/activities - Get deal activities
router.get("/:id/activities", async (ctx) => {
const { id } = ctx.params;
// ============================================
// HELPER FUNCTIONS
// ============================================
ctx.response.body = {
success: true,
data: [
{
id: "act-1",
type: "note",
subject: "Anforderungen besprochen",
createdAt: "2026-02-01T10:00:00Z",
},
],
function formatDeal(deal: dealRepo.DealWithRelations) {
return {
id: deal.id,
title: deal.title,
pipelineId: deal.pipeline_id,
stageId: deal.stage_id,
stageName: deal.stage_name,
value: deal.value,
currency: deal.currency,
probability: deal.probability,
status: deal.status,
expectedCloseDate: deal.expected_close_date,
actualCloseDate: deal.actual_close_date,
lostReason: deal.lost_reason,
contact: deal.contact_id ? {
id: deal.contact_id,
name: deal.contact_name,
email: deal.contact_email,
} : null,
company: deal.company_id ? {
id: deal.company_id,
name: deal.company_name,
} : null,
owner: deal.owner_id ? {
id: deal.owner_id,
name: deal.owner_name,
} : null,
notes: deal.notes,
customFields: deal.custom_fields,
createdBy: deal.created_by,
createdAt: deal.created_at,
updatedAt: deal.updated_at,
};
});
// GET /api/v1/deals/forecast - Sales forecast
router.get("/forecast", async (ctx) => {
ctx.response.body = {
success: true,
data: {
currentMonth: {
expected: 75000,
weighted: 35000,
won: 15000,
},
nextMonth: {
expected: 50000,
weighted: 20000,
},
quarter: {
expected: 200000,
weighted: 95000,
won: 45000,
},
},
};
});
}
export { router as dealsRouter };

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
router.delete("/:id", async (ctx) => {
const { id } = ctx.params;
// 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);
// TODO: Check if pipeline has deals
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 };