diff --git a/src/main.ts b/src/main.ts index 0b2862d..9086d83 100644 --- a/src/main.ts +++ b/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()); diff --git a/src/repositories/company.ts b/src/repositories/company.ts new file mode 100644 index 0000000..44db724 --- /dev/null +++ b/src/repositories/company.ts @@ -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; + 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( + `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 { + const rows = await query( + `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 { + return await queryOne( + `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; + createdBy?: string; +}): Promise { + const rows = await query( + `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; + }> +): Promise { + const updates: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + const fieldMap: Record = { + 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( + `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( + `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 { + 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 { + 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); +} diff --git a/src/repositories/contact.ts b/src/repositories/contact.ts new file mode 100644 index 0000000..283d9f2 --- /dev/null +++ b/src/repositories/contact.ts @@ -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; + 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( + `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 { + return await queryOne( + `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 { + return await queryOne( + `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; + consentMarketing?: boolean; + dataSource?: string; + ownerId?: string; + createdBy?: string; +}): Promise { + const rows = await query( + `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; + consentMarketing: boolean; + dataSource: string; + ownerId: string | null; + }> +): Promise { + // Build SET clause dynamically + const updates: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + const fieldMap: Record = { + 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( + `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 { + 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 { + 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 { + return await query( + `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"), + }; +} diff --git a/src/routes/companies.ts b/src/routes/companies.ts new file mode 100644 index 0000000..9f9c297 --- /dev/null +++ b/src/routes/companies.ts @@ -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({ 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 }; diff --git a/src/routes/contacts.ts b/src/routes/contacts.ts index af9b07d..17f4154 100644 --- a/src/routes/contacts.ts +++ b/src/routes/contacts.ts @@ -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({ 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); + + 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; + } - // TODO: Implement with database - // 1. Get org_id from JWT - // 2. Build query with filters - // 3. Paginate results + 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; - - // TODO: Fetch from database +router.get("/:id", requireAuth, async (ctx) => { + const contact = await contactRepo.findById(ctx.state.orgId, ctx.params.id); + + 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); + + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid input", details: result.error.errors }, + }; + return; + } - // TODO: Implement - // 1. Validate input (Zod) - // 2. Check for duplicates - // 3. Create contact - // 4. Log audit + 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); + + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid input", details: result.error.errors }, + }; + return; + } - // TODO: Implement - // 1. Validate input - // 2. Check ownership - // 3. Update contact - // 4. Log audit (before/after) + // 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; - - // TODO: Implement soft delete - // 1. Check ownership - // 2. Set deleted_at - // 3. Log audit +// 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); + + 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", - }, - ], - }; -}); - -// 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", +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, }, - }; -}); - -// 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(), + 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, }; -}); +} export { router as contactsRouter };