- 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.
468 lines
14 KiB
Go
468 lines
14 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/auth"
|
|
"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 (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
|
|
if s.nc == nil {
|
|
return "", fmt.Errorf("nextcloud unavailable")
|
|
}
|
|
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
|
|
}
|
|
|
|
// ReprovisionPrincipal drops cached CardDAV credentials and provisions fresh app password.
|
|
func (s *Service) ReprovisionPrincipal(ctx context.Context, claims *auth.Claims) (string, error) {
|
|
if s.nc == nil {
|
|
return "", fmt.Errorf("nextcloud unavailable")
|
|
}
|
|
userID := nextcloud.UserIDFromClaims(claims.Email, claims.Sub)
|
|
if err := s.nc.InvalidatePrincipalCredentials(ctx, userID); err != nil {
|
|
return "", err
|
|
}
|
|
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
|
|
}
|
|
|
|
func bookPath(userID, bookID string) string {
|
|
return nextcloud.AddressBookPath(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)
|
|
}
|
|
if searchQ == "" {
|
|
var zero int64
|
|
return ContactsList{
|
|
Contacts: []nextcloud.Contact{},
|
|
Pagination: params.Meta(&zero),
|
|
}, nil
|
|
}
|
|
|
|
contacts, err := s.nc.ListContacts(ctx, userID, bookPath(userID, bookID))
|
|
if err != nil {
|
|
return ContactsList{}, err
|
|
}
|
|
ranked := rankContactsByQuery(contacts, searchQ)
|
|
page, total := paginate.Slice(ranked, 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) (*nextcloud.Contact, error) {
|
|
return s.nc.CreateContact(ctx, userID, bookPath(userID, bookID), contact)
|
|
}
|
|
|
|
func (s *Service) GetContact(ctx context.Context, userID, contactPath string) (*nextcloud.Contact, error) {
|
|
return s.nc.GetContact(ctx, userID, contactPath)
|
|
}
|
|
|
|
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 {
|
|
return rankContactsByQuery(contacts, q)
|
|
}
|