/** * CRM Web UI — serves HTML + REST API for contacts management. * * Can run standalone (/crm-web) or mount on pi-webserver (automatic if available). * When pi-webserver is installed, the CRM auto-mounts at /crm on session start. */ import * as http from "node:http"; import * as fs from "node:fs"; import * as path from "node:path"; import { getCrmStore, isStoreReady } from "./store.ts"; import { VALID_EXTENSION_FIELD_TYPES } from "./types.ts"; // ── Validation ────────────────────────────────────────────────── /** * Sanitize a URL: only allow http(s) protocols. Returns the cleaned * URL or null if the value is empty/missing. Throws on bad protocols. */ function sanitizeUrl(value: unknown): string | null { if (value == null || value === "") return null; const s = String(value).trim(); if (!s) return null; if (/^https?:\/\//i.test(s)) return s; // Bare domain — assume https if (!s.includes("://")) return `https://${s}`; throw new Error(`Invalid URL protocol — only http and https are allowed`); } // ── State ─────────────────────────────────────────────────────── let standaloneServer: http.Server | null = null; let standalonePort: number | null = null; let webServerMounted = false; // ── HTML Loader ───────────────────────────────────────────────── function loadCrmHtml(): string { const shellHtml = fs.readFileSync( path.resolve(import.meta.dirname, "../crm.html"), "utf-8", ); const pageDir = path.resolve(import.meta.dirname, "../pages"); const pageNames = ["dashboard", "contacts", "companies", "groups", "interactions", "reminders", "upcoming"]; const pagesHtml = pageNames .map((name) => fs.readFileSync(path.join(pageDir, `${name}.html`), "utf-8"), ) .join("\n\n"); return shellHtml.replace("", pagesHtml); } // ── Page Handler ──────────────────────────────────────────────── /** * Serves the CRM HTML page. Mounted at /crm via web:mount. * API calls use absolute URLs (/api/crm/*) and go through the API mount. */ export async function handleCrmPage( req: http.IncomingMessage, res: http.ServerResponse, urlPath: string, ): Promise { const method = req.method ?? "GET"; try { // Trailing-slash redirect (needed when mounted at a prefix) if (urlPath === "/" && method === "GET") { const rawUrl = req.url ?? "/"; const qIdx = rawUrl.indexOf("?"); const rawPath = qIdx >= 0 ? rawUrl.slice(0, qIdx) : rawUrl; if (rawPath.length > 1 && !rawPath.endsWith("/")) { const qs = qIdx >= 0 ? rawUrl.slice(qIdx) : ""; res.writeHead(301, { Location: rawPath + "/" + qs }); res.end(); return; } } if (method === "GET" && urlPath === "/") { const CRM_HTML = loadCrmHtml(); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(CRM_HTML); return; } json(res, 404, { error: "Not found" }); } catch (err: any) { json(res, 500, { error: err.message }); } } // ── API Handler ───────────────────────────────────────────────── /** * Handles CRM REST API requests. Paths are relative to the API mount * point (e.g. "/contacts", "/companies/1"). * * When mounted via pi-webserver's mountApi at "/crm", the webserver * strips "/api/crm" and passes the remainder. The standalone server * strips the prefix before calling this handler. */ export async function handleCrmApi( req: http.IncomingMessage, res: http.ServerResponse, urlPath: string, ): Promise { const url = new URL(req.url ?? "/", "http://localhost"); const method = req.method ?? "GET"; try { if (!isStoreReady()) { json(res, 503, { error: "CRM is starting up — please wait a moment and refresh" }); return; } const store = getCrmStore(); // ── Contacts ──────────────────────────────────────── if (method === "GET" && urlPath === "/contacts") { const companyId = url.searchParams.get("company_id"); if (companyId) { json(res, 200, await store.getContactsByCompany(parseInt(companyId))); return; } const search = url.searchParams.get("q") ?? undefined; const limit = parseInt(url.searchParams.get("limit") ?? "1000"); json(res, 200, await store.getContacts(search, limit)); return; } if (method === "GET" && urlPath === "/contacts/export.csv") { const csv = await store.exportContactsCsv(); res.writeHead(200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="crm-contacts.csv"', }); res.end(csv); return; } if (method === "POST" && urlPath === "/contacts/import") { const csv = await readBody(req); if (!csv.trim()) { json(res, 400, { error: "Empty CSV body" }); return; } json(res, 200, await store.importContactsCsv(csv)); return; } if (method === "POST" && urlPath === "/contacts/check-duplicates") { const body = JSON.parse(await readBody(req)); if (!body.first_name) { json(res, 400, { error: "first_name is required" }); return; } json(res, 200, { duplicates: await store.findDuplicates(body) }); return; } const contactMatch = urlPath.match(/^\/contacts\/(\d+)$/); if (contactMatch) { const id = parseInt(contactMatch[1]); if (method === "GET") { const contact = await store.getContact(id); if (!contact) { json(res, 404, { error: "Not found" }); return; } json(res, 200, { contact, interactions: await store.getInteractions(id), reminders: await store.getReminders(id), relationships: await store.getRelationships(id), groups: await store.getContactGroups(id), extensionFields: await store.getExtensionFields(id), }); return; } if (method === "PATCH") { const body = JSON.parse(await readBody(req)); let company_id = body.company_id; if (body.company_name && company_id === undefined) { const companies = await store.getCompanies(body.company_name); if (companies.length > 0) { company_id = companies[0].id; } else if (body.company_name) { company_id = (await store.createCompany({ name: body.company_name })).id; } } const contact = await store.updateContact(id, { ...body, company_id }); if (!contact) { json(res, 404, { error: "Not found" }); return; } json(res, 200, contact); return; } if (method === "DELETE") { json(res, 200, { ok: await store.deleteContact(id) }); return; } } if (method === "POST" && urlPath === "/contacts") { const body = JSON.parse(await readBody(req)); if (!body.first_name) { json(res, 400, { error: "first_name is required" }); return; } let company_id = body.company_id; if (body.company_name && !company_id) { const companies = await store.getCompanies(body.company_name); if (companies.length > 0) { company_id = companies[0].id; } else { company_id = (await store.createCompany({ name: body.company_name })).id; } } json(res, 201, await store.createContact({ ...body, company_id })); return; } // ── Companies ─────────────────────────────────────── if (method === "GET" && urlPath === "/companies") { const search = url.searchParams.get("q") ?? undefined; json(res, 200, await store.getCompanies(search)); return; } const companyMatch = urlPath.match(/^\/companies\/(\d+)$/); if (companyMatch) { const id = parseInt(companyMatch[1]); if (method === "PATCH") { const body = JSON.parse(await readBody(req)); if (body.website !== undefined) { try { body.website = sanitizeUrl(body.website); } catch (e: any) { json(res, 400, { error: e.message }); return; } } const co = await store.updateCompany(id, body); if (!co) { json(res, 404, { error: "Not found" }); return; } json(res, 200, co); return; } if (method === "DELETE") { json(res, 200, { ok: await store.deleteCompany(id) }); return; } } if (method === "POST" && urlPath === "/companies") { const body = JSON.parse(await readBody(req)); if (!body.name) { json(res, 400, { error: "name is required" }); return; } try { body.website = sanitizeUrl(body.website); } catch (e: any) { json(res, 400, { error: e.message }); return; } json(res, 201, await store.createCompany(body)); return; } // ── Interactions ──────────────────────────────────── if (method === "GET" && urlPath === "/interactions") { const contactId = url.searchParams.get("contact_id"); if (contactId) { json(res, 200, await store.getInteractions(parseInt(contactId))); } else { json(res, 200, await store.getAllInteractions()); } return; } if (method === "POST" && urlPath === "/interactions") { const body = JSON.parse(await readBody(req)); if (!body.contact_id || !body.interaction_type || !body.summary) { json(res, 400, { error: "contact_id, interaction_type, and summary are required" }); return; } json(res, 201, await store.createInteraction(body)); return; } const interactionMatch = urlPath.match(/^\/interactions\/(\d+)$/); if (interactionMatch && method === "DELETE") { json(res, 200, { ok: await store.deleteInteraction(parseInt(interactionMatch[1])) }); return; } // ── Reminders ─────────────────────────────────────── if (method === "GET" && urlPath === "/reminders/upcoming") { const days = parseInt(url.searchParams.get("days") ?? "30"); json(res, 200, await store.getUpcomingReminders(days)); return; } if (method === "GET" && urlPath === "/reminders") { const contactId = url.searchParams.get("contact_id"); json(res, 200, contactId ? await store.getReminders(parseInt(contactId)) : await store.getAllReminders()); return; } if (method === "POST" && urlPath === "/reminders") { const body = JSON.parse(await readBody(req)); if (!body.contact_id || !body.reminder_type || !body.reminder_date) { json(res, 400, { error: "contact_id, reminder_type, and reminder_date are required" }); return; } json(res, 201, await store.createReminder(body)); return; } const reminderMatch = urlPath.match(/^\/reminders\/(\d+)$/); if (reminderMatch && method === "DELETE") { json(res, 200, { ok: await store.deleteReminder(parseInt(reminderMatch[1])) }); return; } // ── Relationships ─────────────────────────────────── if (method === "GET" && urlPath === "/relationships") { const contactId = url.searchParams.get("contact_id"); if (!contactId) { json(res, 400, { error: "contact_id is required" }); return; } json(res, 200, await store.getRelationships(parseInt(contactId))); return; } if (method === "POST" && urlPath === "/relationships") { const body = JSON.parse(await readBody(req)); if (!body.contact_id || !body.related_contact_id || !body.relationship_type) { json(res, 400, { error: "contact_id, related_contact_id, and relationship_type are required" }); return; } json(res, 201, await store.createRelationship(body)); return; } const relMatch = urlPath.match(/^\/relationships\/(\d+)$/); if (relMatch && method === "DELETE") { json(res, 200, { ok: await store.deleteRelationship(parseInt(relMatch[1])) }); return; } // ── Groups ────────────────────────────────────────── if (method === "GET" && urlPath === "/groups") { json(res, 200, await store.getGroups()); return; } if (method === "POST" && urlPath === "/groups") { const body = JSON.parse(await readBody(req)); if (!body.name) { json(res, 400, { error: "name is required" }); return; } json(res, 201, await store.createGroup(body)); return; } const groupMembersMatch = urlPath.match(/^\/groups\/(\d+)\/members$/); if (groupMembersMatch) { const groupId = parseInt(groupMembersMatch[1]); if (method === "GET") { json(res, 200, await store.getGroupMembers(groupId)); return; } if (method === "POST") { const body = JSON.parse(await readBody(req)); if (!body.contact_id) { json(res, 400, { error: "contact_id is required" }); return; } const ok = await store.addGroupMember(groupId, body.contact_id); json(res, ok ? 201 : 200, { ok }); return; } } const groupMemberMatch = urlPath.match(/^\/groups\/(\d+)\/members\/(\d+)$/); if (groupMemberMatch && method === "DELETE") { json(res, 200, { ok: await store.removeGroupMember(parseInt(groupMemberMatch[1]), parseInt(groupMemberMatch[2])) }); return; } const groupMatch = urlPath.match(/^\/groups\/(\d+)$/); if (groupMatch && method === "DELETE") { json(res, 200, { ok: await store.deleteGroup(parseInt(groupMatch[1])) }); return; } // ── Extension Fields ──────────────────────────────── // GET /contacts/:id/extension-fields[?source=...] const extFieldsMatch = urlPath.match(/^\/contacts\/(\d+)\/extension-fields$/); if (extFieldsMatch && method === "GET") { const contactId = parseInt(extFieldsMatch[1]); const source = url.searchParams.get("source"); json(res, 200, source ? await store.getExtensionFieldsBySource(contactId, source) : await store.getExtensionFields(contactId)); return; } // PUT /contacts/:id/extension-fields — upsert a field if (extFieldsMatch && method === "PUT") { const contactId = parseInt(extFieldsMatch[1]); const body = JSON.parse(await readBody(req)); if (!body.source || !body.field_name || body.field_value == null) { json(res, 400, { error: "source, field_name, and field_value are required" }); return; } if (body.field_type && !VALID_EXTENSION_FIELD_TYPES.includes(body.field_type)) { json(res, 400, { error: `Invalid field_type — must be one of: ${VALID_EXTENSION_FIELD_TYPES.join(", ")}` }); return; } json(res, 200, await store.setExtensionField({ ...body, contact_id: contactId })); return; } // DELETE /contacts/:id/extension-fields?source=... if (extFieldsMatch && method === "DELETE") { const contactId = parseInt(extFieldsMatch[1]); const source = url.searchParams.get("source"); if (!source) { json(res, 400, { error: "source query param is required" }); return; } json(res, 200, { deleted: await store.deleteExtensionFields(contactId, source) }); return; } // ── Company Extension Fields ──────────────────────── // GET /companies/:id/extension-fields[?source=...] const coExtFieldsMatch = urlPath.match(/^\/companies\/(\d+)\/extension-fields$/); if (coExtFieldsMatch && method === "GET") { const companyId = parseInt(coExtFieldsMatch[1]); const source = url.searchParams.get("source"); json(res, 200, source ? await store.getCompanyExtensionFieldsBySource(companyId, source) : await store.getCompanyExtensionFields(companyId)); return; } // PUT /companies/:id/extension-fields — upsert a field if (coExtFieldsMatch && method === "PUT") { const companyId = parseInt(coExtFieldsMatch[1]); const body = JSON.parse(await readBody(req)); if (!body.source || !body.field_name || body.field_value == null) { json(res, 400, { error: "source, field_name, and field_value are required" }); return; } if (body.field_type && !VALID_EXTENSION_FIELD_TYPES.includes(body.field_type)) { json(res, 400, { error: `Invalid field_type — must be one of: ${VALID_EXTENSION_FIELD_TYPES.join(", ")}` }); return; } json(res, 200, await store.setCompanyExtensionField({ ...body, company_id: companyId })); return; } // DELETE /companies/:id/extension-fields?source=... if (coExtFieldsMatch && method === "DELETE") { const companyId = parseInt(coExtFieldsMatch[1]); const source = url.searchParams.get("source"); if (!source) { json(res, 400, { error: "source query param is required" }); return; } json(res, 200, { deleted: await store.deleteCompanyExtensionFields(companyId, source) }); return; } // 404 json(res, 404, { error: "Not found" }); } catch (err: any) { json(res, 500, { error: err.message }); } } // ── Standalone Server ─────────────────────────────────────────── /** * Start a standalone CRM web server. Returns the URL. * Routes /api/crm/* to the API handler, everything else to the page handler. */ export function startStandaloneServer(port: number = 4100): string { if (standaloneServer) stopStandaloneServer(); const API_PREFIX = "/api/crm"; standaloneServer = http.createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://localhost:${port}`); if (url.pathname.startsWith(API_PREFIX + "/") || url.pathname === API_PREFIX) { const subPath = url.pathname.slice(API_PREFIX.length) || "/"; await handleCrmApi(req, res, subPath); } else { await handleCrmPage(req, res, url.pathname); } }); standaloneServer.listen(port); standalonePort = port; return `http://localhost:${port}`; } /** * Stop the standalone CRM web server. Returns true if a server was running. */ export function stopStandaloneServer(): boolean { if (!standaloneServer) return false; standaloneServer.closeAllConnections(); standaloneServer.close(); standaloneServer = null; standalonePort = null; return true; } // ── pi-webserver Integration ──────────────────────────────────── /** * Mount CRM on the shared pi-webserver via the event bus. * - Page at /crm (web:mount) * - API at /api/crm (web:mount-api) — gets pi-webserver's token auth for free */ export function mountOnWebServer(events: { emit: (event: string, data: unknown) => void }): void { events.emit("web:mount", { name: "crm", label: "Personal CRM", description: "Contact management, interactions, and reminders", prefix: "/crm", handler: handleCrmPage, }); events.emit("web:mount-api", { name: "crm-api", label: "CRM API", description: "CRM REST API", prefix: "/crm", handler: handleCrmApi, }); webServerMounted = true; } /** * Unmount CRM routes from the shared pi-webserver. */ export function unmountFromWebServer(events: { emit: (event: string, data: unknown) => void }): void { events.emit("web:unmount", { name: "crm" }); events.emit("web:unmount-api", { name: "crm-api" }); webServerMounted = false; } /** * Check if the CRM is currently mounted on pi-webserver. */ export function isMountedOnWebServer(): boolean { return webServerMounted; } // ── Backward Compatibility ────────────────────────────────────── /** @deprecated Use handleCrmApi instead */ export async function handleCrmRequest( req: http.IncomingMessage, res: http.ServerResponse, urlPath: string, ): Promise { // Legacy handler — route based on path prefix if (urlPath.startsWith("/api/crm")) { const subPath = urlPath.slice("/api/crm".length) || "/"; return handleCrmApi(req, res, subPath); } return handleCrmPage(req, res, urlPath); } /** @deprecated Use startStandaloneServer instead */ export function startCrmServer(port?: number): string { return startStandaloneServer(port); } /** @deprecated Use stopStandaloneServer instead */ export function stopCrmServer(): boolean { return stopStandaloneServer(); } // ── Helpers ───────────────────────────────────────────────────── function readBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); req.on("error", reject); }); } function json(res: http.ServerResponse, status: number, data: unknown): void { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); }