Skip to content

Commit 0bc499f

Browse files
committed
Harden contact trust and merge workflows
1 parent 7a2562e commit 0bc499f

6 files changed

Lines changed: 241 additions & 9 deletions

File tree

src/app/actions/contacts.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export async function updateContactAction(formData: FormData) {
8282
export async function mergeContactsAction(formData: FormData) {
8383
const sourceContactId = String(formData.get("sourceContactId") ?? "");
8484
const targetContactId = String(formData.get("targetContactId") ?? "");
85+
const confirmed = String(formData.get("confirmMerge") ?? "") === "yes";
8586
const returnTo = getReturnTo(formData, "/contacts");
8687

8788
if (!sourceContactId || !targetContactId) {
@@ -93,6 +94,7 @@ export async function mergeContactsAction(formData: FormData) {
9394
try {
9495
slug = await mergeContacts({
9596
actorUserId: await getAuditActorUserId(),
97+
confirmed,
9698
sourceContactId,
9799
targetContactId,
98100
});
@@ -109,6 +111,7 @@ export async function mergeContactsAction(formData: FormData) {
109111

110112
export async function splitContactAction(formData: FormData) {
111113
const sourceContactId = String(formData.get("sourceContactId") ?? "");
114+
const confirmed = String(formData.get("confirmSplit") ?? "") === "yes";
112115
const returnTo = getReturnTo(formData, "/contacts");
113116
const identityIds = formData
114117
.getAll("identityIds")
@@ -129,6 +132,7 @@ export async function splitContactAction(formData: FormData) {
129132
const created = await splitContact({
130133
actorUserId: await getAuditActorUserId(),
131134
company: String(formData.get("company") ?? ""),
135+
confirmed,
132136
displayName: String(formData.get("displayName") ?? ""),
133137
identityIds,
134138
primaryEmail: String(formData.get("primaryEmail") ?? ""),

src/app/actions/reviews.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,15 @@ export async function resolveMergeReviewAction(formData: FormData) {
3535
) as Partial<
3636
Record<"company" | "displayName" | "primaryEmail" | "title", "current" | "proposed">
3737
>;
38+
const bulkDecision = String(formData.get("bulkDecision") ?? "");
3839

3940
try {
4041
await resolveMergeReview({
4142
actorUserId: await getAuditActorUserId(),
43+
bulkDecision:
44+
bulkDecision === "current" || bulkDecision === "proposed"
45+
? bulkDecision
46+
: undefined,
4247
decisions,
4348
reviewId,
4449
});

src/components/app/contacts/contact-workspace-view.tsx

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Badge } from "@/components/ui/badge";
1818
import { Button } from "@/components/ui/button";
1919
import {
2020
getContactBySlug,
21+
getContactMergeImpact,
2122
listContactTimeline,
2223
listPotentialDuplicateContacts,
2324
} from "@/lib/contacts";
@@ -37,6 +38,9 @@ type AvailableSequence = Awaited<
3738
type DuplicateCandidate = Awaited<
3839
ReturnType<typeof listPotentialDuplicateContacts>
3940
>[number];
41+
type MergeImpact = NonNullable<
42+
Awaited<ReturnType<typeof getContactMergeImpact>>
43+
>;
4044
type Enrollment = Awaited<
4145
ReturnType<typeof listContactSequenceEnrollments>
4246
>[number];
@@ -55,6 +59,10 @@ function confidenceVariant(confidence: DuplicateCandidate["confidence"]) {
5559
return "outline";
5660
}
5761

62+
function impactPillLabel(label: string, value: number) {
63+
return `${value} ${label}`;
64+
}
65+
5866
function formatDueAt(value: Date | null) {
5967
if (!value) {
6068
return "No due date";
@@ -511,7 +519,10 @@ function ContactEnrollmentsPanel(props: {
511519

512520
function DuplicateCandidatesPanel(props: {
513521
contact: ContactWorkspaceContact;
514-
duplicateCandidates: DuplicateCandidate[];
522+
duplicateCandidates: Array<{
523+
candidate: DuplicateCandidate;
524+
mergeImpact: MergeImpact | null;
525+
}>;
515526
}) {
516527
return (
517528
<DashboardPanel>
@@ -526,7 +537,7 @@ function DuplicateCandidatesPanel(props: {
526537
No duplicate candidates detected for this contact right now.
527538
</p>
528539
) : (
529-
props.duplicateCandidates.map((candidate) => (
540+
props.duplicateCandidates.map(({ candidate, mergeImpact }) => (
530541
<div
531542
key={candidate.id}
532543
className="rounded-2xl border border-border/85 bg-background/75 px-4 py-4"
@@ -556,6 +567,25 @@ function DuplicateCandidatesPanel(props: {
556567
))}
557568
</div>
558569
</div>
570+
{mergeImpact ? (
571+
<div className="mt-3 flex flex-wrap gap-2">
572+
<Badge variant="secondary">
573+
{impactPillLabel("identities", mergeImpact.source.impact.identities)}
574+
</Badge>
575+
<Badge variant="secondary">
576+
{impactPillLabel("sources", mergeImpact.source.impact.sources)}
577+
</Badge>
578+
<Badge variant="secondary">
579+
{impactPillLabel("tasks", mergeImpact.source.impact.tasks)}
580+
</Badge>
581+
<Badge variant="secondary">
582+
{impactPillLabel(
583+
"enrollments",
584+
mergeImpact.source.impact.enrollments,
585+
)}
586+
</Badge>
587+
</div>
588+
) : null}
559589
<div className="mt-3 flex flex-wrap gap-3">
560590
<form action={mergeContactsAction}>
561591
<input
@@ -573,6 +603,10 @@ function DuplicateCandidatesPanel(props: {
573603
name="targetContactId"
574604
value={props.contact.id}
575605
/>
606+
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
607+
<input type="checkbox" name="confirmMerge" value="yes" />
608+
Confirm merge and move linked history into this contact
609+
</label>
576610
<Button type="submit" variant="outline">
577611
Merge into this contact
578612
</Button>
@@ -873,6 +907,15 @@ function ContactSplitPanel(props: { contact: ContactWorkspaceContact }) {
873907
</div>
874908
</div>
875909

910+
<p className="text-sm text-muted-foreground">
911+
Safety rule: the source contact must keep at least one identity or source
912+
anchor after the split.
913+
</p>
914+
915+
<label className="flex items-center gap-2 text-sm text-muted-foreground">
916+
<input type="checkbox" name="confirmSplit" value="yes" />
917+
Confirm split and move the selected identities and sources
918+
</label>
876919
<Button type="submit" variant="outline">
877920
Create split contact
878921
</Button>
@@ -939,6 +982,15 @@ export async function ContactWorkspaceView(props: {
939982
listReplySignalsForContact(contact.id),
940983
listContactTimeline(contact.id),
941984
]);
985+
const duplicateCandidatesWithImpact = await Promise.all(
986+
duplicateCandidates.map(async (candidate) => ({
987+
candidate,
988+
mergeImpact: await getContactMergeImpact({
989+
sourceContactId: candidate.id,
990+
targetContactId: contact.id,
991+
}),
992+
})),
993+
);
942994

943995
return (
944996
<div className="space-y-6">
@@ -961,10 +1013,10 @@ export async function ContactWorkspaceView(props: {
9611013
contactSlug={contact.slug}
9621014
enrollments={enrollments}
9631015
/>
964-
<DuplicateCandidatesPanel
965-
contact={contact}
966-
duplicateCandidates={duplicateCandidates}
967-
/>
1016+
<DuplicateCandidatesPanel
1017+
contact={contact}
1018+
duplicateCandidates={duplicateCandidatesWithImpact}
1019+
/>
9681020
<ReplyHistoryPanel replyHistory={replyHistory} />
9691021
<MergeReviewsPanel contact={contact} />
9701022
<FollowUpTasksPanel contact={contact} />

src/components/app/reviews/reviews-page-view.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ function MergeReviewsPanel(props: {
321321
<p className="text-sm text-muted-foreground">
322322
{review.contact?.primaryEmail ?? "No primary email"}
323323
</p>
324+
<p className="text-sm text-muted-foreground">
325+
{review.sourceLabel ?? review.sourceRef}
326+
</p>
324327
</div>
325328
<p className="text-sm text-muted-foreground">
326329
Last seen {review.lastSeenAt.toLocaleString()}
@@ -383,6 +386,22 @@ function MergeReviewsPanel(props: {
383386

384387
<div className="flex flex-wrap gap-3">
385388
<Button type="submit">Resolve review</Button>
389+
<Button
390+
type="submit"
391+
name="bulkDecision"
392+
value="proposed"
393+
variant="outline"
394+
>
395+
Use proposed for all
396+
</Button>
397+
<Button
398+
type="submit"
399+
name="bulkDecision"
400+
value="current"
401+
variant="outline"
402+
>
403+
Keep current for all
404+
</Button>
386405
{review.contact?.slug ? (
387406
<Button
388407
render={<Link href={`/contacts/${review.contact.slug}`} />}

0 commit comments

Comments
 (0)