- Introduced new endpoints for contact discovery, including scanning, listing, and managing discovered contacts. - Implemented retry logic for handling missing DAV credentials during contact operations. - Added public share functionality for drive API, allowing users to manage public shares, including upload, delete, and rename operations. - Updated Nextcloud configuration to support public share links and improved error handling for public share permissions. - Enhanced logging and validation across contact and drive APIs for better error tracking and user feedback. - Added tests for new contact matching and ranking functionalities to ensure accuracy and reliability.
348 lines
9.7 KiB
Go
348 lines
9.7 KiB
Go
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
|
|
}
|