package contacts import ( "encoding/json" "sort" "strings" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) const ( maxImportBody = 8 << 20 // 8 MiB maxContactsPerImport = 5000 ) // importRequest is the bulk import body. Each element of contacts is either a // raw vCard string ("BEGIN:VCARD...") or a structured contact object matching // the single-create shape (full_name/email/phone/org or raw_vcard). type importRequest struct { Contacts []json.RawMessage `json:"contacts"` } // parsedImport holds the valid contacts to send and a mapping back to the // caller's original array index (so failures can be reported precisely). type parsedImport struct { contacts []nextcloud.Contact originalIndex []int } func parseImportContacts(items []json.RawMessage) (parsedImport, []ImportFailure, *apivalidate.ValidationError) { if len(items) == 0 { return parsedImport{}, nil, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "contacts", Message: "at least one contact is required", }) } if len(items) > maxContactsPerImport { return parsedImport{}, nil, apivalidate.NewValidationError(apivalidate.FieldDetail{ Field: "contacts", Message: "too many contacts in a single import", }) } out := parsedImport{ contacts: make([]nextcloud.Contact, 0, len(items)), originalIndex: make([]int, 0, len(items)), } preFailures := make([]ImportFailure, 0) for i, raw := range items { contact, err := decodeImportItem(raw) if err != "" { preFailures = append(preFailures, ImportFailure{Index: i, Error: err}) continue } out.contacts = append(out.contacts, contact) out.originalIndex = append(out.originalIndex, i) } return out, preFailures, nil } // decodeImportItem turns a single array element into a Contact, returning a // non-empty error string when the element is unusable. func decodeImportItem(raw json.RawMessage) (nextcloud.Contact, string) { trimmed := strings.TrimSpace(string(raw)) if trimmed == "" || trimmed == "null" { return nextcloud.Contact{}, "empty contact" } // A JSON string element is treated as a raw vCard. if trimmed[0] == '"' { var vcard string if err := json.Unmarshal(raw, &vcard); err != nil { return nextcloud.Contact{}, "invalid vcard string" } vcard = strings.TrimSpace(vcard) if vcard == "" { return nextcloud.Contact{}, "empty vcard" } return nextcloud.Contact{RawVCard: vcard}, "" } var contact nextcloud.Contact if err := json.Unmarshal(raw, &contact); err != nil { return nextcloud.Contact{}, "invalid contact object" } if strings.TrimSpace(contact.RawVCard) == "" && strings.TrimSpace(contact.FullName) == "" { return nextcloud.Contact{}, "full_name or raw_vcard required" } return contact, "" } // mergeImportFailures combines pre-send validation failures with service send // failures, remapping service indices (relative to the valid slice) back to the // caller's original array indices, sorted ascending. func mergeImportFailures(pre []ImportFailure, svc []ImportFailure, originalIndex []int) []ImportFailure { merged := make([]ImportFailure, 0, len(pre)+len(svc)) merged = append(merged, pre...) for _, f := range svc { idx := f.Index if idx >= 0 && idx < len(originalIndex) { idx = originalIndex[idx] } merged = append(merged, ImportFailure{Index: idx, Error: f.Error}) } sort.SliceStable(merged, func(i, j int) bool { return merged[i].Index < merged[j].Index }) return merged }