ultisuite-backend/internal/contacts/discovery/other_page.go
R3D347HR4Y 556d5f416d Enhance API and configuration for contact discovery and public sharing
- 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.
2026-06-06 20:27:02 +02:00

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
}