From 085b83e429f09663d183742e7c90d6d9cdf90141 Mon Sep 17 00:00:00 2001 From: Flux_bot Date: Wed, 11 Feb 2026 11:08:29 +0000 Subject: [PATCH] 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 --- src/main.ts | 17 +- src/repositories/deal.ts | 501 +++++++++++++++++++++++++++++++ src/repositories/pipeline.ts | 212 ++++++++++++++ src/routes/deals.ts | 552 +++++++++++++++++++++++------------ src/routes/pipelines.ts | 275 ++++++++++++----- 5 files changed, 1301 insertions(+), 256 deletions(-) create mode 100644 src/repositories/deal.ts create mode 100644 src/repositories/pipeline.ts diff --git a/src/main.ts b/src/main.ts index 9086d83..ffc93d1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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", diff --git a/src/repositories/deal.ts b/src/repositories/deal.ts new file mode 100644 index 0000000..8522b31 --- /dev/null +++ b/src/repositories/deal.ts @@ -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; + 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( + `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> { + const deals = await query( + `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(); + 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 { + const rows = await query( + `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; + ownerId?: string; + createdBy?: string; +}): Promise { + const rows = await query( + `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; + ownerId: string | null; + }> +): Promise { + const updates: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + const fieldMap: Record = { + 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( + `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 { + const rows = await query( + `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 { + const rows = await query( + `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 { + const rows = await query( + `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 { + const rows = await query( + `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 { + 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), + })); +} diff --git a/src/repositories/pipeline.ts b/src/repositories/pipeline.ts new file mode 100644 index 0000000..6d12334 --- /dev/null +++ b/src/repositories/pipeline.ts @@ -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 { + return await query( + `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 { + return await queryOne( + `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 { + return await queryOne( + `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 { + // 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( + `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 { + 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( + `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 { + const rows = await query( + `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 { + // 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 { + 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 }; diff --git a/src/routes/deals.ts b/src/routes/deals.ts index 546e958..74b4ced 100644 --- a/src/routes/deals.ts +++ b/src/routes/deals.ts @@ -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({ 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", - }, - ], - meta: { page, limit, total: 45, totalPages: 3 }, + data: deals.map(formatDeal), + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, }; }); -// 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 }; diff --git a/src/routes/pipelines.ts b/src/routes/pipelines.ts index 58e180a..8b2c0e9 100644 --- a/src/routes/pipelines.ts +++ b/src/routes/pipelines.ts @@ -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({ 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 };