From 0c2253df1d4d94f2e7afe4d4359f0f1b2c4f63e6 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sun, 11 Oct 2026 05:09:02 +0530 Subject: [PATCH] Merge duplicate people and companies --- packages/core/records.ts | 558 ++++++++++++++++- packages/i18n/translations/en.json | 21 +- packages/operations/catalog.ts | 11 + src/app/globals.css | 160 +++++ src/components/crm-app.tsx | 44 ++ src/components/crm/crm-context.tsx | 6 +- src/components/records/company-record.tsx | 7 + src/components/records/merge-dialog.tsx | 489 +++++++++++++++ src/components/records/person-record.tsx | 7 + src/components/records/record-actions.tsx | 16 +- src/components/records/record-dialog-host.tsx | 11 + tests/merge-records.test.ts | 584 ++++++++++++++++++ tests/merge-ui.test.tsx | 200 ++++++ tests/support/crm-harness.tsx | 3 + 14 files changed, 2112 insertions(+), 5 deletions(-) create mode 100644 src/components/records/merge-dialog.tsx create mode 100644 tests/merge-records.test.ts create mode 100644 tests/merge-ui.test.tsx diff --git a/packages/core/records.ts b/packages/core/records.ts index 64f71b8..edd8cb9 100644 --- a/packages/core/records.ts +++ b/packages/core/records.ts @@ -1,18 +1,21 @@ -import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import { z } from "zod"; import type { Database } from "../database/client"; import * as s from "../database/schema"; +import { publishChange } from "./changes"; import { recordContactSubmission } from "./contact-attribution"; import { lockContactDirectory } from "./contact-history"; import { scopeSchema } from "./crm"; import { authorize, DomainError, type Principal } from "./policy"; import { assertProductActive } from "./products"; +import { tagsSchema } from "./record-tags"; import { activeCompany, assertActiveRelationships, clearApprovals, companyVisible, emailTaken, + linkedinKey, linkedinTaken, lockOrganization, personVisible, @@ -147,6 +150,34 @@ export const opportunityChangeSchema = scopeSchema value.currency !== undefined, ); +export const mergeRecordsSchema = scopeSchema.extend({ + entity: z.enum(["person", "company"]), + targetId: z.uuid(), + targetVersion: version, + sourceId: z.uuid(), + sourceVersion: version, + name: z.string().trim().min(1).max(100).optional(), + title: z.string().trim().max(150).optional(), + email: emailAddress.nullable().optional(), + otherEmails: z.array(emailAddress).max(10).optional(), + phone: z + .string() + .trim() + .max(40) + .regex(/^[+\d\s().-]*$/) + .optional(), + linkedinUrl: linkedinUrl.optional(), + companyId: z.uuid().nullable().optional(), + summary: z.string().trim().max(10000).optional(), + doNotContact: z.boolean().optional(), + timeZone: z.string().nullable().optional(), + domain: companyDomain.optional(), + description: z.string().trim().max(2000).optional(), + tags: tagsSchema.optional(), + amountMinor: amountMinor.optional(), + currency: currency.optional(), +}); + function requireWriteActor(principal: Principal) { if (principal.source === "mcp" && principal.readOnly !== false) throw new DomainError("HUMAN_ACTION_REQUIRED", 403); @@ -1013,4 +1044,529 @@ export class RecordService { return updated; }); } + + async mergeRecords( + principal: Principal, + input: z.infer, + ) { + requireWriteActor(principal); + const result = await this.db.transaction(async (tx) => { + await lockOrganization(tx, input.organizationId); + const permission = await authorize( + tx, + principal, + input.organizationId, + input.productId, + true, + ); + if (input.targetId === input.sourceId) + throw new DomainError("CONFLICT", 409); + + const writable = new Set( + permission.products.map((product) => product.id), + ); + + if (input.entity === "person") { + const [target] = await tx + .select() + .from(s.people) + .where( + and( + eq(s.people.id, input.targetId), + eq(s.people.organizationId, input.organizationId), + ), + ) + .for("update"); + const [source] = await tx + .select() + .from(s.people) + .where( + and( + eq(s.people.id, input.sourceId), + eq(s.people.organizationId, input.organizationId), + ), + ) + .for("update"); + if (!target || !source) throw new DomainError("NOT_FOUND", 404); + if ( + target.version !== input.targetVersion || + source.version !== input.sourceVersion + ) + throw new DomainError("CONFLICT", 409); + if (target.archivedAt || source.archivedAt) + throw new DomainError("RECORD_ARCHIVED", 409); + if ( + !(await personVisible( + tx, + [...writable], + input.organizationId, + target.id, + )) || + !(await personVisible( + tx, + [...writable], + input.organizationId, + source.id, + )) + ) + throw new DomainError("NOT_FOUND", 404); + + const sourceRelationships = await tx + .select() + .from(s.relationships) + .where( + and( + eq(s.relationships.organizationId, input.organizationId), + eq(s.relationships.personId, source.id), + ), + ); + const targetRelationships = await tx + .select() + .from(s.relationships) + .where( + and( + eq(s.relationships.organizationId, input.organizationId), + eq(s.relationships.personId, target.id), + ), + ); + + const blockedProducts = sourceRelationships + .map((r) => r.productId) + .filter((pId) => !writable.has(pId)); + if (blockedProducts.length > 0) throw new DomainError("FORBIDDEN", 403); + + const chosenName = input.name ?? target.name; + const chosenTitle = + input.title !== undefined + ? input.title + : target.title || source.title; + const chosenEmail = + input.email !== undefined + ? input.email + : target.email || source.email || null; + const mergedOther = [ + ...new Set([ + ...(input.otherEmails ?? target.otherEmails), + ...source.otherEmails, + ...(target.email && target.email !== chosenEmail + ? [target.email] + : []), + ...(source.email && source.email !== chosenEmail + ? [source.email] + : []), + ]), + ].filter((e) => e && e !== chosenEmail); + const chosenOtherEmails = + input.otherEmails !== undefined ? input.otherEmails : mergedOther; + const chosenPhone = + input.phone !== undefined + ? input.phone + : target.phone || source.phone; + const chosenLinkedin = + input.linkedinUrl !== undefined + ? input.linkedinUrl + : target.linkedinUrl || source.linkedinUrl; + const chosenCompanyId = + input.companyId !== undefined + ? input.companyId + : target.companyId || source.companyId || null; + const chosenSummary = + input.summary !== undefined + ? input.summary + : target.summary || source.summary; + const chosenDoNotContact = + input.doNotContact !== undefined + ? input.doNotContact + : target.doNotContact || source.doNotContact; + const chosenTimeZone = + input.timeZone !== undefined + ? input.timeZone + : target.timeZone || source.timeZone || null; + const chosenTags = + input.tags !== undefined + ? input.tags + : [...new Set([...target.tags, ...source.tags])]; + const chosenAmount = + input.amountMinor !== undefined + ? input.amountMinor + : (target.amountMinor ?? source.amountMinor); + const chosenCurrency = + input.currency ?? target.currency ?? source.currency ?? "USD"; + + const allEmails = [ + ...new Set([ + ...(chosenEmail ? [chosenEmail] : []), + ...chosenOtherEmails, + ]), + ]; + if (allEmails.length > 0) { + const emailTakenOther = await tx + .select({ id: s.people.id }) + .from(s.people) + .where( + and( + eq(s.people.organizationId, input.organizationId), + ne(s.people.id, target.id), + ne(s.people.id, source.id), + or( + inArray( + sql`lower(${s.people.email})`, + allEmails.map((e) => e.toLowerCase()), + ), + sql`${s.people.otherEmails} ?| array[${sql.join( + allEmails.map((e) => sql`${e.toLowerCase()}`), + sql`, `, + )}]::text[]`, + ), + ), + ) + .limit(1); + if (emailTakenOther.length > 0) + throw new DomainError("PERSON_EXISTS", 409); + } + + if (chosenLinkedin) { + const [linkedinTakenOther] = await tx + .select({ id: s.people.id }) + .from(s.people) + .where( + and( + eq(s.people.organizationId, input.organizationId), + ne(s.people.id, target.id), + ne(s.people.id, source.id), + ne(s.people.linkedinUrl, ""), + sql`${linkedinKey(s.people.linkedinUrl)} = ${linkedinKey(sql`${chosenLinkedin}`)}`, + ), + ) + .limit(1); + if (linkedinTakenOther) throw new DomainError("PERSON_EXISTS", 409); + } + + if (chosenCompanyId && chosenCompanyId !== target.companyId) { + await activeCompany( + tx, + [...writable], + input.organizationId, + chosenCompanyId, + ); + } + + for (const sr of sourceRelationships) { + const tr = targetRelationships.find( + (r) => r.productId === sr.productId && r.purpose === sr.purpose, + ); + if (tr) { + await tx + .update(s.conversations) + .set({ relationshipId: tr.id }) + .where(eq(s.conversations.relationshipId, sr.id)); + await tx + .update(s.actions) + .set({ relationshipId: tr.id }) + .where(eq(s.actions.relationshipId, sr.id)); + await tx + .update(s.meetings) + .set({ relationshipId: tr.id }) + .where(eq(s.meetings.relationshipId, sr.id)); + await tx + .update(s.opportunities) + .set({ relationshipId: tr.id }) + .where(eq(s.opportunities.relationshipId, sr.id)); + await tx + .update(s.internalTasks) + .set({ relationshipId: tr.id }) + .where(eq(s.internalTasks.relationshipId, sr.id)); + await tx + .update(s.touches) + .set({ relationshipId: tr.id }) + .where(eq(s.touches.relationshipId, sr.id)); + await tx + .update(s.nativeDrafts) + .set({ relationshipId: tr.id }) + .where(eq(s.nativeDrafts.relationshipId, sr.id)); + await tx + .update(s.evidence) + .set({ relationshipId: tr.id }) + .where(eq(s.evidence.relationshipId, sr.id)); + await tx + .update(s.integrationItems) + .set({ relationshipId: tr.id }) + .where(eq(s.integrationItems.relationshipId, sr.id)); + await tx + .update(s.yoduBindings) + .set({ relationshipId: tr.id }) + .where(eq(s.yoduBindings.relationshipId, sr.id)); + + const targetActiveSequences = new Set( + ( + await tx + .select({ sequenceId: s.enrollments.sequenceId }) + .from(s.enrollments) + .where( + and( + eq(s.enrollments.relationshipId, tr.id), + inArray(s.enrollments.status, ["running", "paused"]), + ), + ) + ).map((e) => e.sequenceId), + ); + const sourceEnrollments = await tx + .select() + .from(s.enrollments) + .where(eq(s.enrollments.relationshipId, sr.id)); + for (const enc of sourceEnrollments) { + if ( + targetActiveSequences.has(enc.sequenceId) && + (enc.status === "running" || enc.status === "paused") + ) { + await tx + .update(s.enrollments) + .set({ + status: "stopped", + pauseReason: null, + relationshipId: tr.id, + version: enc.version + 1, + }) + .where(eq(s.enrollments.id, enc.id)); + } else { + await tx + .update(s.enrollments) + .set({ relationshipId: tr.id }) + .where(eq(s.enrollments.id, enc.id)); + } + } + + await tx + .delete(s.relationships) + .where(eq(s.relationships.id, sr.id)); + } else { + await tx + .update(s.relationships) + .set({ personId: target.id, version: sr.version + 1 }) + .where(eq(s.relationships.id, sr.id)); + } + } + + await tx + .update(s.contactContributions) + .set({ personId: target.id }) + .where(eq(s.contactContributions.personId, source.id)); + + const [updated] = await tx + .update(s.people) + .set({ + name: chosenName, + title: chosenTitle, + email: chosenEmail, + otherEmails: chosenOtherEmails, + phone: chosenPhone, + linkedinUrl: chosenLinkedin, + companyId: chosenCompanyId, + summary: chosenSummary, + doNotContact: chosenDoNotContact, + timeZone: chosenTimeZone, + tags: chosenTags, + amountMinor: chosenAmount, + currency: chosenCurrency, + version: target.version + 1, + }) + .where( + and( + eq(s.people.id, target.id), + eq(s.people.version, target.version), + ), + ) + .returning(); + if (!updated) throw new DomainError("CONFLICT", 409); + + await tx.delete(s.people).where(eq(s.people.id, source.id)); + + await recordContactSubmission(tx, principal, { + organizationId: input.organizationId, + personId: target.id, + kind: "updated", + }); + + await tx.insert(s.changeEvents).values({ + organizationId: input.organizationId, + actorId: principal.userId, + type: "person.merged", + entityId: target.id, + }); + + if ( + (target.email ?? null) !== (updated.email ?? null) || + target.name !== updated.name || + target.title !== updated.title || + (target.companyId ?? null) !== (updated.companyId ?? null) + ) { + await clearApprovals(tx, principal, input.organizationId, target.id); + } + + return updated; + } + + // Company merge + const [target] = await tx + .select() + .from(s.companies) + .where( + and( + eq(s.companies.id, input.targetId), + eq(s.companies.organizationId, input.organizationId), + ), + ) + .for("update"); + const [source] = await tx + .select() + .from(s.companies) + .where( + and( + eq(s.companies.id, input.sourceId), + eq(s.companies.organizationId, input.organizationId), + ), + ) + .for("update"); + if (!target || !source) throw new DomainError("NOT_FOUND", 404); + if ( + target.version !== input.targetVersion || + source.version !== input.sourceVersion + ) + throw new DomainError("CONFLICT", 409); + if (target.archivedAt || source.archivedAt) + throw new DomainError("RECORD_ARCHIVED", 409); + if ( + !(await companyVisible( + tx, + [...writable], + input.organizationId, + target.id, + )) || + !(await companyVisible( + tx, + [...writable], + input.organizationId, + source.id, + )) + ) + throw new DomainError("NOT_FOUND", 404); + + const chosenName = input.name ?? target.name; + const chosenDomain = + input.domain !== undefined + ? input.domain || null + : target.domain || source.domain || null; + const chosenDescription = + input.description !== undefined + ? input.description + : target.description || source.description; + const chosenTags = + input.tags !== undefined + ? input.tags + : [...new Set([...target.tags, ...source.tags])]; + const chosenAmount = + input.amountMinor !== undefined + ? input.amountMinor + : (target.amountMinor ?? source.amountMinor); + const chosenCurrency = + input.currency ?? target.currency ?? source.currency ?? "USD"; + + if (chosenDomain) { + const [duplicateDomain] = await tx + .select({ id: s.companies.id }) + .from(s.companies) + .where( + and( + eq(s.companies.organizationId, input.organizationId), + eq(s.companies.domain, chosenDomain), + isNull(s.companies.archivedAt), + ne(s.companies.id, target.id), + ne(s.companies.id, source.id), + ), + ); + if (duplicateDomain) throw new DomainError("COMPANY_EXISTS", 409); + } + + const employees = await tx + .select({ id: s.people.id, version: s.people.version }) + .from(s.people) + .where( + and( + eq(s.people.organizationId, input.organizationId), + eq(s.people.companyId, source.id), + ), + ); + if (employees.length) { + await tx + .update(s.people) + .set({ + companyId: target.id, + version: sql`${s.people.version} + 1`, + }) + .where( + and( + eq(s.people.organizationId, input.organizationId), + eq(s.people.companyId, source.id), + ), + ); + for (const emp of employees) { + await clearApprovals(tx, principal, input.organizationId, emp.id); + } + } + + const [updated] = await tx + .update(s.companies) + .set({ + name: chosenName, + domain: chosenDomain, + description: chosenDescription, + tags: chosenTags, + amountMinor: chosenAmount, + currency: chosenCurrency, + version: target.version + 1, + }) + .where( + and( + eq(s.companies.id, target.id), + eq(s.companies.version, target.version), + ), + ) + .returning(); + if (!updated) throw new DomainError("CONFLICT", 409); + + await tx.delete(s.companies).where(eq(s.companies.id, source.id)); + + await tx.insert(s.changeEvents).values({ + organizationId: input.organizationId, + actorId: principal.userId, + type: "company.merged", + entityId: target.id, + }); + + if (target.name !== updated.name) { + const allEmployees = await tx + .select({ id: s.people.id }) + .from(s.people) + .where( + and( + eq(s.people.organizationId, input.organizationId), + eq(s.people.companyId, target.id), + ), + ); + for (const employee of allEmployees) { + await clearApprovals( + tx, + principal, + input.organizationId, + employee.id, + ); + } + } + + return updated; + }); + + publishChange(input.organizationId); + return result; + } } diff --git a/packages/i18n/translations/en.json b/packages/i18n/translations/en.json index a0c73c8..93c31b5 100644 --- a/packages/i18n/translations/en.json +++ b/packages/i18n/translations/en.json @@ -2452,5 +2452,24 @@ "edit": "Edit action reason", "original": "Original action reason", "hint": "Replace imported JSON with readable notes after reviewing the source. The original is kept, and editing clears any message approval." - } + }, + "merge": "Merge", + "mergeDuplicate": "Merge duplicate…", + "mergePerson": "Merge person", + "mergeCompany": "Merge company", + "mergeRecords": "Merge records", + "mergeRecordsSubtitle": "Review conflicting fields and pick which values to keep.", + "recordsMerged": "Records merged", + "canonicalRecord": "Canonical record (kept)", + "duplicateRecord": "Duplicate record (merged & removed)", + "selectDuplicateRecord": "Select record to merge…", + "swapCanonical": "Swap canonical record", + "mergeNotice": "Relationships, actions, conversations, meetings, and deals from both records will be moved to the canonical record. Private conversations remain private to their owners. The duplicate record will be removed.", + "mergeFieldConflict": "Conflicting field", + "mergeUseLeft": "Keep canonical value", + "mergeUseRight": "Use duplicate value", + "mergeSubmit": "Confirm & merge", + "mergeSelectPrompt": "Choose a duplicate record to merge into this one", + "cannotMergeSelf": "Cannot merge a record into itself", + "searchRecordPlaceholder": "Type to search records…" } diff --git a/packages/operations/catalog.ts b/packages/operations/catalog.ts index 954e5de..d52b9e7 100644 --- a/packages/operations/catalog.ts +++ b/packages/operations/catalog.ts @@ -149,6 +149,7 @@ import { companyArchiveSchema, companySchema, meetingSchema, + mergeRecordsSchema, opportunityChangeSchema, opportunityCreateSchema, opportunityRemovalSchema, @@ -1244,6 +1245,16 @@ export const operations: Operation[] = [ schema: companyArchiveSchema, run: (c, input) => records(c).archiveCompany(c.principal, input), }), + operation({ + api: "crm", + method: "POST", + operation: "merge-records", + name: "merge_records", + description: + "Merge duplicate person or company records into a single canonical record within the same organization. Moves relationships, conversations, actions, meetings, and deals atomically inside one transaction. Overwrites canonical fields with chosen values. Preserves private conversation ownership and records the merge in change_events.", + schema: mergeRecordsSchema, + run: (c, input) => records(c).mergeRecords(c.principal, input), + }), operation({ api: "crm", method: "POST", diff --git a/src/app/globals.css b/src/app/globals.css index 6b11d51..115d766 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6279,3 +6279,163 @@ nav.list-pagination > label:has(select) { flex: 1 1 200px; } } + +.merge-dialog { + width: min(760px, 92vw); + max-height: 88vh; + overflow-y: auto; +} +.merge-dialog-content { + display: flex; + flex-direction: column; + gap: 16px; +} +.merge-dialog-header h2 { + display: flex; + align-items: center; + gap: 8px; + margin: 0; +} +.merge-select-step { + display: flex; + flex-direction: column; + gap: 12px; +} +.candidate-list { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + max-height: 260px; + overflow-y: auto; + display: flex; + flex-direction: column; +} +.candidate-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + text-align: left; + transition: background var(--duration-fast); +} +.candidate-item:last-child { + border-bottom: none; +} +.candidate-item:hover { + background: var(--surface-hover); +} +.candidate-name { + font-weight: 500; +} +.empty-candidates { + padding: 16px; + text-align: center; + margin: 0; +} +.merge-records-header { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} +.record-header-card { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 12px; + background: var(--surface); + display: flex; + flex-direction: column; + gap: 6px; +} +.record-header-card h3 { + margin: 0; + font-size: var(--text-base); + word-break: break-word; +} +.swap-button { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--text-dense); + white-space: nowrap; +} +.change-candidate-btn { + align-self: flex-start; + font-size: var(--text-dense); + padding: 0; + text-decoration: underline; + cursor: pointer; +} +.field-comparisons-table { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + overflow: hidden; + display: flex; + flex-direction: column; +} +.comparison-row { + display: grid; + grid-template-columns: 150px 1fr; + border-bottom: 1px solid var(--border-color); + padding: 8px 12px; + align-items: center; + gap: 12px; +} +.comparison-row:last-child { + border-bottom: none; +} +.comparison-row.conflict { + background: var(--surface-subtle); +} +.field-label { + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--text-dense); + font-weight: 500; +} +.conflict-tag { + font-size: 10px; + text-transform: uppercase; + color: var(--amber); + font-weight: 600; + letter-spacing: 0.05em; +} +.field-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.field-option { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 6px 10px; + display: flex; + align-items: center; + gap: 6px; + background: var(--surface); + color: inherit; + font-size: var(--text-dense); + cursor: pointer; + text-align: left; + transition: all var(--duration-fast); +} +.field-option.selected { + border-color: var(--color-brand); + background: var(--brand-wash); + font-weight: 500; +} +.option-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.merge-notice { + font-size: var(--text-dense); + line-height: 1.5; + margin-top: 12px; +} diff --git a/src/components/crm-app.tsx b/src/components/crm-app.tsx index 9b01a21..a717b71 100644 --- a/src/components/crm-app.tsx +++ b/src/components/crm-app.tsx @@ -666,6 +666,50 @@ function CrmShell({ children }: { children: ReactNode }) { crm.setPersonDialog(true); }, }, + ...(section === "people" && + (recordId || crm.selection.selected[0] || paletteFocus.current) + ? [ + { + id: "merge-person", + title: t.mergePerson, + shortcut: "", + run: () => { + const target = + recordId || + crm.selection.selected[0] || + paletteFocus.current; + if (target) + crm.openRecordDialog({ + kind: "merge", + id: target, + entity: "person", + }); + }, + }, + ] + : []), + ...(section === "companies" && + (recordId || crm.selection.selected[0] || paletteFocus.current) + ? [ + { + id: "merge-company", + title: t.mergeCompany, + shortcut: "", + run: () => { + const target = + recordId || + crm.selection.selected[0] || + paletteFocus.current; + if (target) + crm.openRecordDialog({ + kind: "merge", + id: target, + entity: "company", + }); + }, + }, + ] + : []), ...(section === "sequences" || outreachTab === "sequences" ? [ { diff --git a/src/components/crm/crm-context.tsx b/src/components/crm/crm-context.tsx index ee51d49..16ee373 100644 --- a/src/components/crm/crm-context.tsx +++ b/src/components/crm/crm-context.tsx @@ -75,9 +75,10 @@ export type ViewVerb = | "list-layout"; export type ViewVerbs = Partial boolean>>; export interface RecordDialogState { - kind: "person" | "company" | "meeting" | "opportunity"; + kind: "person" | "company" | "meeting" | "opportunity" | "merge"; id?: string; relationshipId?: string; + entity?: "person" | "company"; } interface Origin { path: string; @@ -794,7 +795,8 @@ function useCrmState({ } export type Crm = ReturnType; -const CrmContext = createContext(null); +export type CrmContextValue = Crm; +export const CrmContext = createContext(null); function CrmState({ children, ...props }: CrmProps & { children: ReactNode }) { const crm = useCrmState(props); diff --git a/src/components/records/company-record.tsx b/src/components/records/company-record.tsx index 2053621..1a922a4 100644 --- a/src/components/records/company-record.tsx +++ b/src/components/records/company-record.tsx @@ -61,6 +61,13 @@ export function CompanyRecord({ companyId }: { companyId: string }) { crm.openRecordDialog({ kind: "company", id: active.id }) } onArchive={() => void archive.archive("company", active)} + onMerge={() => + crm.openRecordDialog({ + kind: "merge", + id: active.id, + entity: "company", + }) + } /> ) : ( void; +}) { + const crm = useCrm(); + const modal = useRef(null); + const titleId = useId(); + useModalLifecycle(modal); + + const [targetId, setTargetId] = useState(initialTargetId); + const [sourceId, setSourceId] = useState(""); + const [search, setSearch] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [fieldChoices, setFieldChoices] = useState< + Record + >({}); + + const data = crm.sourceData; + const companies = data?.companies ?? []; + + const targetPerson = + entity === "person" + ? data?.people.find((p) => p.id === targetId) + : undefined; + const targetCompany = + entity === "company" + ? data?.companies.find((c) => c.id === targetId) + : undefined; + + const candidates = + entity === "person" + ? (data?.people.filter((p) => p.id !== targetId) ?? []) + : (data?.companies.filter((c) => c.id !== targetId) ?? []); + + const filteredCandidates = candidates.filter((item) => { + if (!search.trim()) return true; + const term = search.toLowerCase(); + if (entity === "person") { + const p = item as (typeof candidates)[number] & { email?: string | null }; + return ( + p.name.toLowerCase().includes(term) || + (p.email?.toLowerCase().includes(term) ?? false) + ); + } + const c = item as (typeof candidates)[number] & { domain?: string | null }; + return ( + c.name.toLowerCase().includes(term) || + (c.domain?.toLowerCase().includes(term) ?? false) + ); + }); + + const sourcePerson = + entity === "person" + ? data?.people.find((p) => p.id === sourceId) + : undefined; + const sourceCompany = + entity === "company" + ? data?.companies.find((c) => c.id === sourceId) + : undefined; + + const targetRecord = targetPerson ?? targetCompany; + const sourceRecord = sourcePerson ?? sourceCompany; + + function swapCanonical() { + if (!sourceId) return; + const newTarget = sourceId; + const newSource = targetId; + setTargetId(newTarget); + setSourceId(newSource); + setFieldChoices({}); + } + + const comparisons: FieldComparison[] = []; + + if (entity === "person" && targetPerson && sourcePerson) { + const targetCompName = + companies.find((c) => c.id === targetPerson.companyId)?.name ?? ""; + const sourceCompName = + companies.find((c) => c.id === sourcePerson.companyId)?.name ?? ""; + + comparisons.push( + { + key: "name", + label: t.name, + targetVal: targetPerson.name, + sourceVal: sourcePerson.name, + targetDisplay: targetPerson.name || "—", + sourceDisplay: sourcePerson.name || "—", + }, + { + key: "title", + label: t.roleTitle, + targetVal: targetPerson.title, + sourceVal: sourcePerson.title, + targetDisplay: targetPerson.title || "—", + sourceDisplay: sourcePerson.title || "—", + }, + { + key: "email", + label: t.email, + targetVal: targetPerson.email ?? "", + sourceVal: sourcePerson.email ?? "", + targetDisplay: targetPerson.email || "—", + sourceDisplay: sourcePerson.email || "—", + }, + { + key: "otherEmails", + label: t.otherEmails, + targetVal: targetPerson.otherEmails.join(", "), + sourceVal: sourcePerson.otherEmails.join(", "), + targetDisplay: targetPerson.otherEmails.join(", ") || "—", + sourceDisplay: sourcePerson.otherEmails.join(", ") || "—", + }, + { + key: "phone", + label: t.phone, + targetVal: targetPerson.phone, + sourceVal: sourcePerson.phone, + targetDisplay: targetPerson.phone || "—", + sourceDisplay: sourcePerson.phone || "—", + }, + { + key: "linkedinUrl", + label: t.linkedinUrl, + targetVal: targetPerson.linkedinUrl, + sourceVal: sourcePerson.linkedinUrl, + targetDisplay: targetPerson.linkedinUrl || "—", + sourceDisplay: sourcePerson.linkedinUrl || "—", + }, + { + key: "companyId", + label: t.company, + targetVal: targetPerson.companyId ?? "", + sourceVal: sourcePerson.companyId ?? "", + targetDisplay: targetCompName || "—", + sourceDisplay: sourceCompName || "—", + }, + { + key: "summary", + label: t.summary, + targetVal: targetPerson.summary, + sourceVal: sourcePerson.summary, + targetDisplay: targetPerson.summary || "—", + sourceDisplay: sourcePerson.summary || "—", + }, + { + key: "doNotContact", + label: t.doNotContact, + targetVal: targetPerson.doNotContact, + sourceVal: sourcePerson.doNotContact, + targetDisplay: targetPerson.doNotContact + ? t.markedDoNotContact + : t.contactAllowed, + sourceDisplay: sourcePerson.doNotContact + ? t.markedDoNotContact + : t.contactAllowed, + }, + ); + } else if (entity === "company" && targetCompany && sourceCompany) { + comparisons.push( + { + key: "name", + label: t.name, + targetVal: targetCompany.name, + sourceVal: sourceCompany.name, + targetDisplay: targetCompany.name || "—", + sourceDisplay: sourceCompany.name || "—", + }, + { + key: "domain", + label: t.domain, + targetVal: targetCompany.domain ?? "", + sourceVal: sourceCompany.domain ?? "", + targetDisplay: targetCompany.domain || "—", + sourceDisplay: sourceCompany.domain || "—", + }, + { + key: "description", + label: t.description, + targetVal: targetCompany.description, + sourceVal: sourceCompany.description, + targetDisplay: targetCompany.description || "—", + sourceDisplay: sourceCompany.description || "—", + }, + ); + } + + async function handleMerge() { + if (!targetRecord || !sourceRecord) return; + setBusy(true); + setError(""); + + try { + const payload: Record = { + operation: "merge-records", + organizationId: crm.organizationId, + entity, + targetId: targetRecord.id, + targetVersion: targetRecord.version, + sourceId: sourceRecord.id, + sourceVersion: sourceRecord.version, + }; + + if (entity === "person" && targetPerson && sourcePerson) { + for (const comp of comparisons) { + const choice = + fieldChoices[comp.key] ?? + (comp.targetVal ? "target" : comp.sourceVal ? "source" : "target"); + if (choice === "source") { + if (comp.key === "name") payload.name = sourcePerson.name; + else if (comp.key === "title") payload.title = sourcePerson.title; + else if (comp.key === "email") payload.email = sourcePerson.email; + else if (comp.key === "otherEmails") + payload.otherEmails = sourcePerson.otherEmails; + else if (comp.key === "phone") payload.phone = sourcePerson.phone; + else if (comp.key === "linkedinUrl") + payload.linkedinUrl = sourcePerson.linkedinUrl; + else if (comp.key === "companyId") + payload.companyId = sourcePerson.companyId; + else if (comp.key === "summary") + payload.summary = sourcePerson.summary; + else if (comp.key === "doNotContact") + payload.doNotContact = sourcePerson.doNotContact; + } else { + if (comp.key === "name") payload.name = targetPerson.name; + else if (comp.key === "title") payload.title = targetPerson.title; + else if (comp.key === "email") payload.email = targetPerson.email; + else if (comp.key === "otherEmails") + payload.otherEmails = targetPerson.otherEmails; + else if (comp.key === "phone") payload.phone = targetPerson.phone; + else if (comp.key === "linkedinUrl") + payload.linkedinUrl = targetPerson.linkedinUrl; + else if (comp.key === "companyId") + payload.companyId = targetPerson.companyId; + else if (comp.key === "summary") + payload.summary = targetPerson.summary; + else if (comp.key === "doNotContact") + payload.doNotContact = targetPerson.doNotContact; + } + } + } else if (entity === "company" && targetCompany && sourceCompany) { + for (const comp of comparisons) { + const choice = + fieldChoices[comp.key] ?? + (comp.targetVal ? "target" : comp.sourceVal ? "source" : "target"); + if (choice === "source") { + if (comp.key === "name") payload.name = sourceCompany.name; + else if (comp.key === "domain") + payload.domain = sourceCompany.domain; + else if (comp.key === "description") + payload.description = sourceCompany.description; + } else { + if (comp.key === "name") payload.name = targetCompany.name; + else if (comp.key === "domain") + payload.domain = targetCompany.domain; + else if (comp.key === "description") + payload.description = targetCompany.description; + } + } + } + + const result = await crm.send(payload, t.recordsMerged, false); + if (result.ok) { + onClose(); + if (entity === "person") { + crm.go(personPath(targetRecord.id)); + } else { + crm.go(companyPath(targetRecord.id)); + } + } else { + setError(result.error ?? t.errors.INTERNAL_ERROR); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + +
+
+

+ + {entity === "person" ? t.mergePerson : t.mergeCompany} +

+

{t.mergeRecordsSubtitle}

+
+ + {!sourceRecord ? ( +
+ +
+ {filteredCandidates.map((c) => ( + + ))} + {filteredCandidates.length === 0 && ( +

{t.noResults}

+ )} +
+
+ ) : ( +
+
+
+ + {t.canonicalRecord} + +

{targetRecord?.name}

+
+ +
+ + {t.duplicateRecord} + +

{sourceRecord?.name}

+ +
+
+ +
+ {comparisons.map((comp) => { + const isConflict = + String(comp.targetVal).trim() !== + String(comp.sourceVal).trim(); + const defaultChoice = comp.targetVal + ? "target" + : comp.sourceVal + ? "source" + : "target"; + const currentChoice = fieldChoices[comp.key] ?? defaultChoice; + + return ( +
+
+ {comp.label} + {isConflict && ( + + {t.mergeFieldConflict} + + )} +
+
+ + +
+
+ ); + })} +
+ +
+

{t.mergeNotice}

+
+
+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + {sourceRecord && ( + + )} +
+
+
+ ); +} diff --git a/src/components/records/person-record.tsx b/src/components/records/person-record.tsx index b018205..17d5aae 100644 --- a/src/components/records/person-record.tsx +++ b/src/components/records/person-record.tsx @@ -119,6 +119,13 @@ export function PersonRecord({ personId }: { personId: string }) { busy={crm.busy} onEdit={() => crm.openRecordDialog({ kind: "person", id: person.id })} onArchive={() => void archive.archive("person", person)} + onMerge={() => + crm.openRecordDialog({ + kind: "merge", + id: person.id, + entity: "person", + }) + } />
diff --git a/src/components/records/record-actions.tsx b/src/components/records/record-actions.tsx index b85f94b..dbba3d3 100644 --- a/src/components/records/record-actions.tsx +++ b/src/components/records/record-actions.tsx @@ -1,6 +1,12 @@ "use client"; import t from "@crm/i18n/translations/en.json"; -import { Archive, ArchiveRestore, MoreHorizontal, Pencil } from "lucide-react"; +import { + Archive, + ArchiveRestore, + GitMerge, + MoreHorizontal, + Pencil, +} from "lucide-react"; import { useState } from "react"; import { DropdownMenu, MenuItem } from "../ui/dropdown-menu"; @@ -8,6 +14,7 @@ export function RecordActions({ busy, onEdit, onArchive, + onMerge, inlineEditing = false, archiveLabel = t.archive, archiveConfirm = t.inlineEditing.archiveConfirm, @@ -15,6 +22,7 @@ export function RecordActions({ busy: boolean; onEdit: () => void; onArchive: () => void; + onMerge?: () => void; inlineEditing?: boolean; archiveLabel?: string; archiveConfirm?: string; @@ -42,6 +50,12 @@ export function RecordActions({ {t.edit} )} + {onMerge && ( + + + {t.mergeDuplicate} + + )} setConfirming(true)}> {archiveLabel} diff --git a/src/components/records/record-dialog-host.tsx b/src/components/records/record-dialog-host.tsx index d306a0b..d02d28c 100644 --- a/src/components/records/record-dialog-host.tsx +++ b/src/components/records/record-dialog-host.tsx @@ -4,6 +4,7 @@ import { useCrm } from "../crm/crm-context"; import { usePersonContext } from "../crm/record-context"; import { companyPath } from "../routes"; import { EmptyState, LoadingState } from "../ui/states"; +import { MergeDialog } from "./merge-dialog"; import { RecordDialog } from "./record-dialog"; import { CompanyDialog, @@ -18,6 +19,16 @@ export function RecordDialogHost() { const data = crm.sourceData; if (!dialog || !data) return null; const close = crm.closeRecordDialog; + if (dialog.kind === "merge" && dialog.id) { + return ( + + ); + } if (dialog.kind === "person") { const person = data.people.find((item) => item.id === dialog.id); if (person && data.compact) diff --git a/tests/merge-records.test.ts b/tests/merge-records.test.ts new file mode 100644 index 0000000..1ec5440 --- /dev/null +++ b/tests/merge-records.test.ts @@ -0,0 +1,584 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { CrmService } from "../packages/core/crm"; +import type { Principal } from "../packages/core/policy"; +import { RecordService } from "../packages/core/records"; +import { createLocalDatabase } from "../packages/database/client"; +import * as s from "../packages/database/schema"; +import { demoId, demoUser, seedDemo } from "../packages/database/seed"; +import { apiOperation } from "../packages/operations/catalog"; + +let local: Awaited>; +let crm: CrmService; +let records: RecordService; + +const admin: Principal = { userId: demoUser, source: "demo" }; +const teammate: Principal = { userId: "demo-teammate", source: "session" }; +const org = demoId(1); +const otherOrg = demoId(2); +const productA = demoId(10); +const productB = demoId(11); + +beforeAll(async () => { + local = await createLocalDatabase(); + await seedDemo(local.db); + crm = new CrmService(local.db); + records = new RecordService(local.db); +}); + +afterAll(async () => { + await local.client.close(); +}); + +describe("Merge Duplicate Records", () => { + test("merging people is atomic and recorded in change_events", async () => { + // Create Person 1 (target) + const [target] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Canonical Alice", + email: "alice.canonical@example.test", + title: "VP Sales", + }) + .returning(); + + // Create Person 2 (source) + const [source] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Duplicate Alice", + email: "alice.dup@example.test", + title: "VP of Sales", + phone: "+15551234567", + }) + .returning(); + + // Target has relationship in productA + const [targetRel] = await local.db + .insert(s.relationships) + .values({ + organizationId: org, + productId: productA, + personId: target.id, + ownerId: demoUser, + purpose: "buyer", + }) + .returning(); + + // Source has relationship in productA + const [sourceRel] = await local.db + .insert(s.relationships) + .values({ + organizationId: org, + productId: productA, + personId: source.id, + ownerId: demoUser, + purpose: "buyer", + }) + .returning(); + + // Attach action and meeting to sourceRel + const [action] = await local.db + .insert(s.actions) + .values({ + organizationId: org, + productId: productA, + relationshipId: sourceRel.id, + ownerId: demoUser, + kind: "reply", + title: "Source reply action", + reason: "Source reason", + owedBy: "us", + channel: "gmail", + dueAt: new Date(), + }) + .returning(); + + const [meeting] = await local.db + .insert(s.meetings) + .values({ + organizationId: org, + productId: productA, + relationshipId: sourceRel.id, + title: "Source meeting", + startsAt: new Date(), + status: "scheduled", + }) + .returning(); + + // Perform merge + const merged = await records.mergeRecords(admin, { + organizationId: org, + entity: "person", + targetId: target.id, + targetVersion: target.version, + sourceId: source.id, + sourceVersion: source.version, + phone: "+15551234567", // picked from source + }); + + expect(merged.id).toBe(target.id); + expect(merged.name).toBe("Canonical Alice"); + expect("phone" in merged ? merged.phone : undefined).toBe("+15551234567"); + expect(merged.version).toBe(target.version + 1); + + // Source is deleted + const [sourceFound] = await local.db + .select() + .from(s.people) + .where(eq(s.people.id, source.id)); + expect(sourceFound).toBeUndefined(); + + // Source relationship was deleted and children moved to target relationship + const [movedAction] = await local.db + .select() + .from(s.actions) + .where(eq(s.actions.id, action.id)); + expect(movedAction?.relationshipId).toBe(targetRel.id); + + const [movedMeeting] = await local.db + .select() + .from(s.meetings) + .where(eq(s.meetings.id, meeting.id)); + expect(movedMeeting?.relationshipId).toBe(targetRel.id); + + // change_events contains person.merged + const [changeEvent] = await local.db + .select() + .from(s.changeEvents) + .where( + and( + eq(s.changeEvents.entityId, target.id), + eq(s.changeEvents.type, "person.merged"), + ), + ); + expect(changeEvent).toBeDefined(); + expect(changeEvent.actorId).toBe(admin.userId); + }); + + test("covers cross-product relationships", async () => { + // Person A in productA + const [personA] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Cross Product Target", + email: "target.cross@example.test", + }) + .returning(); + + const [_relA] = await local.db + .insert(s.relationships) + .values({ + organizationId: org, + productId: productA, + personId: personA.id, + ownerId: demoUser, + purpose: "buyer", + }) + .returning(); + + // Person B in productB + const [personB] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Cross Product Source", + email: "source.cross@example.test", + }) + .returning(); + + const [relB] = await local.db + .insert(s.relationships) + .values({ + organizationId: org, + productId: productB, + personId: personB.id, + ownerId: demoUser, + purpose: "buyer", + }) + .returning(); + + // Stage for opportunity in productB + const [stageB] = await local.db + .select() + .from(s.stages) + .where( + and( + eq(s.stages.organizationId, org), + eq(s.stages.productId, productB), + eq(s.stages.pipeline, "deal"), + ), + ) + .limit(1); + + const [deal] = await local.db + .insert(s.opportunities) + .values({ + organizationId: org, + productId: productB, + relationshipId: relB.id, + stageId: stageB.id, + name: "Cross Product Deal", + }) + .returning(); + + // Merge personB into personA + await records.mergeRecords(admin, { + organizationId: org, + entity: "person", + targetId: personA.id, + targetVersion: personA.version, + sourceId: personB.id, + sourceVersion: personB.version, + }); + + // Person A now has both relationships! + const targetRelationships = await local.db + .select() + .from(s.relationships) + .where(eq(s.relationships.personId, personA.id)); + + expect(targetRelationships).toHaveLength(2); + expect(targetRelationships.map((r) => r.productId).sort()).toEqual( + [productA, productB].sort(), + ); + + // relB now belongs to personA + const [updatedRelB] = await local.db + .select() + .from(s.relationships) + .where(eq(s.relationships.id, relB.id)); + expect(updatedRelB?.personId).toBe(personA.id); + + // Deal is intact and still points to relB + const [foundDeal] = await local.db + .select() + .from(s.opportunities) + .where(eq(s.opportunities.id, deal.id)); + expect(foundDeal?.relationshipId).toBe(relB.id); + }); + + test("private conversation ownership stays private after merge", async () => { + const [target] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Private Convo Target", + email: "target.private@example.test", + }) + .returning(); + + const [targetRel] = await local.db + .insert(s.relationships) + .values({ + organizationId: org, + productId: productA, + personId: target.id, + ownerId: demoUser, + purpose: "buyer", + }) + .returning(); + + const [source] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Private Convo Source", + email: "source.private@example.test", + }) + .returning(); + + const [sourceRel] = await local.db + .insert(s.relationships) + .values({ + organizationId: org, + productId: productA, + personId: source.id, + ownerId: demoUser, + purpose: "buyer", + }) + .returning(); + + // Private conversation owned by teammate + const [privateConvo] = await local.db + .insert(s.conversations) + .values({ + organizationId: org, + productId: productA, + relationshipId: sourceRel.id, + ownerId: teammate.userId, + visibility: "private", + provenance: "native", + channel: "gmail", + externalThreadId: randomUUID(), + }) + .returning(); + + // Merge source into target + await records.mergeRecords(admin, { + organizationId: org, + entity: "person", + targetId: target.id, + targetVersion: target.version, + sourceId: source.id, + sourceVersion: source.version, + }); + + // Check conversation row in database: ownership and visibility are intact + const [updatedConvo] = await local.db + .select() + .from(s.conversations) + .where(eq(s.conversations.id, privateConvo.id)); + + expect(updatedConvo?.relationshipId).toBe(targetRel.id); + expect(updatedConvo?.ownerId).toBe(teammate.userId); + expect(updatedConvo?.visibility).toBe("private"); + + // teammate can view the conversation in context + const teammateContext = await crm.context(teammate, org, targetRel.id); + expect( + teammateContext.conversations.some((c) => c.id === privateConvo.id), + ).toBe(true); + + // admin (who is not the owner) cannot view this private conversation in context + const adminContext = await crm.context(admin, org, targetRel.id); + expect( + adminContext.conversations.some((c) => c.id === privateConvo.id), + ).toBe(false); + }); + + test("refuses merge across organizations", async () => { + const [target] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Org1 Person", + email: "org1@example.test", + }) + .returning(); + + const [sourceForeign] = await local.db + .insert(s.people) + .values({ + organizationId: otherOrg, + name: "Org2 Person", + email: "org2@example.test", + }) + .returning(); + + // Attempting to merge across organizations + await expect( + records.mergeRecords(admin, { + organizationId: org, + entity: "person", + targetId: target.id, + targetVersion: target.version, + sourceId: sourceForeign.id, + sourceVersion: sourceForeign.version, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND", status: 404 }); + + // Neither record was deleted or modified + const [stillTarget] = await local.db + .select() + .from(s.people) + .where(eq(s.people.id, target.id)); + expect(stillTarget?.version).toBe(target.version); + + const [stillSource] = await local.db + .select() + .from(s.people) + .where(eq(s.people.id, sourceForeign.id)); + expect(stillSource?.version).toBe(sourceForeign.version); + }); + + test("merges duplicate companies and repoints employees", async () => { + const [companyA] = await local.db + .insert(s.companies) + .values({ + organizationId: org, + name: "Acme Corp Canonical", + domain: "acme.com", + description: "Canonical description", + }) + .returning(); + + const [companyB] = await local.db + .insert(s.companies) + .values({ + organizationId: org, + name: "Acme Corp Duplicate", + domain: "acme-corp.com", + description: "Secondary description", + }) + .returning(); + + // Person linked to companyB + const [employee] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Acme Employee", + email: "emp@acme-corp.com", + companyId: companyB.id, + }) + .returning(); + + await local.db.insert(s.relationships).values({ + organizationId: org, + productId: productA, + personId: employee.id, + ownerId: demoUser, + purpose: "buyer", + }); + + // Merge companyB into companyA + const merged = await records.mergeRecords(admin, { + organizationId: org, + entity: "company", + targetId: companyA.id, + targetVersion: companyA.version, + sourceId: companyB.id, + sourceVersion: companyB.version, + description: "Merged description", + }); + + expect(merged.id).toBe(companyA.id); + expect(merged.name).toBe("Acme Corp Canonical"); + expect("description" in merged ? merged.description : undefined).toBe( + "Merged description", + ); + expect(merged.version).toBe(companyA.version + 1); + + // companyB was deleted + const [bFound] = await local.db + .select() + .from(s.companies) + .where(eq(s.companies.id, companyB.id)); + expect(bFound).toBeUndefined(); + + // Employee was repointed to companyA + const [updatedEmp] = await local.db + .select() + .from(s.people) + .where(eq(s.people.id, employee.id)); + expect(updatedEmp?.companyId).toBe(companyA.id); + + // change_events contains company.merged + const [changeEvent] = await local.db + .select() + .from(s.changeEvents) + .where( + and( + eq(s.changeEvents.entityId, companyA.id), + eq(s.changeEvents.type, "company.merged"), + ), + ); + expect(changeEvent).toBeDefined(); + }); + + test("rejects self-merge and version conflicts", async () => { + const [person] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Self Merge Person", + email: "self.merge@example.test", + }) + .returning(); + + // Self merge + await expect( + records.mergeRecords(admin, { + organizationId: org, + entity: "person", + targetId: person.id, + targetVersion: person.version, + sourceId: person.id, + sourceVersion: person.version, + }), + ).rejects.toMatchObject({ code: "CONFLICT", status: 409 }); + + const [second] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Second Person", + email: "second@example.test", + }) + .returning(); + + // Stale version + await expect( + records.mergeRecords(admin, { + organizationId: org, + entity: "person", + targetId: person.id, + targetVersion: person.version + 99, + sourceId: second.id, + sourceVersion: second.version, + }), + ).rejects.toMatchObject({ code: "CONFLICT", status: 409 }); + }); + + test("merge operation is executable via catalog operation", async () => { + const [p1] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Op Target", + email: "op.target@example.test", + }) + .returning(); + + const [p2] = await local.db + .insert(s.people) + .values({ + organizationId: org, + name: "Op Source", + email: "op.source@example.test", + }) + .returning(); + + await local.db.insert(s.relationships).values([ + { + organizationId: org, + productId: productA, + personId: p1.id, + ownerId: demoUser, + purpose: "buyer", + }, + { + organizationId: org, + productId: productA, + personId: p2.id, + ownerId: demoUser, + purpose: "buyer", + }, + ]); + + const op = apiOperation("crm", "POST", "merge-records"); + expect(op.name).toBe("merge_records"); + + const result = (await op.execute( + { db: local.db, principal: admin }, + { + operation: "merge-records", + organizationId: org, + entity: "person", + targetId: p1.id, + targetVersion: p1.version, + sourceId: p2.id, + sourceVersion: p2.version, + name: "Op Combined", + }, + )) as typeof s.people.$inferSelect; + + expect(result.id).toBe(p1.id); + expect(result.name).toBe("Op Combined"); + }); +}); diff --git a/tests/merge-ui.test.tsx b/tests/merge-ui.test.tsx new file mode 100644 index 0000000..3a696a3 --- /dev/null +++ b/tests/merge-ui.test.tsx @@ -0,0 +1,200 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { demoId } from "../packages/database/seed"; +import t from "../packages/i18n/translations/en.json"; +import { + CrmContext, + type CrmContextValue, +} from "../src/components/crm/crm-context"; +import { MergeDialog } from "../src/components/records/merge-dialog"; + +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function () { + this.open = true; + }; + HTMLDialogElement.prototype.close = function () { + this.open = false; + }; +}); + +afterEach(() => { + cleanup(); +}); + +const personA = { + id: demoId(201), + name: "Alice Canonical", + title: "VP of Product", + email: "alice.canonical@example.test", + otherEmails: ["alice.home@example.test"], + phone: "+1 555 0101", + linkedinUrl: "https://linkedin.com/in/alice-canonical", + companyId: demoId(101), + summary: "Canonical summary notes.", + doNotContact: false, + version: 1, +}; + +const personB = { + id: demoId(202), + name: "Alice Duplicate", + title: "Head of Product", + email: "alice.dup@example.test", + otherEmails: [], + phone: "+1 555 0199", + linkedinUrl: "", + companyId: null, + summary: "Secondary notes.", + doNotContact: true, + version: 2, +}; + +const mockSourceData = { + people: [personA, personB], + companies: [ + { + id: demoId(101), + name: "Acme Corp", + domain: "acme.com", + description: "Acme Corp description", + version: 1, + }, + ], + relationships: [], + actions: [], + meetings: [], + opportunities: [], + archived: { people: [], companies: [], opportunities: [], sequences: [] }, + compact: false, +} as unknown as CrmContextValue["sourceData"]; + +function renderMergeDialog(overrides: Partial = {}) { + const send = vi.fn().mockResolvedValue({ ok: true }); + const go = vi.fn(); + const close = vi.fn(); + + const ctx: CrmContextValue = { + organizationId: demoId(1), + productId: demoId(10), + sourceData: mockSourceData, + busy: false, + send, + go, + ...overrides, + } as unknown as CrmContextValue; + + render( + + + , + ); + + return { send, go, close }; +} + +describe("MergeDialog Review Screen UI", () => { + test("shows candidate selection and filters candidates", async () => { + renderMergeDialog(); + + expect(screen.getByText(t.mergePerson)).toBeDefined(); + expect(screen.getByText(t.selectDuplicateRecord)).toBeDefined(); + expect(screen.getByText("Alice Duplicate")).toBeDefined(); + + // Filter candidate + const searchInput = screen.getByPlaceholderText(t.searchRecordPlaceholder); + fireEvent.change(searchInput, { target: { value: "Nonexistent" } }); + expect(screen.getByText(t.noResults)).toBeDefined(); + + fireEvent.change(searchInput, { target: { value: "Duplicate" } }); + expect(screen.getByText("Alice Duplicate")).toBeDefined(); + }); + + test("side-by-side review screen highlights conflicts and lets user choose values", async () => { + const { send, close, go } = renderMergeDialog(); + + // Select Person B as duplicate + fireEvent.click(screen.getByText("Alice Duplicate")); + + // Review screen is displayed + expect(screen.getByText(t.canonicalRecord)).toBeDefined(); + expect(screen.getByText(t.duplicateRecord)).toBeDefined(); + expect( + screen.getByRole("heading", { name: "Alice Canonical" }), + ).toBeDefined(); + + // Notice about relationships and private conversations + expect(screen.getByText(t.mergeNotice)).toBeDefined(); + + // Title conflict: Alice Canonical ("VP of Product") vs Alice Duplicate ("Head of Product") + expect(screen.getByText("VP of Product")).toBeDefined(); + const headOfProductOption = screen + .getByText("Head of Product") + .closest("button"); + expect(headOfProductOption).toBeDefined(); + + // Pick Head of Product from duplicate + if (headOfProductOption) fireEvent.click(headOfProductOption); + + // Click confirm & merge + const submitBtn = screen.getByRole("button", { name: t.mergeSubmit }); + fireEvent.click(submitBtn); + + await waitFor(() => expect(send).toHaveBeenCalledOnce()); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "merge-records", + organizationId: demoId(1), + entity: "person", + targetId: personA.id, + targetVersion: 1, + sourceId: personB.id, + sourceVersion: 2, + title: "Head of Product", + }), + t.recordsMerged, + false, + ); + + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(go).toHaveBeenCalledWith(`/people/${personA.id}`); + }); + + test("allows swapping canonical record", async () => { + const { send } = renderMergeDialog(); + + // Select Person B as duplicate + fireEvent.click(screen.getByText("Alice Duplicate")); + + // Click Swap + const swapBtn = screen.getByRole("button", { name: t.swapCanonical }); + fireEvent.click(swapBtn); + + // Person B is now canonical + const submitBtn = screen.getByRole("button", { name: t.mergeSubmit }); + fireEvent.click(submitBtn); + + await waitFor(() => expect(send).toHaveBeenCalledOnce()); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "merge-records", + targetId: personB.id, + targetVersion: 2, + sourceId: personA.id, + sourceVersion: 1, + }), + t.recordsMerged, + false, + ); + }); +}); diff --git a/tests/support/crm-harness.tsx b/tests/support/crm-harness.tsx index 66c020d..fd06c42 100644 --- a/tests/support/crm-harness.tsx +++ b/tests/support/crm-harness.tsx @@ -35,6 +35,7 @@ import { companyArchiveSchema, companySchema, meetingSchema, + mergeRecordsSchema, opportunityChangeSchema, opportunityCreateSchema, personArchiveSchema, @@ -289,6 +290,8 @@ async function respond(harness: Harness, url: string, init?: RequestInit) { principal, opportunityChangeSchema.parse(body), ), + "merge-records": () => + records.mergeRecords(principal, mergeRecordsSchema.parse(body)), }; const run = operations[body.operation]; if (run) return serialize(await run());