feat: Backend REST API Grundstruktur
🔐 Auth Routes: - POST /register, /login, /refresh, /logout - GET /me 👥 Contacts Routes: - CRUD + /activities, /deals - /import, /export (DSGVO Art. 20) 💰 Deals Routes: - CRUD + /pipeline (Kanban View) - /move, /won, /lost - /forecast 📝 Activities Routes: - CRUD + /upcoming - /complete 📊 Pipelines Routes: - CRUD + /stages ✨ Features: - CORS Middleware - Error Handler - Request Logger - API Documentation Endpoint
This commit is contained in:
135
src/main.ts
135
src/main.ts
@@ -1,14 +1,28 @@
|
|||||||
import { Application } from "@oak/oak";
|
import { Application } from "@oak/oak";
|
||||||
import "@std/dotenv/load";
|
import "@std/dotenv/load";
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
import { authRouter } from "./routes/auth.ts";
|
||||||
|
import { contactsRouter } from "./routes/contacts.ts";
|
||||||
|
import { dealsRouter } from "./routes/deals.ts";
|
||||||
|
import { activitiesRouter } from "./routes/activities.ts";
|
||||||
|
import { pipelinesRouter } from "./routes/pipelines.ts";
|
||||||
|
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MIDDLEWARE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
// CORS Middleware
|
// CORS Middleware
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (ctx, next) => {
|
||||||
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
|
const origin = ctx.request.headers.get("origin") || "*";
|
||||||
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||||
|
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH");
|
||||||
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||||
|
ctx.response.headers.set("Access-Control-Max-Age", "86400");
|
||||||
|
|
||||||
if (ctx.request.method === "OPTIONS") {
|
if (ctx.request.method === "OPTIONS") {
|
||||||
ctx.response.status = 204;
|
ctx.response.status = 204;
|
||||||
@@ -23,9 +37,34 @@ app.use(async (ctx, next) => {
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
await next();
|
await next();
|
||||||
const ms = Date.now() - start;
|
const ms = Date.now() - start;
|
||||||
console.log(`${ctx.request.method} ${ctx.request.url.pathname} - ${ctx.response.status} (${ms}ms)`);
|
const status = ctx.response.status;
|
||||||
|
const statusColor = status >= 500 ? "\x1b[31m" : status >= 400 ? "\x1b[33m" : "\x1b[32m";
|
||||||
|
console.log(`${statusColor}${status}\x1b[0m ${ctx.request.method} ${ctx.request.url.pathname} - ${ms}ms`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Error Handler
|
||||||
|
app.use(async (ctx, next) => {
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error:", err);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "INTERNAL_ERROR",
|
||||||
|
message: Deno.env.get("NODE_ENV") === "development"
|
||||||
|
? err.message
|
||||||
|
: "An internal error occurred",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SYSTEM ROUTES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
// Health Check
|
// Health Check
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (ctx, next) => {
|
||||||
if (ctx.request.url.pathname === "/health") {
|
if (ctx.request.url.pathname === "/health") {
|
||||||
@@ -33,7 +72,7 @@ app.use(async (ctx, next) => {
|
|||||||
status: "ok",
|
status: "ok",
|
||||||
service: "pulse-crm-backend",
|
service: "pulse-crm-backend",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -46,36 +85,94 @@ app.use(async (ctx, next) => {
|
|||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
name: "Pulse CRM API",
|
name: "Pulse CRM API",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
docs: "/api/v1/docs",
|
description: "Der Herzschlag deines Business",
|
||||||
endpoints: {
|
endpoints: {
|
||||||
auth: "/api/v1/auth/*",
|
auth: {
|
||||||
contacts: "/api/v1/contacts/*",
|
"POST /api/v1/auth/register": "Register new user",
|
||||||
companies: "/api/v1/companies/*",
|
"POST /api/v1/auth/login": "Login",
|
||||||
deals: "/api/v1/deals/*",
|
"POST /api/v1/auth/refresh": "Refresh token",
|
||||||
pipelines: "/api/v1/pipelines/*",
|
"POST /api/v1/auth/logout": "Logout",
|
||||||
activities: "/api/v1/activities/*",
|
"GET /api/v1/auth/me": "Get current user",
|
||||||
users: "/api/v1/users/*"
|
},
|
||||||
}
|
contacts: {
|
||||||
|
"GET /api/v1/contacts": "List contacts",
|
||||||
|
"GET /api/v1/contacts/:id": "Get contact",
|
||||||
|
"POST /api/v1/contacts": "Create contact",
|
||||||
|
"PUT /api/v1/contacts/:id": "Update contact",
|
||||||
|
"DELETE /api/v1/contacts/:id": "Delete contact",
|
||||||
|
},
|
||||||
|
deals: {
|
||||||
|
"GET /api/v1/deals": "List deals",
|
||||||
|
"GET /api/v1/deals/pipeline": "Get pipeline 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",
|
||||||
|
},
|
||||||
|
activities: {
|
||||||
|
"GET /api/v1/activities": "List activities",
|
||||||
|
"GET /api/v1/activities/upcoming": "Upcoming tasks",
|
||||||
|
"POST /api/v1/activities": "Create activity",
|
||||||
|
"POST /api/v1/activities/:id/complete": "Complete task",
|
||||||
|
},
|
||||||
|
pipelines: {
|
||||||
|
"GET /api/v1/pipelines": "List pipelines",
|
||||||
|
"POST /api/v1/pipelines": "Create pipeline",
|
||||||
|
"PUT /api/v1/pipelines/:id/stages": "Update stages",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 404 Handler
|
// ============================================
|
||||||
|
// API ROUTES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.use(authRouter.routes());
|
||||||
|
app.use(authRouter.allowedMethods());
|
||||||
|
|
||||||
|
app.use(contactsRouter.routes());
|
||||||
|
app.use(contactsRouter.allowedMethods());
|
||||||
|
|
||||||
|
app.use(dealsRouter.routes());
|
||||||
|
app.use(dealsRouter.allowedMethods());
|
||||||
|
|
||||||
|
app.use(activitiesRouter.routes());
|
||||||
|
app.use(activitiesRouter.allowedMethods());
|
||||||
|
|
||||||
|
app.use(pipelinesRouter.routes());
|
||||||
|
app.use(pipelinesRouter.allowedMethods());
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 404 HANDLER
|
||||||
|
// ============================================
|
||||||
|
|
||||||
app.use((ctx) => {
|
app.use((ctx) => {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Endpoint not found"
|
message: `Endpoint ${ctx.request.method} ${ctx.request.url.pathname} not found`,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`🚀 Pulse CRM Backend starting on port ${PORT}...`);
|
// ============================================
|
||||||
console.log(`📚 API Docs: http://localhost:${PORT}/api/v1`);
|
// START SERVER
|
||||||
console.log(`❤️ Health: http://localhost:${PORT}/health`);
|
// ============================================
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log(" 🫀 Pulse CRM Backend");
|
||||||
|
console.log(" ====================");
|
||||||
|
console.log(` 📡 Server: http://localhost:${PORT}`);
|
||||||
|
console.log(` 📚 API: http://localhost:${PORT}/api/v1`);
|
||||||
|
console.log(` ❤️ Health: http://localhost:${PORT}/health`);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
await app.listen({ port: PORT });
|
await app.listen({ port: PORT });
|
||||||
|
|||||||
153
src/routes/activities.ts
Normal file
153
src/routes/activities.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/v1/activities" });
|
||||||
|
|
||||||
|
// GET /api/v1/activities - List activities
|
||||||
|
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 type = query.get("type"); // note, call, email, meeting, task
|
||||||
|
const contactId = query.get("contactId");
|
||||||
|
const dealId = query.get("dealId");
|
||||||
|
const userId = query.get("userId");
|
||||||
|
const completed = query.get("completed");
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "act-1",
|
||||||
|
type: "call",
|
||||||
|
subject: "Erstgespräch mit TechStart",
|
||||||
|
description: "Sehr interessiert an CRM Lösung",
|
||||||
|
contact: { id: "c-1", name: "Sarah Müller" },
|
||||||
|
deal: { id: "d-1", title: "TechStart CRM" },
|
||||||
|
user: { id: "u-1", name: "Max Mustermann" },
|
||||||
|
isCompleted: true,
|
||||||
|
completedAt: "2026-02-01T15:00:00Z",
|
||||||
|
createdAt: "2026-02-01T14:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-2",
|
||||||
|
type: "task",
|
||||||
|
subject: "Angebot erstellen",
|
||||||
|
dueDate: "2026-02-12T17:00:00Z",
|
||||||
|
contact: { id: "c-2", name: "Michael Fischer" },
|
||||||
|
deal: { id: "d-3", title: "ScaleUp Deal" },
|
||||||
|
user: { id: "u-1", name: "Max Mustermann" },
|
||||||
|
isCompleted: false,
|
||||||
|
createdAt: "2026-02-10T09:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { page, limit, total: 25 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/activities/upcoming - Upcoming tasks & meetings
|
||||||
|
router.get("/upcoming", async (ctx) => {
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
today: [
|
||||||
|
{
|
||||||
|
id: "act-2",
|
||||||
|
type: "task",
|
||||||
|
subject: "Angebot erstellen",
|
||||||
|
dueDate: "2026-02-11T17:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
thisWeek: [
|
||||||
|
{
|
||||||
|
id: "act-3",
|
||||||
|
type: "meeting",
|
||||||
|
subject: "Demo Präsentation",
|
||||||
|
dueDate: "2026-02-15T14:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
overdue: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/activities/:id
|
||||||
|
router.get("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
type: "call",
|
||||||
|
subject: "Erstgespräch",
|
||||||
|
description: "Details...",
|
||||||
|
durationMinutes: 30,
|
||||||
|
callOutcome: "successful",
|
||||||
|
contact: { id: "c-1", firstName: "Sarah", lastName: "Müller" },
|
||||||
|
deal: { id: "d-1", title: "TechStart CRM" },
|
||||||
|
user: { id: "u-1", firstName: "Max", lastName: "Mustermann" },
|
||||||
|
isCompleted: true,
|
||||||
|
completedAt: "2026-02-01T15:00:00Z",
|
||||||
|
createdAt: "2026-02-01T14:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/activities - Create activity
|
||||||
|
router.post("/", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Activity created",
|
||||||
|
data: {
|
||||||
|
id: "new-act-uuid",
|
||||||
|
...body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/v1/activities/:id - Update activity
|
||||||
|
router.put("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Activity updated",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/activities/:id/complete - Mark as completed
|
||||||
|
router.post("/:id/complete", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Activity completed",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
isCompleted: true,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/activities/:id
|
||||||
|
router.delete("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Activity deleted",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export { router as activitiesRouter };
|
||||||
155
src/routes/auth.ts
Normal file
155
src/routes/auth.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/v1/auth" });
|
||||||
|
|
||||||
|
// POST /api/v1/auth/register
|
||||||
|
router.post("/register", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { email, password, firstName, lastName, orgName } = body;
|
||||||
|
|
||||||
|
// TODO: Implement registration
|
||||||
|
// 1. Validate input (Zod)
|
||||||
|
// 2. Check if email exists
|
||||||
|
// 3. Hash password (Argon2)
|
||||||
|
// 4. Create organization
|
||||||
|
// 5. Create user
|
||||||
|
// 6. Send verification email
|
||||||
|
// 7. Return tokens
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Registration successful",
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: "uuid",
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
},
|
||||||
|
organization: {
|
||||||
|
id: "uuid",
|
||||||
|
name: orgName,
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
accessToken: "jwt_access_token",
|
||||||
|
refreshToken: "jwt_refresh_token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/auth/login
|
||||||
|
router.post("/login", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { email, password } = body;
|
||||||
|
|
||||||
|
// TODO: Implement login
|
||||||
|
// 1. Find user by email
|
||||||
|
// 2. Verify password (Argon2)
|
||||||
|
// 3. Generate tokens
|
||||||
|
// 4. Log login (audit)
|
||||||
|
// 5. Return user + tokens
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Login successful",
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: "uuid",
|
||||||
|
email,
|
||||||
|
firstName: "Max",
|
||||||
|
lastName: "Mustermann",
|
||||||
|
role: "admin",
|
||||||
|
orgId: "org_uuid",
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
accessToken: "jwt_access_token",
|
||||||
|
refreshToken: "jwt_refresh_token",
|
||||||
|
expiresIn: 900, // 15 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/auth/refresh
|
||||||
|
router.post("/refresh", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { refreshToken } = body;
|
||||||
|
|
||||||
|
// TODO: Implement token refresh
|
||||||
|
// 1. Validate refresh token
|
||||||
|
// 2. Check if revoked
|
||||||
|
// 3. Generate new access token
|
||||||
|
// 4. Optionally rotate refresh token
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accessToken: "new_jwt_access_token",
|
||||||
|
expiresIn: 900,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/auth/logout
|
||||||
|
router.post("/logout", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { refreshToken } = body;
|
||||||
|
|
||||||
|
// TODO: Revoke refresh token
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Logged out successfully",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/auth/forgot-password
|
||||||
|
router.post("/forgot-password", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { email } = body;
|
||||||
|
|
||||||
|
// TODO: Send password reset email
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "If the email exists, a reset link has been sent",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/auth/reset-password
|
||||||
|
router.post("/reset-password", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { token, password } = body;
|
||||||
|
|
||||||
|
// TODO: Reset password
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Password reset successful",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/auth/me
|
||||||
|
router.get("/me", async (ctx) => {
|
||||||
|
// TODO: Get current user from JWT
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: "uuid",
|
||||||
|
email: "user@example.com",
|
||||||
|
firstName: "Max",
|
||||||
|
lastName: "Mustermann",
|
||||||
|
role: "admin",
|
||||||
|
organization: {
|
||||||
|
id: "org_uuid",
|
||||||
|
name: "Demo Company",
|
||||||
|
plan: "pro",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export { router as authRouter };
|
||||||
214
src/routes/contacts.ts
Normal file
214
src/routes/contacts.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/v1/contacts" });
|
||||||
|
|
||||||
|
// GET /api/v1/contacts - List contacts
|
||||||
|
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 search = query.get("search");
|
||||||
|
const status = query.get("status");
|
||||||
|
const companyId = query.get("companyId");
|
||||||
|
const ownerId = query.get("ownerId");
|
||||||
|
const tags = query.get("tags")?.split(",");
|
||||||
|
|
||||||
|
// TODO: Implement with database
|
||||||
|
// 1. Get org_id from JWT
|
||||||
|
// 2. Build query with filters
|
||||||
|
// 3. Paginate results
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "uuid-1",
|
||||||
|
firstName: "Sarah",
|
||||||
|
lastName: "Müller",
|
||||||
|
email: "sarah@example.com",
|
||||||
|
phone: "+49 30 123456",
|
||||||
|
company: { id: "comp-1", name: "TechStart GmbH" },
|
||||||
|
status: "active",
|
||||||
|
tags: ["VIP", "Entscheider"],
|
||||||
|
createdAt: "2026-01-15T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: 150,
|
||||||
|
totalPages: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/contacts/:id - Get single contact
|
||||||
|
router.get("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
// TODO: Fetch from database
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
firstName: "Sarah",
|
||||||
|
lastName: "Müller",
|
||||||
|
email: "sarah@example.com",
|
||||||
|
phone: "+49 30 123456",
|
||||||
|
mobile: "+49 171 123456",
|
||||||
|
jobTitle: "CEO",
|
||||||
|
company: {
|
||||||
|
id: "comp-1",
|
||||||
|
name: "TechStart GmbH",
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
line1: "Hauptstraße 1",
|
||||||
|
city: "Berlin",
|
||||||
|
postalCode: "10115",
|
||||||
|
country: "Deutschland",
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
leadSource: "Website",
|
||||||
|
leadScore: 85,
|
||||||
|
tags: ["VIP", "Entscheider"],
|
||||||
|
customFields: {},
|
||||||
|
gdprConsent: true,
|
||||||
|
gdprConsentDate: "2026-01-01T00:00:00Z",
|
||||||
|
owner: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Max Mustermann",
|
||||||
|
},
|
||||||
|
createdAt: "2026-01-15T10:00:00Z",
|
||||||
|
updatedAt: "2026-02-01T14:30:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/contacts - Create contact
|
||||||
|
router.post("/", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
// TODO: Implement
|
||||||
|
// 1. Validate input (Zod)
|
||||||
|
// 2. Check for duplicates
|
||||||
|
// 3. Create contact
|
||||||
|
// 4. Log audit
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Contact created",
|
||||||
|
data: {
|
||||||
|
id: "new-uuid",
|
||||||
|
...body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/v1/contacts/:id - Update contact
|
||||||
|
router.put("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
// TODO: Implement
|
||||||
|
// 1. Validate input
|
||||||
|
// 2. Check ownership
|
||||||
|
// 3. Update contact
|
||||||
|
// 4. Log audit (before/after)
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Contact updated",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/contacts/:id - Delete contact (soft delete)
|
||||||
|
router.delete("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
// TODO: Implement soft delete
|
||||||
|
// 1. Check ownership
|
||||||
|
// 2. Set deleted_at
|
||||||
|
// 3. Log audit
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Contact deleted",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/contacts/:id/activities - Get contact activities
|
||||||
|
router.get("/:id/activities", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "act-1",
|
||||||
|
type: "call",
|
||||||
|
subject: "Erstgespräch",
|
||||||
|
description: "Sehr interessiert",
|
||||||
|
createdAt: "2026-02-01T14:00:00Z",
|
||||||
|
user: { id: "user-1", name: "Max Mustermann" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/contacts/:id/deals - Get contact deals
|
||||||
|
router.get("/:id/deals", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "deal-1",
|
||||||
|
title: "CRM Implementation",
|
||||||
|
value: 25000,
|
||||||
|
stage: "proposal",
|
||||||
|
status: "open",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/contacts/import - Import contacts (CSV)
|
||||||
|
router.post("/import", async (ctx) => {
|
||||||
|
// TODO: Handle CSV upload
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Import started",
|
||||||
|
data: {
|
||||||
|
importId: "import-uuid",
|
||||||
|
status: "processing",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/contacts/export - Export contacts (DSGVO Art. 20)
|
||||||
|
router.get("/export", async (ctx) => {
|
||||||
|
const format = ctx.request.url.searchParams.get("format") || "json";
|
||||||
|
|
||||||
|
// TODO: Generate export
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Export ready",
|
||||||
|
data: {
|
||||||
|
downloadUrl: "/api/v1/contacts/export/download/export-uuid",
|
||||||
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export { router as contactsRouter };
|
||||||
263
src/routes/deals.ts
Normal file
263
src/routes/deals.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/v1/deals" });
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/deals/pipeline - Get deals grouped by stage (Kanban)
|
||||||
|
router.get("/pipeline", async (ctx) => {
|
||||||
|
const pipelineId = ctx.request.url.searchParams.get("pipelineId");
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/deals/:id - Get single deal
|
||||||
|
router.get("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/deals - Create deal
|
||||||
|
router.post("/", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Deal created",
|
||||||
|
data: {
|
||||||
|
id: "new-deal-uuid",
|
||||||
|
...body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/v1/deals/:id - Update deal
|
||||||
|
router.put("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Deal updated",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
...body,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/deals/:id/move - Move deal to different stage
|
||||||
|
router.post("/:id/move", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { stageId } = body;
|
||||||
|
|
||||||
|
// TODO: Implement stage move
|
||||||
|
// 1. Validate stage exists in pipeline
|
||||||
|
// 2. Update deal
|
||||||
|
// 3. Log activity
|
||||||
|
// 4. Trigger webhooks
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Deal moved",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
stageId,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/deals/:id/won - Mark deal as won
|
||||||
|
router.post("/:id/won", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Deal marked as won",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
status: "won",
|
||||||
|
actualCloseDate: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Deal marked as lost",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
status: "lost",
|
||||||
|
lostReason: reason,
|
||||||
|
actualCloseDate: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/deals/:id - Delete deal
|
||||||
|
router.delete("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Deal deleted",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/deals/:id/activities - Get deal activities
|
||||||
|
router.get("/:id/activities", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "act-1",
|
||||||
|
type: "note",
|
||||||
|
subject: "Anforderungen besprochen",
|
||||||
|
createdAt: "2026-02-01T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 };
|
||||||
109
src/routes/pipelines.ts
Normal file
109
src/routes/pipelines.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/v1/pipelines" });
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/pipelines/:id
|
||||||
|
router.get("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/pipelines - Create pipeline
|
||||||
|
router.post("/", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Pipeline created",
|
||||||
|
data: {
|
||||||
|
id: "new-pipeline-uuid",
|
||||||
|
...body,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/v1/pipelines/:id - Update pipeline
|
||||||
|
router.put("/:id", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Pipeline updated",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
...body,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/v1/pipelines/:id/stages - Update stages (reorder, add, remove)
|
||||||
|
router.put("/:id/stages", async (ctx) => {
|
||||||
|
const { id } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { stages } = body;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Stages updated",
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
stages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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