- 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.
277 lines
8.9 KiB
Go
277 lines
8.9 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
func (s *Service) ListOtherProfiles(ctx context.Context, externalUserID string) ([]Profile, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+profileSelectColumns+`
|
|
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+`
|
|
ORDER BY `+profileInteractionOrderBy+`
|
|
`, externalUserID)
|
|
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) ListSuggestions(ctx context.Context, externalUserID string, enrichOnly bool) ([]Suggestion, error) {
|
|
query := `
|
|
SELECT s.id::text, COALESCE(s.profile_id::text, ''), COALESCE(s.target_contact_uid, ''),
|
|
s.suggestion_type, s.field_path, s.suggested_value, COALESCE(s.suggested_label, ''),
|
|
s.confidence, s.status
|
|
FROM contact_enrichment_suggestions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE u.external_id = $1 AND s.status = 'pending'
|
|
`
|
|
if enrichOnly {
|
|
query += ` AND s.suggestion_type = 'enrich_contact'`
|
|
} else {
|
|
query += ` AND s.suggestion_type IN ('new_contact', 'enrich_contact')`
|
|
}
|
|
query += ` ORDER BY s.confidence DESC, s.created_at DESC`
|
|
|
|
rows, err := s.db.Query(ctx, query, externalUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var suggestions []Suggestion
|
|
for rows.Next() {
|
|
var sug Suggestion
|
|
if err := rows.Scan(
|
|
&sug.ID, &sug.ProfileID, &sug.TargetContactUID,
|
|
&sug.SuggestionType, &sug.FieldPath, &sug.SuggestedValue, &sug.SuggestedLabel,
|
|
&sug.Confidence, &sug.Status,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if sug.ProfileID != "" {
|
|
p, err := s.getProfileByID(ctx, externalUserID, sug.ProfileID)
|
|
if err == nil {
|
|
sug.Profile = &p
|
|
}
|
|
}
|
|
suggestions = append(suggestions, sug)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(suggestions) > 0 {
|
|
ncUserID, bookID := s.resolveDiscoveryNCContext(ctx, externalUserID)
|
|
contacts := s.loadNCContacts(ctx, ncUserID, bookID)
|
|
suggestions = filterRedundantSuggestions(suggestions, contacts)
|
|
}
|
|
return suggestions, nil
|
|
}
|
|
|
|
func (s *Service) AcceptProfile(ctx context.Context, externalUserID, profileID, contactUID string) error {
|
|
ids, err := s.relatedProfileIDs(ctx, externalUserID, profileID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tag, err := s.db.Exec(ctx, `
|
|
UPDATE contact_discovered_profiles
|
|
SET status = 'accepted', linked_contact_uid = NULLIF($3, ''), accepted_at = NOW(), updated_at = NOW()
|
|
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
|
AND id = ANY($2::uuid[])
|
|
AND status IN ('suggested', 'ignored', 'blocked')
|
|
`, externalUserID, ids, contactUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("profile not found or already processed")
|
|
}
|
|
_, _ = s.db.Exec(ctx, `
|
|
UPDATE contact_enrichment_suggestions
|
|
SET status = 'accepted', accepted_at = NOW()
|
|
WHERE profile_id = ANY($2::uuid[])
|
|
AND user_id = (SELECT id FROM users WHERE external_id = $1)
|
|
AND status = 'pending'
|
|
`, externalUserID, ids)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) RejectProfile(ctx context.Context, externalUserID, profileID string) error {
|
|
var email string
|
|
err := s.db.QueryRow(ctx, `
|
|
UPDATE contact_discovered_profiles
|
|
SET status = 'rejected', rejected_at = NOW(), updated_at = NOW()
|
|
WHERE id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1)
|
|
RETURNING primary_email
|
|
`, externalUserID, profileID).Scan(&email)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return fmt.Errorf("profile not found")
|
|
}
|
|
return err
|
|
}
|
|
|
|
_, _ = s.db.Exec(ctx, `
|
|
INSERT INTO contact_discovery_rejections (user_id, rejection_key, rejection_type)
|
|
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, 'profile')
|
|
ON CONFLICT DO NOTHING
|
|
`, externalUserID, "email:"+strings.ToLower(email))
|
|
|
|
_, _ = s.db.Exec(ctx, `
|
|
UPDATE contact_enrichment_suggestions
|
|
SET status = 'rejected', rejected_at = NOW()
|
|
WHERE profile_id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1) AND status = 'pending'
|
|
`, externalUserID, profileID)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) AcceptSuggestion(ctx context.Context, externalUserID, suggestionID string) (Suggestion, error) {
|
|
var sug Suggestion
|
|
err := s.db.QueryRow(ctx, `
|
|
UPDATE contact_enrichment_suggestions
|
|
SET status = 'accepted', accepted_at = NOW()
|
|
WHERE id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1) AND status = 'pending'
|
|
RETURNING id::text, COALESCE(profile_id::text, ''), COALESCE(target_contact_uid, ''),
|
|
suggestion_type, field_path, suggested_value, COALESCE(suggested_label, ''), confidence, status
|
|
`, externalUserID, suggestionID).Scan(
|
|
&sug.ID, &sug.ProfileID, &sug.TargetContactUID,
|
|
&sug.SuggestionType, &sug.FieldPath, &sug.SuggestedValue, &sug.SuggestedLabel,
|
|
&sug.Confidence, &sug.Status,
|
|
)
|
|
if err != nil {
|
|
return Suggestion{}, err
|
|
}
|
|
return sug, nil
|
|
}
|
|
|
|
func (s *Service) RejectSuggestion(ctx context.Context, externalUserID, suggestionID string) error {
|
|
var profileID, fieldPath, value string
|
|
err := s.db.QueryRow(ctx, `
|
|
UPDATE contact_enrichment_suggestions
|
|
SET status = 'rejected', rejected_at = NOW()
|
|
WHERE id = $2::uuid AND user_id = (SELECT id FROM users WHERE external_id = $1) AND status = 'pending'
|
|
RETURNING COALESCE(profile_id::text, ''), field_path, suggested_value
|
|
`, externalUserID, suggestionID).Scan(&profileID, &fieldPath, &value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rejKey := fmt.Sprintf("field:%s:%s:%s", profileID, fieldPath, value)
|
|
_, _ = s.db.Exec(ctx, `
|
|
INSERT INTO contact_discovery_rejections (user_id, rejection_key, rejection_type)
|
|
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, 'field_suggestion')
|
|
ON CONFLICT DO NOTHING
|
|
`, externalUserID, rejKey)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) PendingCounts(ctx context.Context, externalUserID string) (otherCount, suggestionCount, ignoredCount, blockedCount int, err error) {
|
|
otherCount, err = s.countOtherProfileGroups(ctx, externalUserID, "")
|
|
if err != nil {
|
|
return
|
|
}
|
|
err = s.db.QueryRow(ctx, `
|
|
SELECT
|
|
(SELECT COUNT(*)::int FROM contact_enrichment_suggestions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE u.external_id = $1 AND s.status = 'pending'),
|
|
(SELECT COUNT(*)::int FROM contact_discovered_profiles p
|
|
JOIN users u ON p.user_id = u.id
|
|
WHERE u.external_id = $1 AND p.status = 'ignored'),
|
|
(SELECT COUNT(*)::int FROM contact_discovered_profiles p
|
|
JOIN users u ON p.user_id = u.id
|
|
WHERE u.external_id = $1 AND p.status = 'blocked')
|
|
`, externalUserID).Scan(&suggestionCount, &ignoredCount, &blockedCount)
|
|
return
|
|
}
|
|
|
|
func (s *Service) getProfileByID(ctx context.Context, externalUserID, profileID string) (Profile, error) {
|
|
row := s.db.QueryRow(ctx, `
|
|
SELECT `+profileSelectColumns+`
|
|
FROM contact_discovered_profiles p
|
|
JOIN users u ON p.user_id = u.id
|
|
WHERE u.external_id = $1 AND p.id = $2::uuid
|
|
`, externalUserID, profileID)
|
|
return scanProfileRow(row)
|
|
}
|
|
|
|
func (s *Service) loadProfileSignatures(ctx context.Context, profileID string) ([]SignatureEntry, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id::text, COALESCE(message_id::text, ''), signature_text, message_date, confidence
|
|
FROM contact_discovered_signatures WHERE profile_id = $1::uuid
|
|
ORDER BY message_date DESC LIMIT 5
|
|
`, profileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var sigs []SignatureEntry
|
|
for rows.Next() {
|
|
var se SignatureEntry
|
|
if err := rows.Scan(&se.ID, &se.MessageID, &se.SignatureText, &se.MessageDate, &se.Confidence); err != nil {
|
|
return nil, err
|
|
}
|
|
sigs = append(sigs, se)
|
|
}
|
|
return sigs, rows.Err()
|
|
}
|
|
|
|
type profileScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanProfileRow(row profileScanner) (Profile, error) {
|
|
var p Profile
|
|
var allEmailsJSON []byte
|
|
var enrichedJSON []byte
|
|
var accountsJSON []byte
|
|
var lastMsg *time.Time
|
|
err := row.Scan(
|
|
&p.ID, &p.DisplayName, &p.PrimaryEmail, &allEmailsJSON,
|
|
&p.MessageCount, &p.SentCount, &p.ReceivedCount, &p.SpamCount, &p.ForwardedCount,
|
|
&p.OutboundCount, &p.InboundFromCCCount, &p.CopresenceCCBCCCount,
|
|
&p.IsMailingList, &p.IsDisposable, &p.IsSpamHeavy, &p.ClassificationReason,
|
|
&p.LinkedContactUID, &p.EnrichmentStatus, &enrichedJSON,
|
|
&p.Status, &lastMsg, &accountsJSON,
|
|
)
|
|
if err != nil {
|
|
return Profile{}, err
|
|
}
|
|
_ = json.Unmarshal(allEmailsJSON, &p.AllEmails)
|
|
_ = json.Unmarshal(accountsJSON, &p.DetectedInAccounts)
|
|
if len(enrichedJSON) > 0 && string(enrichedJSON) != "null" {
|
|
var ed EnrichedContactData
|
|
if json.Unmarshal(enrichedJSON, &ed) == nil {
|
|
p.EnrichedData = &ed
|
|
}
|
|
}
|
|
p.LastMessageAt = lastMsg
|
|
return p, nil
|
|
}
|