package contacts import ( "context" "encoding/json" "fmt" "regexp" "sort" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) type Service struct { nc *nextcloud.Client db *pgxpool.Pool } func NewService(nc *nextcloud.Client, db *pgxpool.Pool) *Service { return &Service{nc: nc, db: db} } func bookPath(userID, bookID string) string { return "/remote.php/dav/addressbooks/users/" + userID + "/" + bookID + "/" } func (s *Service) ListAddressBooks(ctx context.Context, userID string) ([]nextcloud.AddressBook, error) { return s.nc.ListAddressBooks(ctx, userID) } type ContactsList struct { Contacts []nextcloud.Contact `json:"contacts"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } func (s *Service) ListContacts(ctx context.Context, userID, bookID string, params query.ListParams) (ContactsList, error) { contacts, err := s.nc.ListContacts(ctx, userID, bookPath(userID, bookID)) if err != nil { return ContactsList{}, err } filtered := filterContacts(contacts, params.Q) page, total := paginate.Slice(filtered, params.Offset(), params.Limit()) return ContactsList{ Contacts: page, Pagination: params.Meta(&total), }, nil } func (s *Service) SearchContacts(ctx context.Context, userID, bookID, q string, params query.ListParams) (ContactsList, error) { searchQ := strings.TrimSpace(q) if searchQ == "" { searchQ = strings.TrimSpace(params.Q) } contacts, err := s.nc.SearchContacts(ctx, userID, bookPath(userID, bookID), searchQ) if err != nil { return ContactsList{}, err } page, total := paginate.Slice(contacts, params.Offset(), params.Limit()) return ContactsList{ Contacts: page, Pagination: params.Meta(&total), }, nil } func (s *Service) CreateContact(ctx context.Context, userID, bookID string, contact *nextcloud.Contact) error { return s.nc.CreateContact(ctx, userID, bookPath(userID, bookID), contact) } func (s *Service) UpdateContact(ctx context.Context, userID, contactPath, ifMatch string, contact *nextcloud.Contact) (string, error) { return s.nc.UpdateContact(ctx, userID, contactPath, ifMatch, contact) } func (s *Service) DeleteContact(ctx context.Context, userID, contactPath string) error { return s.nc.DeleteContact(ctx, userID, contactPath) } func (s *Service) SyncContacts(ctx context.Context, userID, bookID, syncToken string) (nextcloud.ContactSyncResult, error) { return s.nc.SyncContacts(ctx, userID, bookPath(userID, bookID), syncToken) } type MergeDuplicatesRequest struct { DryRun bool `json:"dry_run"` } type DuplicateGroup struct { Key string `json:"key"` KeyType string `json:"key_type"` Primary nextcloud.Contact `json:"primary"` Duplicates []nextcloud.Contact `json:"duplicates"` } type MergeDuplicatesResult struct { DryRun bool `json:"dry_run"` TotalGroups int `json:"total_groups"` MergedCount int `json:"merged_count"` Groups []DuplicateGroup `json:"groups"` } func (s *Service) MergeDuplicates(ctx context.Context, userID, bookID string, req MergeDuplicatesRequest) (MergeDuplicatesResult, error) { contacts, err := s.nc.ListContacts(ctx, userID, bookPath(userID, bookID)) if err != nil { return MergeDuplicatesResult{}, err } groupsByKey := map[string][]nextcloud.Contact{} groupType := map[string]string{} for _, c := range contacts { key, keyType := dedupeKey(c) if key == "" { continue } groupsByKey[key] = append(groupsByKey[key], c) groupType[key] = keyType } result := MergeDuplicatesResult{ DryRun: req.DryRun, Groups: make([]DuplicateGroup, 0), } for key, group := range groupsByKey { if len(group) < 2 { continue } sort.SliceStable(group, func(i, j int) bool { return contactScore(group[i]) > contactScore(group[j]) }) primary := group[0] duplicates := group[1:] merged := primary for _, d := range duplicates { merged = mergeContactFields(merged, d) } result.Groups = append(result.Groups, DuplicateGroup{ Key: key, KeyType: groupType[key], Primary: merged, Duplicates: duplicates, }) if req.DryRun { continue } updatePath := strings.TrimSpace(primary.Path) if updatePath == "" && strings.TrimSpace(primary.UID) != "" { updatePath = fmt.Sprintf("%s%s.vcf", bookPath(userID, bookID), strings.TrimSpace(primary.UID)) } if updatePath != "" { if _, err := s.nc.UpdateContact(ctx, userID, updatePath, primary.ETag, &merged); err != nil { return MergeDuplicatesResult{}, err } } for _, d := range duplicates { if strings.TrimSpace(d.Path) == "" { continue } if err := s.nc.DeleteContact(ctx, userID, d.Path); err != nil { return MergeDuplicatesResult{}, err } result.MergedCount++ } } sort.SliceStable(result.Groups, func(i, j int) bool { return result.Groups[i].Key < result.Groups[j].Key }) result.TotalGroups = len(result.Groups) return result, nil } type MailInteraction struct { ID string `json:"id"` AccountID string `json:"account_id"` Subject string `json:"subject"` Snippet string `json:"snippet"` Date time.Time `json:"date"` } type MeetingInteraction struct { CalendarID string `json:"calendar_id"` UID string `json:"uid"` Summary string `json:"summary"` Start string `json:"start"` End string `json:"end"` Location string `json:"location,omitempty"` Path string `json:"path,omitempty"` } type FileInteraction struct { ID string `json:"id"` MessageID string `json:"message_id"` Subject string `json:"subject"` Filename string `json:"filename"` ContentType string `json:"content_type"` Size int64 `json:"size"` Date time.Time `json:"date"` } type ContactInteractions struct { Email string `json:"email"` Mail []MailInteraction `json:"mail"` Meetings []MeetingInteraction `json:"meetings"` Files []FileInteraction `json:"files"` } func (s *Service) ContactInteractionsByPath(ctx context.Context, userID, contactPath string, limit int) (ContactInteractions, error) { contact, err := s.nc.GetContact(ctx, userID, contactPath) if err != nil { return ContactInteractions{}, err } email := strings.TrimSpace(contact.Email) if email == "" { return ContactInteractions{}, ErrContactEmailMissing } return s.ContactInteractionsByEmail(ctx, userID, email, limit) } func (s *Service) ContactInteractionsByEmail(ctx context.Context, userID, email string, limit int) (ContactInteractions, error) { out := ContactInteractions{ Email: email, Mail: []MailInteraction{}, Meetings: []MeetingInteraction{}, Files: []FileInteraction{}, } if limit <= 0 { limit = 20 } if s.db != nil { mailRows, err := s.db.Query(ctx, ` SELECT m.id, m.account_id, m.subject, m.snippet, m.date FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1) AND ( EXISTS (SELECT 1 FROM jsonb_array_elements(m.from_addr) a WHERE lower(coalesce(a->>'address', '')) = lower($2)) OR EXISTS (SELECT 1 FROM jsonb_array_elements(m.to_addrs) a WHERE lower(coalesce(a->>'address', '')) = lower($2)) OR EXISTS (SELECT 1 FROM jsonb_array_elements(m.cc_addrs) a WHERE lower(coalesce(a->>'address', '')) = lower($2)) ) ORDER BY m.date DESC LIMIT $3 `, userID, email, limit) if err != nil { return ContactInteractions{}, err } for mailRows.Next() { var item MailInteraction if err := mailRows.Scan(&item.ID, &item.AccountID, &item.Subject, &item.Snippet, &item.Date); err != nil { mailRows.Close() return ContactInteractions{}, err } out.Mail = append(out.Mail, item) } if err := mailRows.Err(); err != nil { mailRows.Close() return ContactInteractions{}, err } mailRows.Close() fileRows, err := s.db.Query(ctx, ` SELECT a.id, m.id, m.subject, a.filename, a.content_type, a.size, m.date FROM attachments a JOIN messages m ON a.message_id = m.id JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1) AND ( EXISTS (SELECT 1 FROM jsonb_array_elements(m.from_addr) f WHERE lower(coalesce(f->>'address', '')) = lower($2)) OR EXISTS (SELECT 1 FROM jsonb_array_elements(m.to_addrs) t WHERE lower(coalesce(t->>'address', '')) = lower($2)) OR EXISTS (SELECT 1 FROM jsonb_array_elements(m.cc_addrs) c WHERE lower(coalesce(c->>'address', '')) = lower($2)) ) ORDER BY m.date DESC LIMIT $3 `, userID, email, limit) if err != nil { return ContactInteractions{}, err } for fileRows.Next() { var item FileInteraction if err := fileRows.Scan(&item.ID, &item.MessageID, &item.Subject, &item.Filename, &item.ContentType, &item.Size, &item.Date); err != nil { fileRows.Close() return ContactInteractions{}, err } out.Files = append(out.Files, item) } if err := fileRows.Err(); err != nil { fileRows.Close() return ContactInteractions{}, err } fileRows.Close() } if s.nc != nil { cals, err := s.nc.ListCalendars(ctx, userID) if err != nil { return ContactInteractions{}, err } now := time.Now().UTC() from := now.AddDate(0, -6, 0) to := now.AddDate(1, 0, 0) for _, cal := range cals { events, err := s.nc.ListEvents(ctx, userID, "/remote.php/dav/calendars/"+userID+"/"+cal.ID+"/", from, to) if err != nil { return ContactInteractions{}, err } for _, ev := range events { if !eventMatchesEmail(ev, email) { continue } out.Meetings = append(out.Meetings, MeetingInteraction{ CalendarID: cal.ID, UID: ev.UID, Summary: ev.Summary, Start: ev.Start, End: ev.End, Location: ev.Location, Path: ev.Path, }) } } sort.SliceStable(out.Meetings, func(i, j int) bool { return out.Meetings[i].Start > out.Meetings[j].Start }) if len(out.Meetings) > limit { out.Meetings = out.Meetings[:limit] } } return out, nil } var onlyDigits = regexp.MustCompile(`\D+`) func dedupeKey(c nextcloud.Contact) (string, string) { email := strings.ToLower(strings.TrimSpace(c.Email)) if email != "" { return "email:" + email, "email" } phone := onlyDigits.ReplaceAllString(strings.TrimSpace(c.Phone), "") if phone != "" { return "phone:" + phone, "phone" } name := strings.ToLower(strings.TrimSpace(c.FullName)) org := strings.ToLower(strings.TrimSpace(c.Org)) if name != "" && org != "" { return "name_org:" + name + "|" + org, "name_org" } return "", "" } func contactScore(c nextcloud.Contact) int { score := 0 if strings.TrimSpace(c.FullName) != "" { score += 3 } if strings.TrimSpace(c.Email) != "" { score += 4 } if strings.TrimSpace(c.Phone) != "" { score += 2 } if strings.TrimSpace(c.Org) != "" { score += 1 } if strings.TrimSpace(c.Path) != "" { score += 1 } return score } func mergeContactFields(primary, candidate nextcloud.Contact) nextcloud.Contact { out := primary if strings.TrimSpace(out.FullName) == "" { out.FullName = candidate.FullName } if strings.TrimSpace(out.Email) == "" { out.Email = candidate.Email } if strings.TrimSpace(out.Phone) == "" { out.Phone = candidate.Phone } if strings.TrimSpace(out.Org) == "" { out.Org = candidate.Org } if strings.TrimSpace(out.UID) == "" { out.UID = candidate.UID } return out } func eventMatchesEmail(event nextcloud.Event, email string) bool { if strings.EqualFold(strings.TrimSpace(event.Organizer), strings.TrimSpace(email)) { return true } for _, attendee := range event.Attendees { if strings.EqualFold(strings.TrimSpace(attendee.Email), strings.TrimSpace(email)) { return true } } return false } var ErrContactEmailMissing = fmt.Errorf("contact email is required for interaction enrichment") type mailAddress struct { Name string `json:"name"` Address string `json:"address"` } func extractAddresses(raw []byte) []string { if len(raw) == 0 { return nil } var addrs []mailAddress if err := json.Unmarshal(raw, &addrs); err != nil { return nil } out := make([]string, 0, len(addrs)) for _, addr := range addrs { if strings.TrimSpace(addr.Address) != "" { out = append(out, strings.ToLower(strings.TrimSpace(addr.Address))) } } return out } func filterContacts(contacts []nextcloud.Contact, q string) []nextcloud.Contact { q = strings.ToLower(strings.TrimSpace(q)) if q == "" { return contacts } out := make([]nextcloud.Contact, 0, len(contacts)) for _, c := range contacts { if strings.Contains(strings.ToLower(c.FullName), q) || strings.Contains(strings.ToLower(c.Email), q) || strings.Contains(strings.ToLower(c.Phone), q) || strings.Contains(strings.ToLower(c.Org), q) { out = append(out, c) } } return out }