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:
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"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user