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.
This commit is contained in:
parent
3cd50bc967
commit
f232aaf960
@ -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())
|
||||
|
||||
@ -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, "*")
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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("<bad>"); 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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@ -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(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
b.WriteString(`<d:sync-collection xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">`)
|
||||
if syncToken != "" {
|
||||
b.WriteString("<d:sync-token>")
|
||||
b.WriteString(xmlEscape(syncToken))
|
||||
b.WriteString("</d:sync-token>")
|
||||
}
|
||||
b.WriteString(`<d:sync-level>1</d:sync-level>`)
|
||||
b.WriteString(`<d:prop><d:getetag/><card:address-data/></d:prop>`)
|
||||
b.WriteString(`</d:sync-collection>`)
|
||||
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 {
|
||||
|
||||
102
internal/nextcloud/contacts_test.go
Normal file
102
internal/nextcloud/contacts_test.go
Normal file
@ -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, "<d:sync-token>http://nc/sync/1</d:sync-token>") {
|
||||
t.Fatalf("unexpected incremental body: %s", incremental)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContactSyncResponse(t *testing.T) {
|
||||
body := strings.NewReader(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:sync-token>http://nc/sync/2</d:sync-token>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/addressbooks/users/alice/contacts/ada.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"etag-1"</d:getetag>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
UID:ada@ulti
|
||||
FN:Ada Lovelace
|
||||
EMAIL:ada@example.com
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/addressbooks/users/alice/contacts/old.vcf</d:href>
|
||||
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||
</d:response>
|
||||
</d:multistatus>`)
|
||||
|
||||
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(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/addressbooks/users/alice/contacts/</d:href>
|
||||
<d:propstat><d:prop/></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/addressbooks/users/alice/contacts/bob.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:getetag>"e2"</d:getetag>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
FN:Bob
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>`)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user