Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions internal/store/contactmerge.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,60 @@ func (s *Store) MergeCandidates(ctx context.Context, resolver contacts.Resolver)
return out, nil
}

// MergedContact is one contact carrying more than one identifier — the unit
// the split UI (issue #12) works over. A single-identifier contact has nothing
// to pull apart, so only multi-identifier contacts are listed: they are the
// ones an auto rule or a manual merge has unified and that a mistaken merge can
// be split back out of.
type MergedContact struct {
ID int64
DisplayName string
Identifiers []ContactIdentifier
}

// MergedContacts returns every contact that holds at least two identifiers,
// each with its identifiers (ordered by source then value), ordered by display
// name. These are exactly the contacts the split UI can act on — a contact with
// one identifier cannot be split — so the settings surface lists them as the
// review set for undoing an incorrect merge.
func (s *Store) MergedContacts(ctx context.Context) ([]MergedContact, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT c.id, c.display_name, ci.source, ci.identifier
FROM contacts c
JOIN contact_identifiers ci ON ci.contact_id = c.id
ORDER BY c.display_name COLLATE NOCASE, c.id, ci.source, ci.identifier`)
if err != nil {
return nil, fmt.Errorf("merged contacts: %w", err)
}
defer rows.Close()
var out []MergedContact
var cur *MergedContact
for rows.Next() {
var id int64
var name string
var ci ContactIdentifier
if err := rows.Scan(&id, &name, &ci.Source, &ci.Identifier); err != nil {
return nil, err
}
if cur == nil || cur.ID != id {
out = append(out, MergedContact{ID: id, DisplayName: name})
cur = &out[len(out)-1]
}
cur.Identifiers = append(cur.Identifiers, ci)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Drop single-identifier contacts: nothing to split.
filtered := out[:0]
for _, mc := range out {
if len(mc.Identifiers) >= 2 {
filtered = append(filtered, mc)
}
}
return filtered, nil
}

// MergeContacts unions two contacts into one person (issue #11 / REQ-0015-005):
// it records the full bipartite pairing of their identifiers as manual merge
// decisions (so the merge survives re-ingest), repoints the loser's
Expand Down
38 changes: 38 additions & 0 deletions internal/store/contactmerge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,44 @@ func TestMergeCandidatesExcludesSplitPairs(t *testing.T) {
}
}

func TestMergedContactsListsMultiIdentifierOnly(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()

// A lone single-identifier contact (never merged): excluded.
if _, err := st.UpsertConversation(ctx, source.Signal, "Solo"); err != nil {
t.Fatal(err)
}

// A merged, multi-identifier person: included.
sig, _ := st.UpsertConversation(ctx, source.Signal, "MJ")
im, _ := st.UpsertConversation(ctx, source.IMessage, "+15551234567")
winner, err := st.MergeContacts(ctx, contactIDOf(t, st, sig), contactIDOf(t, st, im))
if err != nil {
t.Fatal(err)
}
renameContact(t, st, winner, "Mary Jane")

got, err := st.MergedContacts(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("merged contacts = %d, want 1 (single-identifier contact excluded): %+v", len(got), got)
}
mc := got[0]
if mc.ID != winner || mc.DisplayName != "Mary Jane" {
t.Fatalf("merged contact = %+v, want id %d name Mary Jane", mc, winner)
}
if len(mc.Identifiers) != 2 {
t.Fatalf("merged identifiers = %+v, want 2", mc.Identifiers)
}
// Ordered by source then value: imessage before signal.
if mc.Identifiers[0].Source != source.IMessage || mc.Identifiers[1].Source != source.Signal {
t.Errorf("identifier order = %+v, want imessage then signal", mc.Identifiers)
}
}

// putTestFact inserts a minimal fact for a contact.
func putTestFact(t *testing.T, st *Store, contactID int64, fact string) {
t.Helper()
Expand Down
Loading
Loading