feat(contacts): Kontakt- & Firmenverwaltung implementiert

Kontakte:
- CRUD mit Soft-Delete
- Suche, Filter, Pagination
- Bulk-Import (max 1000)
- DSGVO Export & Permanent Delete
- Duplikat-Erkennung (Email)
- Tags & Custom Fields
- Marketing Consent Tracking

Firmen:
- CRUD mit Stats (Kontakte, Deals, Wert)
- Branchen-Autocomplete
- Verknüpfung mit Kontakten

Task: #9 Kontakt- & Firmenverwaltung
This commit is contained in:
2026-02-11 11:02:55 +00:00
parent 3cef9111fc
commit 1725783404
5 changed files with 1319 additions and 155 deletions

View File

@@ -4,6 +4,7 @@ import "@std/dotenv/load";
// Routes
import { authRouter } from "./routes/auth.ts";
import { contactsRouter } from "./routes/contacts.ts";
import { companiesRouter } from "./routes/companies.ts";
import { dealsRouter } from "./routes/deals.ts";
import { activitiesRouter } from "./routes/activities.ts";
import { pipelinesRouter } from "./routes/pipelines.ts";
@@ -140,12 +141,23 @@ app.use(async (ctx, next) => {
},
contacts: {
"GET /api/v1/contacts": "List contacts",
"GET /api/v1/contacts/stats": "Contact statistics",
"GET /api/v1/contacts/export": "Export contacts (DSGVO)",
"GET /api/v1/contacts/:id": "Get contact",
"POST /api/v1/contacts": "Create contact",
"POST /api/v1/contacts/import": "Bulk import contacts",
"PUT /api/v1/contacts/:id": "Update contact",
"DELETE /api/v1/contacts/:id": "Delete contact",
"POST /api/v1/contacts/import": "Import contacts (CSV)",
"GET /api/v1/contacts/export": "Export contacts (DSGVO)",
"DELETE /api/v1/contacts/:id": "Soft delete contact",
"DELETE /api/v1/contacts/:id/permanent": "Permanent delete (DSGVO)",
},
companies: {
"GET /api/v1/companies": "List companies",
"GET /api/v1/companies/industries": "Get industries",
"GET /api/v1/companies/:id": "Get company",
"GET /api/v1/companies/:id/contacts": "Get company contacts",
"POST /api/v1/companies": "Create company",
"PUT /api/v1/companies/:id": "Update company",
"DELETE /api/v1/companies/:id": "Delete company",
},
deals: {
"GET /api/v1/deals": "List deals",
@@ -185,6 +197,9 @@ app.use(authRouter.allowedMethods());
app.use(contactsRouter.routes());
app.use(contactsRouter.allowedMethods());
app.use(companiesRouter.routes());
app.use(companiesRouter.allowedMethods());
app.use(dealsRouter.routes());
app.use(dealsRouter.allowedMethods());

298
src/repositories/company.ts Normal file
View File

@@ -0,0 +1,298 @@
import { query, queryOne, execute } from "../db/connection.ts";
// ============================================
// COMPANY REPOSITORY
// ============================================
export interface Company {
id: string;
org_id: string;
name: string;
industry?: string;
website?: string;
phone?: string;
email?: string;
address_street?: string;
address_city?: string;
address_zip?: string;
address_country?: string;
notes?: string;
custom_fields: Record<string, unknown>;
created_by?: string;
created_at: Date;
updated_at: Date;
deleted_at?: Date;
}
export interface CompanyWithStats extends Company {
contact_count: number;
deal_count: number;
deal_value: number;
}
export interface CompanyFilters {
search?: string;
industry?: string;
hasWebsite?: boolean;
}
/**
* List companies with filters and pagination
*/
export async function findAll(
orgId: string,
filters: CompanyFilters = {},
pagination: { page: number; limit: number } = { page: 1, limit: 20 }
): Promise<{ companies: CompanyWithStats[]; total: number }> {
const { page, limit } = pagination;
const offset = (page - 1) * limit;
const conditions: string[] = ["c.org_id = $1", "c.deleted_at IS NULL"];
const params: unknown[] = [orgId];
let paramIndex = 2;
if (filters.search) {
conditions.push(`c.name ILIKE $${paramIndex}`);
params.push(`%${filters.search}%`);
paramIndex++;
}
if (filters.industry) {
conditions.push(`c.industry = $${paramIndex}`);
params.push(filters.industry);
paramIndex++;
}
if (filters.hasWebsite === true) {
conditions.push(`c.website IS NOT NULL AND c.website != ''`);
}
const whereClause = conditions.join(" AND ");
// Get total count
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM companies c WHERE ${whereClause}`,
params
);
const total = parseInt(countResult?.count || "0");
// Get companies with stats
const companies = await query<CompanyWithStats>(
`SELECT
c.*,
COALESCE(contact_stats.contact_count, 0)::int as contact_count,
COALESCE(deal_stats.deal_count, 0)::int as deal_count,
COALESCE(deal_stats.deal_value, 0)::numeric as deal_value
FROM companies c
LEFT JOIN (
SELECT company_id, COUNT(*) as contact_count
FROM contacts
WHERE deleted_at IS NULL
GROUP BY company_id
) contact_stats ON contact_stats.company_id = c.id
LEFT JOIN (
SELECT company_id, COUNT(*) as deal_count, SUM(value) as deal_value
FROM deals
WHERE deleted_at IS NULL
GROUP BY company_id
) deal_stats ON deal_stats.company_id = c.id
WHERE ${whereClause}
ORDER BY c.name ASC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return { companies, total };
}
/**
* Find company by ID
*/
export async function findById(orgId: string, companyId: string): Promise<CompanyWithStats | null> {
const rows = await query<CompanyWithStats>(
`SELECT
c.*,
COALESCE(contact_stats.contact_count, 0)::int as contact_count,
COALESCE(deal_stats.deal_count, 0)::int as deal_count,
COALESCE(deal_stats.deal_value, 0)::numeric as deal_value
FROM companies c
LEFT JOIN (
SELECT company_id, COUNT(*) as contact_count
FROM contacts
WHERE deleted_at IS NULL
GROUP BY company_id
) contact_stats ON contact_stats.company_id = c.id
LEFT JOIN (
SELECT company_id, COUNT(*) as deal_count, SUM(value) as deal_value
FROM deals
WHERE deleted_at IS NULL
GROUP BY company_id
) deal_stats ON deal_stats.company_id = c.id
WHERE c.id = $1 AND c.org_id = $2 AND c.deleted_at IS NULL`,
[companyId, orgId]
);
return rows[0] || null;
}
/**
* Find company by name (for duplicate detection)
*/
export async function findByName(orgId: string, name: string): Promise<Company | null> {
return await queryOne<Company>(
`SELECT * FROM companies WHERE org_id = $1 AND LOWER(name) = LOWER($2) AND deleted_at IS NULL`,
[orgId, name]
);
}
/**
* Create a new company
*/
export async function create(data: {
orgId: string;
name: string;
industry?: string;
website?: string;
phone?: string;
email?: string;
addressStreet?: string;
addressCity?: string;
addressZip?: string;
addressCountry?: string;
notes?: string;
customFields?: Record<string, unknown>;
createdBy?: string;
}): Promise<Company> {
const rows = await query<Company>(
`INSERT INTO companies (
org_id, name, industry, website, phone, email,
address_street, address_city, address_zip, address_country,
notes, custom_fields, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
data.orgId,
data.name,
data.industry || null,
data.website || null,
data.phone || null,
data.email || null,
data.addressStreet || null,
data.addressCity || null,
data.addressZip || null,
data.addressCountry || "DE",
data.notes || null,
JSON.stringify(data.customFields || {}),
data.createdBy || null,
]
);
return rows[0];
}
/**
* Update a company
*/
export async function update(
orgId: string,
companyId: string,
data: Partial<{
name: string;
industry: string;
website: string;
phone: string;
email: string;
addressStreet: string;
addressCity: string;
addressZip: string;
addressCountry: string;
notes: string;
customFields: Record<string, unknown>;
}>
): Promise<Company | null> {
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
const fieldMap: Record<string, string> = {
name: "name",
industry: "industry",
website: "website",
phone: "phone",
email: "email",
addressStreet: "address_street",
addressCity: "address_city",
addressZip: "address_zip",
addressCountry: "address_country",
notes: "notes",
customFields: "custom_fields",
};
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 queryOne<Company>(
`SELECT * FROM companies WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`,
[companyId, orgId]
);
}
params.push(companyId, orgId);
const rows = await query<Company>(
`UPDATE companies
SET ${updates.join(", ")}
WHERE id = $${paramIndex} AND org_id = $${paramIndex + 1} AND deleted_at IS NULL
RETURNING *`,
params
);
return rows[0] || null;
}
/**
* Soft delete a company
*/
export async function softDelete(orgId: string, companyId: string): Promise<boolean> {
const count = await execute(
`UPDATE companies SET deleted_at = NOW() WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`,
[companyId, orgId]
);
return count > 0;
}
/**
* Get contacts for a company
*/
export async function getContacts(orgId: string, companyId: string) {
return await query(
`SELECT id, first_name, last_name, email, phone, job_title
FROM contacts
WHERE org_id = $1 AND company_id = $2 AND deleted_at IS NULL
ORDER BY last_name, first_name`,
[orgId, companyId]
);
}
/**
* Get distinct industries for autocomplete
*/
export async function getIndustries(orgId: string): Promise<string[]> {
const rows = await query<{ industry: string }>(
`SELECT DISTINCT industry FROM companies
WHERE org_id = $1 AND industry IS NOT NULL AND industry != '' AND deleted_at IS NULL
ORDER BY industry`,
[orgId]
);
return rows.map((r) => r.industry);
}

423
src/repositories/contact.ts Normal file
View File

@@ -0,0 +1,423 @@
import { query, queryOne, execute, transaction } from "../db/connection.ts";
// ============================================
// CONTACT REPOSITORY
// ============================================
export interface Contact {
id: string;
org_id: string;
company_id?: string;
first_name: string;
last_name: string;
email?: string;
phone?: string;
mobile?: string;
job_title?: string;
address_street?: string;
address_city?: string;
address_zip?: string;
address_country?: string;
notes?: string;
tags: string[];
custom_fields: Record<string, unknown>;
consent_marketing: boolean;
consent_date?: Date;
data_source?: string;
owner_id?: string;
created_by?: string;
created_at: Date;
updated_at: Date;
deleted_at?: Date;
}
export interface ContactFilters {
search?: string;
companyId?: string;
ownerId?: string;
tags?: string[];
hasEmail?: boolean;
createdAfter?: Date;
createdBefore?: Date;
}
export interface PaginationOptions {
page: number;
limit: number;
sortBy?: string;
sortOrder?: "asc" | "desc";
}
/**
* List contacts with filters and pagination
*/
export async function findAll(
orgId: string,
filters: ContactFilters = {},
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<{ contacts: Contact[]; total: number }> {
const { page, limit, sortBy = "created_at", sortOrder = "desc" } = pagination;
const offset = (page - 1) * limit;
// Build WHERE clauses
const conditions: string[] = ["org_id = $1", "deleted_at IS NULL"];
const params: unknown[] = [orgId];
let paramIndex = 2;
if (filters.search) {
conditions.push(`(
first_name ILIKE $${paramIndex} OR
last_name ILIKE $${paramIndex} OR
email ILIKE $${paramIndex} OR
(first_name || ' ' || last_name) ILIKE $${paramIndex}
)`);
params.push(`%${filters.search}%`);
paramIndex++;
}
if (filters.companyId) {
conditions.push(`company_id = $${paramIndex}`);
params.push(filters.companyId);
paramIndex++;
}
if (filters.ownerId) {
conditions.push(`owner_id = $${paramIndex}`);
params.push(filters.ownerId);
paramIndex++;
}
if (filters.tags && filters.tags.length > 0) {
conditions.push(`tags && $${paramIndex}`);
params.push(filters.tags);
paramIndex++;
}
if (filters.hasEmail === true) {
conditions.push(`email IS NOT NULL AND email != ''`);
} else if (filters.hasEmail === false) {
conditions.push(`(email IS NULL OR email = '')`);
}
if (filters.createdAfter) {
conditions.push(`created_at >= $${paramIndex}`);
params.push(filters.createdAfter);
paramIndex++;
}
if (filters.createdBefore) {
conditions.push(`created_at <= $${paramIndex}`);
params.push(filters.createdBefore);
paramIndex++;
}
const whereClause = conditions.join(" AND ");
// Whitelist sortBy to prevent SQL injection
const allowedSorts = ["created_at", "updated_at", "first_name", "last_name", "email"];
const safeSortBy = allowedSorts.includes(sortBy) ? sortBy : "created_at";
const safeSortOrder = sortOrder === "asc" ? "ASC" : "DESC";
// Get total count
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM contacts WHERE ${whereClause}`,
params
);
const total = parseInt(countResult?.count || "0");
// Get contacts
const contacts = await query<Contact>(
`SELECT * FROM contacts
WHERE ${whereClause}
ORDER BY ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limit, offset]
);
return { contacts, total };
}
/**
* Find contact by ID
*/
export async function findById(orgId: string, contactId: string): Promise<Contact | null> {
return await queryOne<Contact>(
`SELECT * FROM contacts WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`,
[contactId, orgId]
);
}
/**
* Find contact by email
*/
export async function findByEmail(orgId: string, email: string): Promise<Contact | null> {
return await queryOne<Contact>(
`SELECT * FROM contacts WHERE org_id = $1 AND LOWER(email) = LOWER($2) AND deleted_at IS NULL`,
[orgId, email]
);
}
/**
* Create a new contact
*/
export async function create(data: {
orgId: string;
companyId?: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
mobile?: string;
jobTitle?: string;
addressStreet?: string;
addressCity?: string;
addressZip?: string;
addressCountry?: string;
notes?: string;
tags?: string[];
customFields?: Record<string, unknown>;
consentMarketing?: boolean;
dataSource?: string;
ownerId?: string;
createdBy?: string;
}): Promise<Contact> {
const rows = await query<Contact>(
`INSERT INTO contacts (
org_id, company_id, first_name, last_name, email, phone, mobile,
job_title, address_street, address_city, address_zip, address_country,
notes, tags, custom_fields, consent_marketing, consent_date, data_source,
owner_id, created_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
CASE WHEN $16 = true THEN NOW() ELSE NULL END, $17, $18, $19
)
RETURNING *`,
[
data.orgId,
data.companyId || null,
data.firstName,
data.lastName,
data.email || null,
data.phone || null,
data.mobile || null,
data.jobTitle || null,
data.addressStreet || null,
data.addressCity || null,
data.addressZip || null,
data.addressCountry || "DE",
data.notes || null,
data.tags || [],
JSON.stringify(data.customFields || {}),
data.consentMarketing || false,
data.dataSource || null,
data.ownerId || null,
data.createdBy || null,
]
);
return rows[0];
}
/**
* Update a contact
*/
export async function update(
orgId: string,
contactId: string,
data: Partial<{
companyId: string | null;
firstName: string;
lastName: string;
email: string;
phone: string;
mobile: string;
jobTitle: string;
addressStreet: string;
addressCity: string;
addressZip: string;
addressCountry: string;
notes: string;
tags: string[];
customFields: Record<string, unknown>;
consentMarketing: boolean;
dataSource: string;
ownerId: string | null;
}>
): Promise<Contact | null> {
// Build SET clause dynamically
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
const fieldMap: Record<string, string> = {
companyId: "company_id",
firstName: "first_name",
lastName: "last_name",
email: "email",
phone: "phone",
mobile: "mobile",
jobTitle: "job_title",
addressStreet: "address_street",
addressCity: "address_city",
addressZip: "address_zip",
addressCountry: "address_country",
notes: "notes",
tags: "tags",
customFields: "custom_fields",
consentMarketing: "consent_marketing",
dataSource: "data_source",
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 if (key === "consentMarketing" && value === true) {
updates.push(`${dbField} = $${paramIndex}`);
updates.push(`consent_date = COALESCE(consent_date, NOW())`);
params.push(value);
} else {
updates.push(`${dbField} = $${paramIndex}`);
params.push(value);
}
paramIndex++;
}
}
if (updates.length === 0) {
return await findById(orgId, contactId);
}
params.push(contactId, orgId);
const rows = await query<Contact>(
`UPDATE contacts
SET ${updates.join(", ")}
WHERE id = $${paramIndex} AND org_id = $${paramIndex + 1} AND deleted_at IS NULL
RETURNING *`,
params
);
return rows[0] || null;
}
/**
* Soft delete a contact
*/
export async function softDelete(orgId: string, contactId: string): Promise<boolean> {
const count = await execute(
`UPDATE contacts SET deleted_at = NOW() WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`,
[contactId, orgId]
);
return count > 0;
}
/**
* Permanently delete a contact (DSGVO - Right to Erasure)
*/
export async function hardDelete(orgId: string, contactId: string): Promise<boolean> {
const count = await execute(
`DELETE FROM contacts WHERE id = $1 AND org_id = $2`,
[contactId, orgId]
);
return count > 0;
}
/**
* Bulk import contacts
*/
export async function bulkCreate(
orgId: string,
contacts: Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
companyId?: string;
tags?: string[];
}>,
createdBy?: string
): Promise<{ created: number; skipped: number }> {
let created = 0;
let skipped = 0;
await transaction(async (conn) => {
for (const contact of contacts) {
// Skip if email already exists
if (contact.email) {
const existing = await conn.queryObject(
`SELECT id FROM contacts WHERE org_id = $1 AND LOWER(email) = LOWER($2) AND deleted_at IS NULL`,
[orgId, contact.email]
);
if (existing.rows.length > 0) {
skipped++;
continue;
}
}
await conn.queryObject(
`INSERT INTO contacts (org_id, first_name, last_name, email, phone, company_id, tags, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
orgId,
contact.firstName,
contact.lastName,
contact.email || null,
contact.phone || null,
contact.companyId || null,
contact.tags || [],
createdBy || null,
]
);
created++;
}
});
return { created, skipped };
}
/**
* Export contacts for DSGVO (Data Portability)
*/
export async function exportForUser(orgId: string, userId: string): Promise<Contact[]> {
return await query<Contact>(
`SELECT * FROM contacts
WHERE org_id = $1 AND (owner_id = $2 OR created_by = $2) AND deleted_at IS NULL`,
[orgId, userId]
);
}
/**
* Get contact statistics
*/
export async function getStats(orgId: string): Promise<{
total: number;
withEmail: number;
withPhone: number;
createdThisMonth: number;
}> {
const result = await queryOne<{
total: string;
with_email: string;
with_phone: string;
created_this_month: string;
}>(
`SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE email IS NOT NULL AND email != '') as with_email,
COUNT(*) FILTER (WHERE phone IS NOT NULL OR mobile IS NOT NULL) as with_phone,
COUNT(*) FILTER (WHERE created_at >= date_trunc('month', NOW())) as created_this_month
FROM contacts
WHERE org_id = $1 AND deleted_at IS NULL`,
[orgId]
);
return {
total: parseInt(result?.total || "0"),
withEmail: parseInt(result?.with_email || "0"),
withPhone: parseInt(result?.with_phone || "0"),
createdThisMonth: parseInt(result?.created_this_month || "0"),
};
}

268
src/routes/companies.ts Normal file
View File

@@ -0,0 +1,268 @@
import { Router } from "@oak/oak";
import { z } from "zod";
import * as companyRepo from "../repositories/company.ts";
import { requireAuth, requireRole } from "../middleware/auth.ts";
import type { AuthState } from "../types/index.ts";
const router = new Router<AuthState>({ prefix: "/api/v1/companies" });
// ============================================
// VALIDATION SCHEMAS
// ============================================
const createCompanySchema = z.object({
name: z.string().min(1).max(200),
industry: z.string().max(100).optional().nullable(),
website: z.string().url().max(255).optional().nullable(),
phone: z.string().max(50).optional().nullable(),
email: z.string().email().optional().nullable(),
addressStreet: z.string().max(255).optional().nullable(),
addressCity: z.string().max(100).optional().nullable(),
addressZip: z.string().max(20).optional().nullable(),
addressCountry: z.string().length(2).optional().default("DE"),
notes: z.string().optional().nullable(),
customFields: z.record(z.unknown()).optional().default({}),
});
const updateCompanySchema = createCompanySchema.partial();
const listQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
industry: z.string().optional(),
hasWebsite: z.enum(["true", "false"]).optional(),
});
// ============================================
// ROUTES
// ============================================
// GET /api/v1/companies - List companies
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, search, industry, hasWebsite } = result.data;
const filters = {
search,
industry,
hasWebsite: hasWebsite === "true" ? true : undefined,
};
const { companies, total } = await companyRepo.findAll(
ctx.state.orgId,
filters,
{ page, limit }
);
ctx.response.body = {
success: true,
data: companies.map(formatCompany),
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
});
// GET /api/v1/companies/industries - Get distinct industries
router.get("/industries", requireAuth, async (ctx) => {
const industries = await companyRepo.getIndustries(ctx.state.orgId);
ctx.response.body = {
success: true,
data: industries,
};
});
// GET /api/v1/companies/:id - Get single company
router.get("/:id", requireAuth, async (ctx) => {
const company = await companyRepo.findById(ctx.state.orgId, ctx.params.id);
if (!company) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Company not found" },
};
return;
}
ctx.response.body = {
success: true,
data: formatCompany(company),
};
});
// GET /api/v1/companies/:id/contacts - Get company contacts
router.get("/:id/contacts", requireAuth, async (ctx) => {
const company = await companyRepo.findById(ctx.state.orgId, ctx.params.id);
if (!company) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Company not found" },
};
return;
}
const contacts = await companyRepo.getContacts(ctx.state.orgId, ctx.params.id);
ctx.response.body = {
success: true,
data: contacts,
};
});
// POST /api/v1/companies - Create company
router.post("/", requireAuth, async (ctx) => {
const body = await ctx.request.body.json();
const result = createCompanySchema.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;
// Check for duplicate name
const existing = await companyRepo.findByName(ctx.state.orgId, data.name);
if (existing) {
ctx.response.status = 409;
ctx.response.body = {
success: false,
error: { code: "DUPLICATE_NAME", message: "A company with this name already exists" },
};
return;
}
const company = await companyRepo.create({
orgId: ctx.state.orgId,
...data,
createdBy: ctx.state.user.id,
});
ctx.response.status = 201;
ctx.response.body = {
success: true,
data: formatCompany(company as companyRepo.CompanyWithStats),
};
});
// PUT /api/v1/companies/:id - Update company
router.put("/:id", requireAuth, async (ctx) => {
const body = await ctx.request.body.json();
const result = updateCompanySchema.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;
}
// Check if company exists
const existing = await companyRepo.findById(ctx.state.orgId, ctx.params.id);
if (!existing) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Company not found" },
};
return;
}
// Check for duplicate name if changing
const data = result.data;
if (data.name && data.name.toLowerCase() !== existing.name.toLowerCase()) {
const duplicate = await companyRepo.findByName(ctx.state.orgId, data.name);
if (duplicate) {
ctx.response.status = 409;
ctx.response.body = {
success: false,
error: { code: "DUPLICATE_NAME", message: "A company with this name already exists" },
};
return;
}
}
const company = await companyRepo.update(ctx.state.orgId, ctx.params.id, data);
ctx.response.body = {
success: true,
data: formatCompany(company as companyRepo.CompanyWithStats),
};
});
// DELETE /api/v1/companies/:id - Soft delete company
router.delete("/:id", requireAuth, requireRole("owner", "admin", "manager"), async (ctx) => {
const deleted = await companyRepo.softDelete(ctx.state.orgId, ctx.params.id);
if (!deleted) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Company not found" },
};
return;
}
ctx.response.body = {
success: true,
message: "Company deleted",
};
});
// ============================================
// HELPER FUNCTIONS
// ============================================
function formatCompany(company: companyRepo.CompanyWithStats) {
return {
id: company.id,
name: company.name,
industry: company.industry,
website: company.website,
phone: company.phone,
email: company.email,
address: {
street: company.address_street,
city: company.address_city,
zip: company.address_zip,
country: company.address_country,
},
notes: company.notes,
customFields: company.custom_fields,
stats: {
contactCount: company.contact_count || 0,
dealCount: company.deal_count || 0,
dealValue: company.deal_value || 0,
},
createdBy: company.created_by,
createdAt: company.created_at,
updatedAt: company.updated_at,
};
}
export { router as companiesRouter };

View File

@@ -1,142 +1,305 @@
import { Router } from "@oak/oak";
import { z } from "zod";
import * as contactRepo from "../repositories/contact.ts";
import * as companyRepo from "../repositories/company.ts";
import * as auditService from "../services/audit.ts";
import { requireAuth, requireRole } from "../middleware/auth.ts";
import type { AuthState } from "../types/index.ts";
const router = new Router({ prefix: "/api/v1/contacts" });
const router = new Router<AuthState>({ prefix: "/api/v1/contacts" });
// ============================================
// VALIDATION SCHEMAS
// ============================================
const createContactSchema = z.object({
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
email: z.string().email().optional().nullable(),
phone: z.string().max(50).optional().nullable(),
mobile: z.string().max(50).optional().nullable(),
jobTitle: z.string().max(100).optional().nullable(),
companyId: z.string().uuid().optional().nullable(),
addressStreet: z.string().max(255).optional().nullable(),
addressCity: z.string().max(100).optional().nullable(),
addressZip: z.string().max(20).optional().nullable(),
addressCountry: z.string().length(2).optional().default("DE"),
notes: z.string().optional().nullable(),
tags: z.array(z.string()).optional().default([]),
customFields: z.record(z.unknown()).optional().default({}),
consentMarketing: z.boolean().optional().default(false),
dataSource: z.string().max(100).optional().nullable(),
ownerId: z.string().uuid().optional().nullable(),
});
const updateContactSchema = createContactSchema.partial();
const listQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
companyId: z.string().uuid().optional(),
ownerId: z.string().uuid().optional(),
tags: z.string().optional(), // comma-separated
hasEmail: z.enum(["true", "false"]).optional(),
sortBy: z.enum(["created_at", "updated_at", "first_name", "last_name", "email"]).default("created_at"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
const bulkImportSchema = z.object({
contacts: z.array(z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email().optional(),
phone: z.string().optional(),
companyId: z.string().uuid().optional(),
tags: z.array(z.string()).optional(),
})).min(1).max(1000),
});
// ============================================
// ROUTES
// ============================================
// 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(",");
router.get("/", requireAuth, async (ctx) => {
const queryParams = Object.fromEntries(ctx.request.url.searchParams);
const result = listQuerySchema.safeParse(queryParams);
// TODO: Implement with database
// 1. Get org_id from JWT
// 2. Build query with filters
// 3. Paginate results
if (!result.success) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid query parameters", details: result.error.errors },
};
return;
}
const { page, limit, search, companyId, ownerId, tags, hasEmail, sortBy, sortOrder } = result.data;
const filters = {
search,
companyId,
ownerId,
tags: tags ? tags.split(",").map((t) => t.trim()) : undefined,
hasEmail: hasEmail === "true" ? true : hasEmail === "false" ? false : undefined,
};
const { contacts, total } = await contactRepo.findAll(
ctx.state.orgId,
filters,
{ page, limit, sortBy, sortOrder }
);
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",
},
],
data: contacts.map(formatContact),
meta: {
page,
limit,
total: 150,
totalPages: 8,
total,
totalPages: Math.ceil(total / limit),
},
};
});
// GET /api/v1/contacts/stats - Get contact statistics
router.get("/stats", requireAuth, async (ctx) => {
const stats = await contactRepo.getStats(ctx.state.orgId);
ctx.response.body = {
success: true,
data: stats,
};
});
// GET /api/v1/contacts/export - Export contacts (DSGVO)
router.get("/export", requireAuth, async (ctx) => {
const contacts = await contactRepo.exportForUser(ctx.state.orgId, ctx.state.user.id);
// Log export for DSGVO compliance
await auditService.logDataExport(
ctx.state.orgId,
ctx.state.user.id,
"contacts",
ctx.request.ip
);
ctx.response.headers.set("Content-Type", "application/json");
ctx.response.headers.set("Content-Disposition", `attachment; filename="contacts-export-${Date.now()}.json"`);
ctx.response.body = {
exportedAt: new Date().toISOString(),
exportedBy: ctx.state.user.email,
contacts: contacts.map(formatContact),
};
});
// GET /api/v1/contacts/:id - Get single contact
router.get("/:id", async (ctx) => {
const { id } = ctx.params;
router.get("/:id", requireAuth, async (ctx) => {
const contact = await contactRepo.findById(ctx.state.orgId, ctx.params.id);
// TODO: Fetch from database
if (!contact) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Contact not found" },
};
return;
}
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",
},
data: formatContact(contact),
};
});
// POST /api/v1/contacts - Create contact
router.post("/", async (ctx) => {
router.post("/", requireAuth, async (ctx) => {
const body = await ctx.request.body.json();
const result = createContactSchema.safeParse(body);
// TODO: Implement
// 1. Validate input (Zod)
// 2. Check for duplicates
// 3. Create contact
// 4. Log audit
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;
// Check for duplicate email
if (data.email) {
const existing = await contactRepo.findByEmail(ctx.state.orgId, data.email);
if (existing) {
ctx.response.status = 409;
ctx.response.body = {
success: false,
error: { code: "DUPLICATE_EMAIL", message: "A contact with this email already exists" },
};
return;
}
}
// Verify company exists if provided
if (data.companyId) {
const company = await companyRepo.findById(ctx.state.orgId, data.companyId);
if (!company) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
error: { code: "INVALID_COMPANY", message: "Company not found" },
};
return;
}
}
const contact = await contactRepo.create({
orgId: ctx.state.orgId,
...data,
createdBy: ctx.state.user.id,
});
ctx.response.status = 201;
ctx.response.body = {
success: true,
data: formatContact(contact),
};
});
// POST /api/v1/contacts/import - Bulk import contacts
router.post("/import", requireAuth, requireRole("owner", "admin", "manager"), async (ctx) => {
const body = await ctx.request.body.json();
const result = bulkImportSchema.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 { contacts } = result.data;
const { created, skipped } = await contactRepo.bulkCreate(
ctx.state.orgId,
contacts,
ctx.state.user.id
);
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Contact created",
data: {
id: "new-uuid",
...body,
createdAt: new Date().toISOString(),
created,
skipped,
total: contacts.length,
},
message: `Imported ${created} contacts, skipped ${skipped} duplicates`,
};
});
// PUT /api/v1/contacts/:id - Update contact
router.put("/:id", async (ctx) => {
const { id } = ctx.params;
router.put("/:id", requireAuth, async (ctx) => {
const body = await ctx.request.body.json();
const result = updateContactSchema.safeParse(body);
// TODO: Implement
// 1. Validate input
// 2. Check ownership
// 3. Update contact
// 4. Log audit (before/after)
if (!result.success) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid input", details: result.error.errors },
};
return;
}
// Check if contact exists
const existing = await contactRepo.findById(ctx.state.orgId, ctx.params.id);
if (!existing) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Contact not found" },
};
return;
}
// Check for duplicate email if changing
const data = result.data;
if (data.email && data.email !== existing.email) {
const duplicate = await contactRepo.findByEmail(ctx.state.orgId, data.email);
if (duplicate && duplicate.id !== ctx.params.id) {
ctx.response.status = 409;
ctx.response.body = {
success: false,
error: { code: "DUPLICATE_EMAIL", message: "A contact with this email already exists" },
};
return;
}
}
const contact = await contactRepo.update(ctx.state.orgId, ctx.params.id, data);
ctx.response.body = {
success: true,
message: "Contact updated",
data: {
id,
...body,
updatedAt: new Date().toISOString(),
},
data: formatContact(contact!),
};
});
// DELETE /api/v1/contacts/:id - Delete contact (soft delete)
router.delete("/:id", async (ctx) => {
const { id } = ctx.params;
// DELETE /api/v1/contacts/:id - Soft delete contact
router.delete("/:id", requireAuth, async (ctx) => {
const deleted = await contactRepo.softDelete(ctx.state.orgId, ctx.params.id);
// TODO: Implement soft delete
// 1. Check ownership
// 2. Set deleted_at
// 3. Log audit
if (!deleted) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Contact not found" },
};
return;
}
ctx.response.body = {
success: true,
@@ -144,71 +307,68 @@ router.delete("/:id", async (ctx) => {
};
});
// GET /api/v1/contacts/:id/activities - Get contact activities
router.get("/:id/activities", async (ctx) => {
const { id } = ctx.params;
// DELETE /api/v1/contacts/:id/permanent - Permanently delete (DSGVO)
router.delete("/:id/permanent", requireAuth, requireRole("owner", "admin"), async (ctx) => {
// Log deletion for DSGVO compliance before deleting
await auditService.logDataDeletion(
ctx.state.orgId,
ctx.state.user.id,
"contact",
ctx.params.id,
ctx.request.ip
);
const deleted = await contactRepo.hardDelete(ctx.state.orgId, ctx.params.id);
if (!deleted) {
ctx.response.status = 404;
ctx.response.body = {
success: false,
error: { code: "NOT_FOUND", message: "Contact not found" },
};
return;
}
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" },
},
],
message: "Contact permanently deleted",
};
});
// GET /api/v1/contacts/:id/deals - Get contact deals
router.get("/:id/deals", async (ctx) => {
const { id } = ctx.params;
// ============================================
// HELPER FUNCTIONS
// ============================================
ctx.response.body = {
success: true,
data: [
{
id: "deal-1",
title: "CRM Implementation",
value: 25000,
stage: "proposal",
status: "open",
function formatContact(contact: contactRepo.Contact) {
return {
id: contact.id,
firstName: contact.first_name,
lastName: contact.last_name,
fullName: `${contact.first_name} ${contact.last_name}`,
email: contact.email,
phone: contact.phone,
mobile: contact.mobile,
jobTitle: contact.job_title,
companyId: contact.company_id,
address: {
street: contact.address_street,
city: contact.address_city,
zip: contact.address_zip,
country: contact.address_country,
},
],
};
});
// 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",
notes: contact.notes,
tags: contact.tags,
customFields: contact.custom_fields,
consent: {
marketing: contact.consent_marketing,
date: contact.consent_date,
},
dataSource: contact.data_source,
ownerId: contact.owner_id,
createdBy: contact.created_by,
createdAt: contact.created_at,
updatedAt: contact.updated_at,
};
});
// 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 };