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
|
||||
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
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 { 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 };
|
||||
|
||||
Reference in New Issue
Block a user