feat(contacts): Kontakt- & Firmenverwaltung implementiert

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

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

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

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

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

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

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