package discovery import ( "context" "fmt" "strings" ) const ( DefaultOtherGroupsPageSize = 12 MaxOtherGroupsPageSize = 50 ) type OtherProfileGroupsPage struct { Groups []ProfileGroup `json:"groups"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` HasMore bool `json:"has_more"` } const otherProfilesBaseWhere = ` FROM contact_discovered_profiles p JOIN users u ON p.user_id = u.id WHERE u.external_id = $1 AND p.status = 'suggested' AND NOT p.is_mailing_list AND NOT p.is_disposable AND NOT p.is_spam_heavy` + noReplyProfilesSQL + suggestableProfilesSQL func otherProfileTokenMatchesParam(param string) string { return fmt.Sprintf(`( position(%[1]s in lower(p.display_name)) > 0 OR position(%[1]s in lower(p.primary_email)) > 0 OR position(%[1]s in lower(COALESCE(p.enriched_data->>'first_name', ''))) > 0 OR position(%[1]s in lower(COALESCE(p.enriched_data->>'last_name', ''))) > 0 OR position(%[1]s in lower(COALESCE(p.enriched_data->>'company', ''))) > 0 OR position(%[1]s in lower(COALESCE(p.enriched_data->>'department', ''))) > 0 OR position(%[1]s in lower(COALESCE(p.enriched_data->>'job_title', ''))) > 0 OR position(%[1]s in lower(COALESCE(p.enriched_data->>'website', ''))) > 0 OR position(%[1]s in lower(COALESCE(p.enriched_data->>'notes', ''))) > 0 OR EXISTS ( SELECT 1 FROM jsonb_array_elements(COALESCE(p.all_emails, '[]'::jsonb)) AS e WHERE position(%[1]s in lower(COALESCE(e->>'email', ''))) > 0 OR position(%[1]s in lower(COALESCE(e->>'display_name', ''))) > 0 ) OR EXISTS ( SELECT 1 FROM jsonb_array_elements(COALESCE(p.enriched_data->'emails', '[]'::jsonb)) AS ee WHERE position(%[1]s in lower(COALESCE(ee->>'value', ''))) > 0 ) OR EXISTS ( SELECT 1 FROM jsonb_array_elements(COALESCE(p.enriched_data->'phones', '[]'::jsonb)) AS ph WHERE position(%[1]s in lower(COALESCE(ph->>'value', ''))) > 0 ) OR EXISTS ( SELECT 1 FROM jsonb_array_elements(COALESCE(p.detected_in_accounts, '[]'::jsonb)) AS a WHERE position(%[1]s in lower(COALESCE(a->>'account_email', ''))) > 0 OR position(%[1]s in lower(COALESCE(a->>'account_name', ''))) > 0 ) )`, param) } func otherProfileSearchClause(search string, paramIndex int) (clause string, terms []string) { tokens := discoveryQueryTokens(search) if len(tokens) == 0 { return "", nil } parts := make([]string, 0, len(tokens)) for i, token := range tokens { parts = append(parts, otherProfileTokenMatchesParam(fmt.Sprintf("$%d", paramIndex+i))) terms = append(terms, token) } return " AND " + strings.Join(parts, " AND "), terms } func (s *Service) countOtherProfileGroups(ctx context.Context, externalUserID string, search string) (int, error) { searchClause, searchTerms := otherProfileSearchClause(search, 2) args := []any{externalUserID} for _, term := range searchTerms { args = append(args, term) } var total int err := s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM ( SELECT DISTINCT COALESCE(p.person_group_id, p.id) AS grp_id `+otherProfilesBaseWhere+searchClause+` ) g `, args...).Scan(&total) return total, err } func (s *Service) listOtherProfileGroupIDs(ctx context.Context, externalUserID string, limit, offset int, search string) ([]string, error) { searchClause, searchTerms := otherProfileSearchClause(search, 2) args := []any{externalUserID} limitIdx := 2 offsetIdx := 3 for _, term := range searchTerms { args = append(args, term) limitIdx++ offsetIdx++ } args = append(args, limit, offset) rows, err := s.db.Query(ctx, fmt.Sprintf(` SELECT grp_id::text FROM ( SELECT COALESCE(p.person_group_id, p.id) AS grp_id, SUM(p.outbound_count) AS total_outbound, SUM(p.inbound_from_cc_count) AS total_inbound, SUM(p.copresence_cc_bcc_count) AS total_copresence, SUM(p.message_count) AS total_messages, MAX(p.last_message_at) AS last_message_at `+otherProfilesBaseWhere+searchClause+` GROUP BY COALESCE(p.person_group_id, p.id) ) ranked ORDER BY `+groupInteractionOrderBy+` LIMIT $%d OFFSET $%d `, limitIdx, offsetIdx), args...) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } func (s *Service) listAllOtherProfileGroupIDs(ctx context.Context, externalUserID string, search string) ([]string, error) { searchClause, searchTerms := otherProfileSearchClause(search, 2) args := []any{externalUserID} for _, term := range searchTerms { args = append(args, term) } rows, err := s.db.Query(ctx, ` SELECT DISTINCT COALESCE(p.person_group_id, p.id)::text AS grp_id `+otherProfilesBaseWhere+searchClause+` `, args...) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } func (s *Service) listOtherProfilesForGroupIDs(ctx context.Context, externalUserID string, groupIDs []string) ([]Profile, error) { if len(groupIDs) == 0 { return nil, nil } rows, err := s.db.Query(ctx, ` SELECT `+profileSelectColumns+` `+otherProfilesBaseWhere+` AND COALESCE(p.person_group_id, p.id) = ANY($2::uuid[]) ORDER BY `+profileInteractionOrderBy+` `, externalUserID, groupIDs) if err != nil { return nil, err } defer rows.Close() var profiles []Profile for rows.Next() { p, err := scanProfileRow(rows) if err != nil { return nil, err } profiles = append(profiles, p) } if err := rows.Err(); err != nil { return nil, err } s.attachSignaturesToProfiles(ctx, profiles) return profiles, nil } func (s *Service) attachSignaturesToProfiles(ctx context.Context, profiles []Profile) { if len(profiles) == 0 { return } ids := make([]string, 0, len(profiles)) for _, p := range profiles { ids = append(ids, p.ID) } batch, err := s.loadProfileSignaturesBatch(ctx, ids) if err != nil { return } for i := range profiles { profiles[i].Signatures = batch[profiles[i].ID] } } func (s *Service) loadProfileSignaturesBatch(ctx context.Context, profileIDs []string) (map[string][]SignatureEntry, error) { out := make(map[string][]SignatureEntry, len(profileIDs)) if len(profileIDs) == 0 { return out, nil } rows, err := s.db.Query(ctx, ` SELECT profile_id::text, id::text, COALESCE(message_id::text, ''), signature_text, message_date, confidence FROM contact_discovered_signatures WHERE profile_id = ANY($1::uuid[]) ORDER BY profile_id, message_date DESC `, profileIDs) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var profileID string var se SignatureEntry if err := rows.Scan(&profileID, &se.ID, &se.MessageID, &se.SignatureText, &se.MessageDate, &se.Confidence); err != nil { return nil, err } list := out[profileID] if len(list) < 5 { out[profileID] = append(list, se) } } return out, rows.Err() } func (s *Service) ListOtherProfileGroupsPage(ctx context.Context, externalUserID string, limit, offset int, search string) (OtherProfileGroupsPage, error) { if limit <= 0 { limit = DefaultOtherGroupsPageSize } if limit > MaxOtherGroupsPageSize { limit = MaxOtherGroupsPageSize } if offset < 0 { offset = 0 } search = strings.TrimSpace(search) if search != "" { return s.listOtherProfileGroupsSearchPage(ctx, externalUserID, limit, offset, search) } total, err := s.countOtherProfileGroups(ctx, externalUserID, search) if err != nil { return OtherProfileGroupsPage{}, err } if total == 0 || offset >= total { return OtherProfileGroupsPage{ Groups: []ProfileGroup{}, Total: total, Limit: limit, Offset: offset, HasMore: false, }, nil } groupIDs, err := s.listOtherProfileGroupIDs(ctx, externalUserID, limit, offset, search) if err != nil { return OtherProfileGroupsPage{}, err } groups, err := s.loadOtherProfileGroupsByIDs(ctx, externalUserID, groupIDs) if err != nil { return OtherProfileGroupsPage{}, err } nextOffset := offset + len(groups) hasMore := nextOffset < total return OtherProfileGroupsPage{ Groups: groups, Total: total, Limit: limit, Offset: offset, HasMore: hasMore, }, nil } func (s *Service) listOtherProfileGroupsSearchPage(ctx context.Context, externalUserID string, limit, offset int, search string) (OtherProfileGroupsPage, error) { groupIDs, err := s.listAllOtherProfileGroupIDs(ctx, externalUserID, search) if err != nil { return OtherProfileGroupsPage{}, err } if len(groupIDs) == 0 { return OtherProfileGroupsPage{ Groups: []ProfileGroup{}, Total: 0, Limit: limit, Offset: offset, HasMore: false, }, nil } groups, err := s.loadOtherProfileGroupsByIDs(ctx, externalUserID, groupIDs) if err != nil { return OtherProfileGroupsPage{}, err } ranked := rankProfileGroupsByQuery(groups, search) total := len(ranked) if offset >= total { return OtherProfileGroupsPage{ Groups: []ProfileGroup{}, Total: total, Limit: limit, Offset: offset, HasMore: false, }, nil } end := offset + limit if end > total { end = total } page := ranked[offset:end] return OtherProfileGroupsPage{ Groups: page, Total: total, Limit: limit, Offset: offset, HasMore: end < total, }, nil } func (s *Service) loadOtherProfileGroupsByIDs(ctx context.Context, externalUserID string, groupIDs []string) ([]ProfileGroup, error) { profiles, err := s.listOtherProfilesForGroupIDs(ctx, externalUserID, groupIDs) if err != nil { return nil, err } groups := filterSuggestableGroups(groupProfiles(profiles)) if groups == nil { return []ProfileGroup{}, nil } return groups, nil }