From f232aaf960109a57ec151785e73a605ffec480fe Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 22 May 2026 20:50:46 +0200 Subject: [PATCH] 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. --- cmd/ultid/main.go | 2 +- internal/api/contacts/handlers.go | 142 +++++++++- internal/api/contacts/service.go | 366 ++++++++++++++++++++++++- internal/api/contacts/validate.go | 73 ++++- internal/api/contacts/validate_test.go | 69 +++++ internal/nextcloud/contacts.go | 182 +++++++++++- internal/nextcloud/contacts_test.go | 102 +++++++ project-plan/checklist-execution.md | 8 +- 8 files changed, 930 insertions(+), 14 deletions(-) create mode 100644 internal/nextcloud/contacts_test.go diff --git a/cmd/ultid/main.go b/cmd/ultid/main.go index 57590d3..eca752e 100644 --- a/cmd/ultid/main.go +++ b/cmd/ultid/main.go @@ -209,7 +209,7 @@ func main() { if ncClient != nil { r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes()) r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes()) - r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient).Routes()) + r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient, pool).Routes()) } if meetCfg != nil { r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) diff --git a/internal/api/contacts/handlers.go b/internal/api/contacts/handlers.go index dbbf0d9..d09df2b 100644 --- a/internal/api/contacts/handlers.go +++ b/internal/api/contacts/handlers.go @@ -1,10 +1,14 @@ package contacts import ( + "errors" "log/slog" "net/http" + "strconv" + "strings" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" @@ -19,9 +23,9 @@ type Handler struct { logger *slog.Logger } -func NewHandler(nc *nextcloud.Client) *Handler { +func NewHandler(nc *nextcloud.Client, db *pgxpool.Pool) *Handler { return &Handler{ - svc: NewService(nc), + svc: NewService(nc, db), logger: slog.Default().With("component", "contacts-api"), } } @@ -32,9 +36,14 @@ func (h *Handler) Routes() chi.Router { write := middleware.RequirePermission(permission.ResourceContacts, permission.LevelWrite) r.With(read).Get("/books", h.ListAddressBooks) + r.With(read).Get("/books/{bookID}/sync", h.SyncContacts) r.With(read).Get("/books/{bookID}", h.ListContacts) + r.With(read).Get("/books/{bookID}/interactions/*", h.GetContactInteractions) r.With(read).Get("/search", h.SearchContacts) + r.With(read).Get("/interactions", h.GetInteractionsByEmail) r.With(write).Post("/books/{bookID}", h.CreateContact) + r.With(write).Post("/books/{bookID}/merge-duplicates", h.MergeDuplicateContacts) + r.With(write).Put("/*", h.UpdateContact) r.With(write).Delete("/*", h.DeleteContact) return r } @@ -50,6 +59,28 @@ func (h *Handler) ListAddressBooks(w http.ResponseWriter, r *http.Request) { apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"address_books": books}) } +func (h *Handler) SyncContacts(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + syncToken, verr := validateSyncToken(r.URL.Query().Get("sync_token")) + if verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + result, err := h.svc.SyncContacts(r.Context(), claims.Sub, chi.URLParam(r, "bookID"), syncToken) + if err != nil { + if errors.Is(err, nextcloud.ErrSyncTokenInvalid) { + apiresponse.WriteError(w, r, http.StatusConflict, "sync_token_invalid", + "sync token is no longer valid; omit sync_token to perform a full resync", nil) + return + } + h.logger.Error("sync contacts", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + func (h *Handler) ListContacts(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) params, err := query.ParseListRequest(r) @@ -110,6 +141,113 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +func (h *Handler) UpdateContact(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + contactPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") + if verr := validateDeletePath(contactPath); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + ifMatch := strings.TrimSpace(r.Header.Get("If-Match")) + if verr := validateIfMatch(ifMatch); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + var contact nextcloud.Contact + if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &contact); err != nil { + return + } + if verr := validateCreateContact(&contact); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + etag, err := h.svc.UpdateContact(r.Context(), claims.Sub, contactPath, ifMatch, &contact) + if err != nil { + if errors.Is(err, nextcloud.ErrETagMismatch) { + apiresponse.WriteError(w, r, http.StatusPreconditionFailed, "etag_mismatch", "etag does not match current resource version", nil) + return + } + h.logger.Error("update contact", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag}) +} + +func (h *Handler) MergeDuplicateContacts(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + var req MergeDuplicatesRequest + if r.ContentLength > 0 { + if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil { + return + } + } + + result, err := h.svc.MergeDuplicates(r.Context(), claims.Sub, chi.URLParam(r, "bookID"), req) + if err != nil { + h.logger.Error("merge duplicate contacts", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) GetInteractionsByEmail(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + email, limit, verr := validateInteractionQuery(r.URL.Query().Get("email"), r.URL.Query().Get("limit")) + if verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + result, err := h.svc.ContactInteractionsByEmail(r.Context(), claims.Sub, email, limit) + if err != nil { + h.logger.Error("contact interactions by email", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) GetContactInteractions(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + contactPath := strings.TrimSuffix(chi.URLParam(r, "*"), "/") + if verr := validateDeletePath(contactPath); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + limit := 20 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + val, err := strconv.Atoi(raw) + if err != nil || val < 1 || val > 100 { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "limit", Message: "must be between 1 and 100", + })) + return + } + limit = val + } + + result, err := h.svc.ContactInteractionsByPath(r.Context(), claims.Sub, contactPath, limit) + if err != nil { + if errors.Is(err, ErrContactEmailMissing) { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "email", Message: "required for enrichment", + })) + return + } + h.logger.Error("contact interactions by path", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + func (h *Handler) DeleteContact(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) contactPath := chi.URLParam(r, "*") diff --git a/internal/api/contacts/service.go b/internal/api/contacts/service.go index 801d13d..a679eec 100644 --- a/internal/api/contacts/service.go +++ b/internal/api/contacts/service.go @@ -2,8 +2,14 @@ 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" @@ -11,10 +17,11 @@ import ( type Service struct { nc *nextcloud.Client + db *pgxpool.Pool } -func NewService(nc *nextcloud.Client) *Service { - return &Service{nc: nc} +func NewService(nc *nextcloud.Client, db *pgxpool.Pool) *Service { + return &Service{nc: nc, db: db} } func bookPath(userID, bookID string) string { @@ -63,10 +70,365 @@ func (s *Service) CreateContact(ctx context.Context, userID, bookID string, cont 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 == "" { diff --git a/internal/api/contacts/validate.go b/internal/api/contacts/validate.go index 37ef2d9..7539e50 100644 --- a/internal/api/contacts/validate.go +++ b/internal/api/contacts/validate.go @@ -1,13 +1,36 @@ package contacts import ( + "net/mail" + "strconv" "strings" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) -const maxRequestBody = 64 << 10 +const ( + maxRequestBody = 64 << 10 + maxSyncTokenLen = 8192 +) + +func validateSyncToken(raw string) (string, *apivalidate.ValidationError) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + if len(raw) > maxSyncTokenLen { + return "", apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "sync_token", Message: "too long", + }) + } + if strings.ContainsAny(raw, "\r\n\x00") || strings.ContainsAny(raw, "<>&") { + return "", apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "sync_token", Message: "invalid", + }) + } + return raw, nil +} func validateCreateContact(contact *nextcloud.Contact) *apivalidate.ValidationError { if strings.TrimSpace(contact.FullName) == "" { @@ -18,6 +41,20 @@ func validateCreateContact(contact *nextcloud.Contact) *apivalidate.ValidationEr return nil } +func validateIfMatch(ifMatch string) *apivalidate.ValidationError { + if strings.TrimSpace(ifMatch) == "" { + return apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "If-Match", Message: "required", + }) + } + if strings.ContainsAny(ifMatch, "\r\n") { + return apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "If-Match", Message: "invalid", + }) + } + return nil +} + func validateDeletePath(path string) *apivalidate.ValidationError { if strings.TrimSpace(path) == "" { return apivalidate.NewValidationError(apivalidate.FieldDetail{ @@ -26,3 +63,37 @@ func validateDeletePath(path string) *apivalidate.ValidationError { } return nil } + +func validateInteractionQuery(emailRaw, limitRaw string) (string, int, *apivalidate.ValidationError) { + email := strings.TrimSpace(emailRaw) + if email == "" { + return "", 0, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "email", Message: "required", + }) + } + if len(email) > 320 || strings.ContainsAny(email, "\r\n") { + return "", 0, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "email", Message: "invalid", + }) + } + parsed, err := mail.ParseAddress(email) + if err != nil || parsed.Address == "" { + return "", 0, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "email", Message: "invalid", + }) + } + + limit := 20 + raw := strings.TrimSpace(limitRaw) + if raw != "" { + parsedLimit, err := strconv.Atoi(raw) + if err != nil || parsedLimit < 1 || parsedLimit > 100 { + return "", 0, apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "limit", Message: "must be between 1 and 100", + }) + } + limit = parsedLimit + } + + return parsed.Address, limit, nil +} diff --git a/internal/api/contacts/validate_test.go b/internal/api/contacts/validate_test.go index 82dc74b..49e1f9b 100644 --- a/internal/api/contacts/validate_test.go +++ b/internal/api/contacts/validate_test.go @@ -6,6 +6,23 @@ import ( "github.com/ultisuite/ulti-backend/internal/nextcloud" ) +func TestValidateSyncToken(t *testing.T) { + token, err := validateSyncToken("") + if err != nil || token != "" { + t.Fatalf("empty token: got %q err=%v", token, err) + } + token, err = validateSyncToken(" http://nc/sync/1 ") + if err != nil || token != "http://nc/sync/1" { + t.Fatalf("trimmed token: got %q err=%v", token, err) + } + if token, err = validateSyncToken(" "); err != nil || token != "" { + t.Fatalf("whitespace-only token treated as empty: got %q err=%v", token, err) + } + if _, err = validateSyncToken(""); err == nil { + t.Fatal("expected invalid token error") + } +} + func TestValidateCreateContact(t *testing.T) { if validateCreateContact(&nextcloud.Contact{FullName: "Ada"}) != nil { t.Fatal("expected valid contact") @@ -14,3 +31,55 @@ func TestValidateCreateContact(t *testing.T) { t.Fatal("expected missing full_name error") } } + +func TestValidateIfMatch(t *testing.T) { + if validateIfMatch(`"abc123"`) != nil { + t.Fatal("expected valid If-Match") + } + if validateIfMatch("") == nil { + t.Fatal("expected missing If-Match error") + } + if validateIfMatch(" \t") == nil { + t.Fatal("expected whitespace-only If-Match error") + } + if validateIfMatch("bad\r\nvalue") == nil { + t.Fatal("expected invalid If-Match error") + } +} + +func TestValidateDeletePath(t *testing.T) { + if validateDeletePath("/remote.php/dav/addressbooks/users/alice/contacts/uid.vcf") != nil { + t.Fatal("expected valid path") + } + if validateDeletePath("") == nil { + t.Fatal("expected missing path error") + } +} + +func TestValidateInteractionQuery(t *testing.T) { + email, limit, err := validateInteractionQuery("ada@example.com", "15") + if err != nil { + t.Fatalf("expected valid query, got error: %v", err) + } + if email != "ada@example.com" { + t.Fatalf("unexpected normalized email: %q", email) + } + if limit != 15 { + t.Fatalf("unexpected limit: %d", limit) + } + + _, _, err = validateInteractionQuery("", "10") + if err == nil { + t.Fatal("expected required email error") + } + + _, _, err = validateInteractionQuery("not-an-email", "") + if err == nil { + t.Fatal("expected invalid email error") + } + + _, _, err = validateInteractionQuery("ada@example.com", "999") + if err == nil { + t.Fatal("expected invalid limit error") + } +} diff --git a/internal/nextcloud/contacts.go b/internal/nextcloud/contacts.go index 69f9357..42031f8 100644 --- a/internal/nextcloud/contacts.go +++ b/internal/nextcloud/contacts.go @@ -3,12 +3,16 @@ package nextcloud import ( "context" "encoding/xml" + "errors" "fmt" "io" + "net/http" "strings" "time" ) +var ErrSyncTokenInvalid = errors.New("nextcloud contacts sync token invalid") + type AddressBook struct { ID string `json:"id"` DisplayName string `json:"display_name"` @@ -21,9 +25,18 @@ type Contact struct { Email string `json:"email"` Phone string `json:"phone"` Org string `json:"org"` + Path string `json:"path,omitempty"` + ETag string `json:"etag,omitempty"` RawVCard string `json:"raw_vcard,omitempty"` } +// ContactSyncResult is the delta from a CardDAV sync-collection REPORT. +type ContactSyncResult struct { + SyncToken string `json:"sync_token"` + Contacts []Contact `json:"contacts"` + Deleted []string `json:"deleted"` +} + func (c *Client) ListAddressBooks(ctx context.Context, userID string) ([]AddressBook, error) { path := fmt.Sprintf("/remote.php/dav/addressbooks/users/%s/", userID) body := ` @@ -67,6 +80,27 @@ func (c *Client) ListContacts(ctx context.Context, userID, bookPath string) ([]C return parseContactList(resp.Body) } +func (c *Client) SyncContacts(ctx context.Context, userID, bookPath, syncToken string) (ContactSyncResult, error) { + body := buildSyncCollectionRequest(syncToken) + resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{ + "Depth": "1", + "Content-Type": "application/xml", + }) + if err != nil { + return ContactSyncResult{}, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusForbidden: + return ContactSyncResult{}, ErrSyncTokenInvalid + case http.StatusOK, http.StatusMultiStatus: + return parseContactSyncResponse(resp.Body) + default: + return ContactSyncResult{}, &HTTPStatusError{Operation: "sync contacts", StatusCode: resp.StatusCode} + } +} + func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) error { vcard := buildVCard(contact) uid := contact.UID @@ -89,6 +123,49 @@ func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, con return nil } +func (c *Client) UpdateContact(ctx context.Context, userID, contactPath, ifMatch string, contact *Contact) (string, error) { + vcard := buildVCard(contact) + headers := map[string]string{ + "Content-Type": "text/vcard; charset=utf-8", + } + if strings.TrimSpace(ifMatch) != "" { + headers["If-Match"] = strings.TrimSpace(ifMatch) + } + + resp, err := c.DoAsUser(ctx, "PUT", contactPath, strings.NewReader(vcard), userID, headers) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusPreconditionFailed { + return "", ErrETagMismatch + } + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("update contact failed: %d", resp.StatusCode) + } + return strings.TrimSpace(resp.Header.Get("ETag")), nil +} + +func (c *Client) GetContact(ctx context.Context, userID, contactPath string) (*Contact, error) { + resp, err := c.DoAsUser(ctx, "GET", contactPath, nil, userID, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("get contact failed: %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + contact := parseVCard(string(raw)) + contact.Path = contactPath + contact.ETag = strings.TrimSpace(resp.Header.Get("ETag")) + contact.RawVCard = string(raw) + return &contact, nil +} + func (c *Client) DeleteContact(ctx context.Context, userID, contactPath string) error { resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil) if err != nil { @@ -172,6 +249,27 @@ func parseAddressBookList(body io.Reader, basePath string) ([]AddressBook, error return books, nil } +func buildSyncCollectionRequest(syncToken string) string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + if syncToken != "" { + b.WriteString("") + b.WriteString(xmlEscape(syncToken)) + b.WriteString("") + } + b.WriteString(`1`) + b.WriteString(``) + b.WriteString(``) + return b.String() +} + +func xmlEscape(s string) string { + var b strings.Builder + _ = xml.EscapeText(&b, []byte(s)) + return b.String() +} + func parseContactList(body io.Reader) ([]Contact, error) { var ms cardMultistatus if err := xml.NewDecoder(body).Decode(&ms); err != nil { @@ -180,14 +278,77 @@ func parseContactList(body io.Reader) ([]Contact, error) { contacts := make([]Contact, 0, len(ms.Responses)) for _, r := range ms.Responses { - vcard := r.Propstat.Prop.AddressData - contact := parseVCard(vcard) - contact.RawVCard = vcard + if isAddressBookCollectionHref(r.Href) { + continue + } + contact, ok := contactFromCardProp(r.Href, r.Propstat.Prop) + if !ok { + continue + } contacts = append(contacts, contact) } return contacts, nil } +func parseContactSyncResponse(body io.Reader) (ContactSyncResult, error) { + var ms cardSyncMultistatus + if err := xml.NewDecoder(body).Decode(&ms); err != nil { + return ContactSyncResult{}, err + } + + out := ContactSyncResult{ + SyncToken: strings.TrimSpace(ms.SyncToken), + Contacts: make([]Contact, 0, len(ms.Responses)), + Deleted: make([]string, 0), + } + for _, r := range ms.Responses { + if isAddressBookCollectionHref(r.Href) { + continue + } + if isDeletedSyncResponse(r) { + out.Deleted = append(out.Deleted, r.Href) + continue + } + contact, ok := contactFromCardProp(r.Href, r.Propstat.Prop) + if !ok { + continue + } + out.Contacts = append(out.Contacts, contact) + } + return out, nil +} + +func contactFromCardProp(href string, prop cardProp) (Contact, bool) { + vcard := strings.TrimSpace(prop.AddressData) + if vcard == "" { + return Contact{}, false + } + contact := parseVCard(vcard) + contact.Path = href + contact.ETag = normalizeETag(prop.ETag) + contact.RawVCard = vcard + return contact, true +} + +func normalizeETag(etag string) string { + return strings.TrimSpace(etag) +} + +func isAddressBookCollectionHref(href string) bool { + return strings.HasSuffix(href, "/") +} + +func isDeletedSyncResponse(r cardSyncResponse) bool { + if statusIndicatesDeleted(r.Status) { + return true + } + return statusIndicatesDeleted(r.Propstat.Status) +} + +func statusIndicatesDeleted(status string) bool { + return strings.Contains(status, "404") +} + func parseVCard(vcard string) Contact { var c Contact for _, line := range strings.Split(vcard, "\n") { @@ -222,8 +383,21 @@ type cardResponse struct { Propstat cardPropstat `xml:"propstat"` } +type cardSyncMultistatus struct { + XMLName xml.Name `xml:"multistatus"` + SyncToken string `xml:"sync-token"` + Responses []cardSyncResponse `xml:"response"` +} + +type cardSyncResponse struct { + Href string `xml:"href"` + Status string `xml:"status"` + Propstat cardPropstat `xml:"propstat"` +} + type cardPropstat struct { - Prop cardProp `xml:"prop"` + Status string `xml:"status"` + Prop cardProp `xml:"prop"` } type cardProp struct { diff --git a/internal/nextcloud/contacts_test.go b/internal/nextcloud/contacts_test.go new file mode 100644 index 0000000..9b05fc1 --- /dev/null +++ b/internal/nextcloud/contacts_test.go @@ -0,0 +1,102 @@ +package nextcloud + +import ( + "strings" + "testing" +) + +func TestBuildSyncCollectionRequest(t *testing.T) { + initial := buildSyncCollectionRequest("") + if strings.Contains(initial, "sync-token") { + t.Fatal("initial sync must omit sync-token element") + } + if !strings.Contains(initial, "sync-collection") { + t.Fatal("expected sync-collection request") + } + + incremental := buildSyncCollectionRequest(`http://nc/sync/1`) + if !strings.Contains(incremental, "http://nc/sync/1") { + t.Fatalf("unexpected incremental body: %s", incremental) + } +} + +func TestParseContactSyncResponse(t *testing.T) { + body := strings.NewReader(` + + http://nc/sync/2 + + /remote.php/dav/addressbooks/users/alice/contacts/ada.vcf + + + "etag-1" + BEGIN:VCARD +UID:ada@ulti +FN:Ada Lovelace +EMAIL:ada@example.com +END:VCARD + + HTTP/1.1 200 OK + + + + /remote.php/dav/addressbooks/users/alice/contacts/old.vcf + HTTP/1.1 404 Not Found + +`) + + result, err := parseContactSyncResponse(body) + if err != nil { + t.Fatal(err) + } + if result.SyncToken != "http://nc/sync/2" { + t.Fatalf("sync token: got %q", result.SyncToken) + } + if len(result.Contacts) != 1 { + t.Fatalf("contacts: got %d", len(result.Contacts)) + } + c := result.Contacts[0] + if c.FullName != "Ada Lovelace" || c.Path != "/remote.php/dav/addressbooks/users/alice/contacts/ada.vcf" { + t.Fatalf("unexpected contact: %+v", c) + } + if c.ETag != "\"etag-1\"" { + t.Fatalf("etag: got %q", c.ETag) + } + if len(result.Deleted) != 1 || result.Deleted[0] != "/remote.php/dav/addressbooks/users/alice/contacts/old.vcf" { + t.Fatalf("deleted: %+v", result.Deleted) + } +} + +func TestParseContactListSetsPathAndETag(t *testing.T) { + body := strings.NewReader(` + + + /remote.php/dav/addressbooks/users/alice/contacts/ + + + + /remote.php/dav/addressbooks/users/alice/contacts/bob.vcf + + + "e2" + BEGIN:VCARD +FN:Bob +END:VCARD + + + +`) + + contacts, err := parseContactList(body) + if err != nil { + t.Fatal(err) + } + if len(contacts) != 1 { + t.Fatalf("contacts: got %d", len(contacts)) + } + if contacts[0].Path != "/remote.php/dav/addressbooks/users/alice/contacts/bob.vcf" { + t.Fatalf("path: %q", contacts[0].Path) + } + if contacts[0].ETag != "\"e2\"" { + t.Fatalf("etag: %q", contacts[0].ETag) + } +} diff --git a/project-plan/checklist-execution.md b/project-plan/checklist-execution.md index 3df51b1..e6a7b3d 100644 --- a/project-plan/checklist-execution.md +++ b/project-plan/checklist-execution.md @@ -152,10 +152,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon #### Contacts -- [ ] Ajouter update contact + ETag. -- [ ] Ajouter sync incrémentale (sync-token). -- [ ] Ajouter fusion doublons serveur. -- [ ] Ajouter endpoints enrichissement interactions mail/réunions/fichiers. +- [x] Ajouter update contact + ETag. +- [x] Ajouter sync incrémentale (sync-token). +- [x] Ajouter fusion doublons serveur. +- [x] Ajouter endpoints enrichissement interactions mail/réunions/fichiers. #### Meet