ultisuite-backend/internal/api/contacts/service.go
R3D347HR4Y f232aaf960 Enhance Contacts API with new features and improvements
- Updated the Contacts API to support contact synchronization with incremental updates using sync tokens.
- Added functionality for merging duplicate contacts on the server side.
- Introduced new endpoints for enriching contact interactions, including mail, meetings, and files.
- Implemented ETag support for contact updates to ensure data integrity.
- Enhanced validation for sync tokens and interaction queries.
- Updated project checklist to reflect the completion of Contacts API enhancements.
2026-05-22 20:50:46 +02:00

448 lines
13 KiB
Go

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
}