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:
R3D347HR4Y 2026-05-22 20:50:46 +02:00
parent 3cd50bc967
commit f232aaf960
8 changed files with 930 additions and 14 deletions

View File

@ -209,7 +209,7 @@ func main() {
if ncClient != nil { if ncClient != nil {
r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes()) r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes())
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).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 { if meetCfg != nil {
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())

View File

@ -1,10 +1,14 @@
package contacts package contacts
import ( import (
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5" "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/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
@ -19,9 +23,9 @@ type Handler struct {
logger *slog.Logger logger *slog.Logger
} }
func NewHandler(nc *nextcloud.Client) *Handler { func NewHandler(nc *nextcloud.Client, db *pgxpool.Pool) *Handler {
return &Handler{ return &Handler{
svc: NewService(nc), svc: NewService(nc, db),
logger: slog.Default().With("component", "contacts-api"), logger: slog.Default().With("component", "contacts-api"),
} }
} }
@ -32,9 +36,14 @@ func (h *Handler) Routes() chi.Router {
write := middleware.RequirePermission(permission.ResourceContacts, permission.LevelWrite) write := middleware.RequirePermission(permission.ResourceContacts, permission.LevelWrite)
r.With(read).Get("/books", h.ListAddressBooks) 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}", h.ListContacts)
r.With(read).Get("/books/{bookID}/interactions/*", h.GetContactInteractions)
r.With(read).Get("/search", h.SearchContacts) 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}", h.CreateContact)
r.With(write).Post("/books/{bookID}/merge-duplicates", h.MergeDuplicateContacts)
r.With(write).Put("/*", h.UpdateContact)
r.With(write).Delete("/*", h.DeleteContact) r.With(write).Delete("/*", h.DeleteContact)
return r 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}) 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) { func (h *Handler) ListContacts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
params, err := query.ParseListRequest(r) params, err := query.ParseListRequest(r)
@ -110,6 +141,113 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) 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) { func (h *Handler) DeleteContact(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
contactPath := chi.URLParam(r, "*") contactPath := chi.URLParam(r, "*")

View File

@ -2,8 +2,14 @@ package contacts
import ( import (
"context" "context"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings" "strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/paginate" "github.com/ultisuite/ulti-backend/internal/api/paginate"
"github.com/ultisuite/ulti-backend/internal/api/query" "github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/nextcloud"
@ -11,10 +17,11 @@ import (
type Service struct { type Service struct {
nc *nextcloud.Client nc *nextcloud.Client
db *pgxpool.Pool
} }
func NewService(nc *nextcloud.Client) *Service { func NewService(nc *nextcloud.Client, db *pgxpool.Pool) *Service {
return &Service{nc: nc} return &Service{nc: nc, db: db}
} }
func bookPath(userID, bookID string) string { 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) 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 { func (s *Service) DeleteContact(ctx context.Context, userID, contactPath string) error {
return s.nc.DeleteContact(ctx, userID, contactPath) 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 { func filterContacts(contacts []nextcloud.Contact, q string) []nextcloud.Contact {
q = strings.ToLower(strings.TrimSpace(q)) q = strings.ToLower(strings.TrimSpace(q))
if q == "" { if q == "" {

View File

@ -1,13 +1,36 @@
package contacts package contacts
import ( import (
"net/mail"
"strconv"
"strings" "strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "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 { func validateCreateContact(contact *nextcloud.Contact) *apivalidate.ValidationError {
if strings.TrimSpace(contact.FullName) == "" { if strings.TrimSpace(contact.FullName) == "" {
@ -18,6 +41,20 @@ func validateCreateContact(contact *nextcloud.Contact) *apivalidate.ValidationEr
return nil 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 { func validateDeletePath(path string) *apivalidate.ValidationError {
if strings.TrimSpace(path) == "" { if strings.TrimSpace(path) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{ return apivalidate.NewValidationError(apivalidate.FieldDetail{
@ -26,3 +63,37 @@ func validateDeletePath(path string) *apivalidate.ValidationError {
} }
return nil 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
}

View File

@ -6,6 +6,23 @@ import (
"github.com/ultisuite/ulti-backend/internal/nextcloud" "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) { func TestValidateCreateContact(t *testing.T) {
if validateCreateContact(&nextcloud.Contact{FullName: "Ada"}) != nil { if validateCreateContact(&nextcloud.Contact{FullName: "Ada"}) != nil {
t.Fatal("expected valid contact") t.Fatal("expected valid contact")
@ -14,3 +31,55 @@ func TestValidateCreateContact(t *testing.T) {
t.Fatal("expected missing full_name error") 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")
}
}

View File

@ -3,12 +3,16 @@ package nextcloud
import ( import (
"context" "context"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io" "io"
"net/http"
"strings" "strings"
"time" "time"
) )
var ErrSyncTokenInvalid = errors.New("nextcloud contacts sync token invalid")
type AddressBook struct { type AddressBook struct {
ID string `json:"id"` ID string `json:"id"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
@ -21,9 +25,18 @@ type Contact struct {
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Org string `json:"org"` Org string `json:"org"`
Path string `json:"path,omitempty"`
ETag string `json:"etag,omitempty"`
RawVCard string `json:"raw_vcard,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) { func (c *Client) ListAddressBooks(ctx context.Context, userID string) ([]AddressBook, error) {
path := fmt.Sprintf("/remote.php/dav/addressbooks/users/%s/", userID) path := fmt.Sprintf("/remote.php/dav/addressbooks/users/%s/", userID)
body := `<?xml version="1.0" encoding="UTF-8"?> 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) 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 { func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) error {
vcard := buildVCard(contact) vcard := buildVCard(contact)
uid := contact.UID uid := contact.UID
@ -89,6 +123,49 @@ func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, con
return nil 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 { func (c *Client) DeleteContact(ctx context.Context, userID, contactPath string) error {
resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil) resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil)
if err != nil { if err != nil {
@ -172,6 +249,27 @@ func parseAddressBookList(body io.Reader, basePath string) ([]AddressBook, error
return books, nil 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) { func parseContactList(body io.Reader) ([]Contact, error) {
var ms cardMultistatus var ms cardMultistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil { 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)) contacts := make([]Contact, 0, len(ms.Responses))
for _, r := range ms.Responses { for _, r := range ms.Responses {
vcard := r.Propstat.Prop.AddressData if isAddressBookCollectionHref(r.Href) {
contact := parseVCard(vcard) continue
contact.RawVCard = vcard }
contact, ok := contactFromCardProp(r.Href, r.Propstat.Prop)
if !ok {
continue
}
contacts = append(contacts, contact) contacts = append(contacts, contact)
} }
return contacts, nil 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 { func parseVCard(vcard string) Contact {
var c Contact var c Contact
for _, line := range strings.Split(vcard, "\n") { for _, line := range strings.Split(vcard, "\n") {
@ -222,7 +383,20 @@ type cardResponse struct {
Propstat cardPropstat `xml:"propstat"` 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 { type cardPropstat struct {
Status string `xml:"status"`
Prop cardProp `xml:"prop"` Prop cardProp `xml:"prop"`
} }

View 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)
}
}

View File

@ -152,10 +152,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
#### Contacts #### Contacts
- [ ] Ajouter update contact + ETag. - [x] Ajouter update contact + ETag.
- [ ] Ajouter sync incrémentale (sync-token). - [x] Ajouter sync incrémentale (sync-token).
- [ ] Ajouter fusion doublons serveur. - [x] Ajouter fusion doublons serveur.
- [ ] Ajouter endpoints enrichissement interactions mail/réunions/fichiers. - [x] Ajouter endpoints enrichissement interactions mail/réunions/fichiers.
#### Meet #### Meet