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:
21
src/main.ts
21
src/main.ts
@@ -4,6 +4,7 @@ import "@std/dotenv/load";
|
|||||||
// Routes
|
// Routes
|
||||||
import { authRouter } from "./routes/auth.ts";
|
import { authRouter } from "./routes/auth.ts";
|
||||||
import { contactsRouter } from "./routes/contacts.ts";
|
import { contactsRouter } from "./routes/contacts.ts";
|
||||||
|
import { companiesRouter } from "./routes/companies.ts";
|
||||||
import { dealsRouter } from "./routes/deals.ts";
|
import { dealsRouter } from "./routes/deals.ts";
|
||||||
import { activitiesRouter } from "./routes/activities.ts";
|
import { activitiesRouter } from "./routes/activities.ts";
|
||||||
import { pipelinesRouter } from "./routes/pipelines.ts";
|
import { pipelinesRouter } from "./routes/pipelines.ts";
|
||||||
@@ -140,12 +141,23 @@ app.use(async (ctx, next) => {
|
|||||||
},
|
},
|
||||||
contacts: {
|
contacts: {
|
||||||
"GET /api/v1/contacts": "List 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",
|
"GET /api/v1/contacts/:id": "Get contact",
|
||||||
"POST /api/v1/contacts": "Create contact",
|
"POST /api/v1/contacts": "Create contact",
|
||||||
|
"POST /api/v1/contacts/import": "Bulk import contacts",
|
||||||
"PUT /api/v1/contacts/:id": "Update contact",
|
"PUT /api/v1/contacts/:id": "Update contact",
|
||||||
"DELETE /api/v1/contacts/:id": "Delete contact",
|
"DELETE /api/v1/contacts/:id": "Soft delete contact",
|
||||||
"POST /api/v1/contacts/import": "Import contacts (CSV)",
|
"DELETE /api/v1/contacts/:id/permanent": "Permanent delete (DSGVO)",
|
||||||
"GET /api/v1/contacts/export": "Export contacts (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: {
|
deals: {
|
||||||
"GET /api/v1/deals": "List deals",
|
"GET /api/v1/deals": "List deals",
|
||||||
@@ -185,6 +197,9 @@ app.use(authRouter.allowedMethods());
|
|||||||
app.use(contactsRouter.routes());
|
app.use(contactsRouter.routes());
|
||||||
app.use(contactsRouter.allowedMethods());
|
app.use(contactsRouter.allowedMethods());
|
||||||
|
|
||||||
|
app.use(companiesRouter.routes());
|
||||||
|
app.use(companiesRouter.allowedMethods());
|
||||||
|
|
||||||
app.use(dealsRouter.routes());
|
app.use(dealsRouter.routes());
|
||||||
app.use(dealsRouter.allowedMethods());
|
app.use(dealsRouter.allowedMethods());
|
||||||
|
|
||||||
|
|||||||
298
src/repositories/company.ts
Normal file
298
src/repositories/company.ts
Normal 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
423
src/repositories/contact.ts
Normal 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
268
src/routes/companies.ts
Normal 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 };
|
||||||
@@ -1,142 +1,305 @@
|
|||||||
import { Router } from "@oak/oak";
|
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
|
// GET /api/v1/contacts - List contacts
|
||||||
router.get("/", async (ctx) => {
|
router.get("/", requireAuth, async (ctx) => {
|
||||||
const query = ctx.request.url.searchParams;
|
const queryParams = Object.fromEntries(ctx.request.url.searchParams);
|
||||||
const page = parseInt(query.get("page") || "1");
|
const result = listQuerySchema.safeParse(queryParams);
|
||||||
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
|
if (!result.success) {
|
||||||
// 1. Get org_id from JWT
|
ctx.response.status = 400;
|
||||||
// 2. Build query with filters
|
ctx.response.body = {
|
||||||
// 3. Paginate results
|
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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: [
|
data: contacts.map(formatContact),
|
||||||
{
|
|
||||||
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: {
|
meta: {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total: 150,
|
total,
|
||||||
totalPages: 8,
|
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
|
// GET /api/v1/contacts/:id - Get single contact
|
||||||
router.get("/:id", async (ctx) => {
|
router.get("/:id", requireAuth, async (ctx) => {
|
||||||
const { id } = ctx.params;
|
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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: formatContact(contact),
|
||||||
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
|
// POST /api/v1/contacts - Create contact
|
||||||
router.post("/", async (ctx) => {
|
router.post("/", requireAuth, async (ctx) => {
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
|
const result = createContactSchema.safeParse(body);
|
||||||
|
|
||||||
// TODO: Implement
|
if (!result.success) {
|
||||||
// 1. Validate input (Zod)
|
ctx.response.status = 400;
|
||||||
// 2. Check for duplicates
|
ctx.response.body = {
|
||||||
// 3. Create contact
|
success: false,
|
||||||
// 4. Log audit
|
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.status = 201;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Contact created",
|
|
||||||
data: {
|
data: {
|
||||||
id: "new-uuid",
|
created,
|
||||||
...body,
|
skipped,
|
||||||
createdAt: new Date().toISOString(),
|
total: contacts.length,
|
||||||
},
|
},
|
||||||
|
message: `Imported ${created} contacts, skipped ${skipped} duplicates`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/v1/contacts/:id - Update contact
|
// PUT /api/v1/contacts/:id - Update contact
|
||||||
router.put("/:id", async (ctx) => {
|
router.put("/:id", requireAuth, async (ctx) => {
|
||||||
const { id } = ctx.params;
|
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
|
const result = updateContactSchema.safeParse(body);
|
||||||
|
|
||||||
// TODO: Implement
|
if (!result.success) {
|
||||||
// 1. Validate input
|
ctx.response.status = 400;
|
||||||
// 2. Check ownership
|
ctx.response.body = {
|
||||||
// 3. Update contact
|
success: false,
|
||||||
// 4. Log audit (before/after)
|
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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Contact updated",
|
data: formatContact(contact!),
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
...body,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/v1/contacts/:id - Delete contact (soft delete)
|
// DELETE /api/v1/contacts/:id - Soft delete contact
|
||||||
router.delete("/:id", async (ctx) => {
|
router.delete("/:id", requireAuth, async (ctx) => {
|
||||||
const { id } = ctx.params;
|
const deleted = await contactRepo.softDelete(ctx.state.orgId, ctx.params.id);
|
||||||
|
|
||||||
// TODO: Implement soft delete
|
if (!deleted) {
|
||||||
// 1. Check ownership
|
ctx.response.status = 404;
|
||||||
// 2. Set deleted_at
|
ctx.response.body = {
|
||||||
// 3. Log audit
|
success: false,
|
||||||
|
error: { code: "NOT_FOUND", message: "Contact not found" },
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -144,71 +307,68 @@ router.delete("/:id", async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/v1/contacts/:id/activities - Get contact activities
|
// DELETE /api/v1/contacts/:id/permanent - Permanently delete (DSGVO)
|
||||||
router.get("/:id/activities", async (ctx) => {
|
router.delete("/:id/permanent", requireAuth, requireRole("owner", "admin"), async (ctx) => {
|
||||||
const { id } = ctx.params;
|
// 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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: [
|
message: "Contact permanently deleted",
|
||||||
{
|
|
||||||
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) => {
|
// HELPER FUNCTIONS
|
||||||
const { id } = ctx.params;
|
// ============================================
|
||||||
|
|
||||||
ctx.response.body = {
|
function formatContact(contact: contactRepo.Contact) {
|
||||||
success: true,
|
return {
|
||||||
data: [
|
id: contact.id,
|
||||||
{
|
firstName: contact.first_name,
|
||||||
id: "deal-1",
|
lastName: contact.last_name,
|
||||||
title: "CRM Implementation",
|
fullName: `${contact.first_name} ${contact.last_name}`,
|
||||||
value: 25000,
|
email: contact.email,
|
||||||
stage: "proposal",
|
phone: contact.phone,
|
||||||
status: "open",
|
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,
|
||||||
},
|
},
|
||||||
],
|
notes: contact.notes,
|
||||||
};
|
tags: contact.tags,
|
||||||
});
|
customFields: contact.custom_fields,
|
||||||
|
consent: {
|
||||||
// POST /api/v1/contacts/import - Import contacts (CSV)
|
marketing: contact.consent_marketing,
|
||||||
router.post("/import", async (ctx) => {
|
date: contact.consent_date,
|
||||||
// TODO: Handle CSV upload
|
|
||||||
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Import started",
|
|
||||||
data: {
|
|
||||||
importId: "import-uuid",
|
|
||||||
status: "processing",
|
|
||||||
},
|
},
|
||||||
|
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 };
|
export { router as contactsRouter };
|
||||||
|
|||||||
Reference in New Issue
Block a user