- UpdateContactName renames a company contact via PUT /crm/contact/company/{id}
- CloseCRMTask closes a CRM task via PUT /crm/task/{id}/close.json
805 lines
25 KiB
Go
805 lines
25 KiB
Go
package onlyoffice
|
|
|
|
// Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes.
|
|
// These expose untyped maps for flexibility — they are primarily consumed by
|
|
// cmd/oo and CRM sync tooling.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ListContacts returns a page of CRM contacts and the total count.
|
|
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
|
|
q := url.Values{}
|
|
q.Set("count", strconv.Itoa(count))
|
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
|
if search != "" {
|
|
q.Set("filterValue", search)
|
|
}
|
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var env struct {
|
|
Response []map[string]any `json:"response"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
total := env.Total
|
|
if total == 0 && len(env.Response) > 0 {
|
|
total = len(env.Response)
|
|
}
|
|
return env.Response, total, nil
|
|
}
|
|
|
|
// GetContact returns a single contact by id.
|
|
func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
|
|
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
|
}
|
|
|
|
// FindCompany searches for a company contact with an exact (case-insensitive)
|
|
// displayName match after slogan/legal-suffix normalization. Returns nil when not found.
|
|
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
|
|
needle := CompanyGroupingKey(name)
|
|
if needle == "" {
|
|
return nil, nil
|
|
}
|
|
const page = 50
|
|
for start := 0; ; start += page {
|
|
items, total, err := c.ListContacts(ctx, page, start, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, co := range items {
|
|
if !isCompany(co) {
|
|
continue
|
|
}
|
|
if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle {
|
|
return co, nil
|
|
}
|
|
}
|
|
if start+page >= total || len(items) == 0 {
|
|
break
|
|
}
|
|
}
|
|
// Search filter may miss longer legal names; fall back to a full company scan.
|
|
all, err := c.ListAllContacts(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, co := range all {
|
|
if !isCompany(co) {
|
|
continue
|
|
}
|
|
if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle {
|
|
return co, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// FindPerson searches for a person by first+last (case-insensitive).
|
|
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
|
|
firstNeedle := strings.ToLower(strings.TrimSpace(first))
|
|
lastNeedle := strings.ToLower(strings.TrimSpace(last))
|
|
const page = 50
|
|
for start := 0; ; start += page {
|
|
items, total, err := c.ListContacts(ctx, page, start, first+" "+last)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, p := range items {
|
|
if isCompany(p) {
|
|
continue
|
|
}
|
|
if strings.ToLower(fmt.Sprint(p["firstName"])) == firstNeedle &&
|
|
strings.ToLower(fmt.Sprint(p["lastName"])) == lastNeedle {
|
|
return p, nil
|
|
}
|
|
}
|
|
if start+page >= total || len(items) == 0 {
|
|
break
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// FindPersonByEmail finds a person whose primary or contact-info email matches
|
|
// (case-insensitive). Companies are skipped. Returns nil when not found.
|
|
func (c *Client) FindPersonByEmail(ctx context.Context, email string) (map[string]any, error) {
|
|
needle := strings.ToLower(strings.TrimSpace(email))
|
|
if needle == "" {
|
|
return nil, nil
|
|
}
|
|
all, err := c.ListAllContacts(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, p := range all {
|
|
if isCompany(p) {
|
|
continue
|
|
}
|
|
for _, em := range contactEmailValues(p) {
|
|
if strings.ToLower(strings.TrimSpace(em)) == needle {
|
|
return p, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func contactEmailValues(p map[string]any) []string {
|
|
var out []string
|
|
for _, key := range []string{"email", "primaryEmail"} {
|
|
if v := strings.TrimSpace(fmt.Sprint(p[key])); v != "" && v != "<nil>" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
for _, row := range ContactInfoRows(p) {
|
|
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
|
|
continue
|
|
}
|
|
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
|
if data == "" || data == "<nil>" {
|
|
data = strings.TrimSpace(fmt.Sprint(row["value"]))
|
|
}
|
|
if data != "" {
|
|
out = append(out, data)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isCompany(m map[string]any) bool {
|
|
v, ok := m["isCompany"].(bool)
|
|
return ok && v
|
|
}
|
|
|
|
// CreateCompany creates a company contact with the given name.
|
|
func (c *Client) CreateCompany(ctx context.Context, name string) (map[string]any, error) {
|
|
fields := url.Values{}
|
|
fields.Set("companyName", name)
|
|
return c.postFormObject(ctx, "/api/2.0/crm/contact/company.json", fields)
|
|
}
|
|
|
|
// CreatePerson creates a person contact; companyID == 0 means unlinked.
|
|
func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
|
|
fields := url.Values{}
|
|
fields.Set("firstName", first)
|
|
fields.Set("lastName", last)
|
|
if companyID != 0 {
|
|
fields.Set("companyId", strconv.Itoa(companyID))
|
|
}
|
|
if jobTitle != "" {
|
|
fields.Set("jobTitle", jobTitle)
|
|
}
|
|
if about != "" {
|
|
fields.Set("about", about)
|
|
}
|
|
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
|
|
}
|
|
|
|
// UpdatePerson updates first/last name and optional company link on a person.
|
|
// companyID == 0 leaves the company association unchanged (re-sends current
|
|
// company id when present). OnlyOffice ignores companyId/about on form-encoded
|
|
// PUT and may unlink the company when companyId is omitted — use JSON body.
|
|
func (c *Client) UpdatePerson(ctx context.Context, personID, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
|
|
body := map[string]any{
|
|
"firstName": first,
|
|
"lastName": last,
|
|
}
|
|
if companyID != 0 {
|
|
body["companyId"] = companyID
|
|
} else {
|
|
// Preserve existing employer: omitted companyId unlinks on this API.
|
|
if cur, err := c.GetContact(ctx, personID); err == nil {
|
|
if co, ok := cur["company"].(map[string]any); ok {
|
|
if id := flexInt(co["id"]); id != 0 {
|
|
body["companyId"] = id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if jobTitle != "" {
|
|
body["jobTitle"] = jobTitle
|
|
}
|
|
if about != "" {
|
|
body["about"] = about
|
|
}
|
|
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), body)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
// PUT response body is often stale; re-fetch for authoritative fields.
|
|
if fresh, gerr := c.GetContact(ctx, personID); gerr == nil && fresh != nil {
|
|
out = fresh
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AddContactInfo attaches an email/website/phone/etc. to a contact.
|
|
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
|
|
if category == "" {
|
|
category = "Work"
|
|
}
|
|
fields := url.Values{}
|
|
fields.Set("infoType", infoType)
|
|
fields.Set("data", dataValue)
|
|
fields.Set("category", category)
|
|
fields.Set("isPrimary", strconv.FormatBool(isPrimary))
|
|
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data.json", url.PathEscape(contactID)), fields)
|
|
}
|
|
|
|
// DeleteContact removes a CRM contact by id.
|
|
func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[string]any, error) {
|
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
|
}
|
|
|
|
// UpdateContactName renames a CRM company contact displayName.
|
|
// Uses the company endpoint (person names go through /crm/contact/person/{id}).
|
|
func (c *Client) UpdateContactName(ctx context.Context, contactID, newName string) (map[string]any, error) {
|
|
body := map[string]any{
|
|
"displayName": newName,
|
|
"companyName": newName,
|
|
"isCompany": true,
|
|
}
|
|
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(contactID)), body)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if fresh, gerr := c.GetContact(ctx, contactID); gerr == nil && fresh != nil {
|
|
out = fresh
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
|
|
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
|
|
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
|
|
}
|
|
|
|
// CreateContactTag creates a contact tag by name. Idempotent: "already exists" is OK.
|
|
func (c *Client) CreateContactTag(ctx context.Context, tagName string) error {
|
|
fields := url.Values{}
|
|
fields.Set("tagName", tagName)
|
|
_, err := c.postFormObject(ctx, "/api/2.0/crm/contact/tag.json", fields)
|
|
if err != nil && strings.Contains(err.Error(), "already exists") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// AddContactTag attaches tagName to a contact.
|
|
func (c *Client) AddContactTag(ctx context.Context, contactID, tagName string) error {
|
|
fields := url.Values{}
|
|
fields.Set("tagName", tagName)
|
|
_, err := c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields)
|
|
return err
|
|
}
|
|
|
|
// RemoveContactTag removes tagName from a contact.
|
|
func (c *Client) RemoveContactTag(ctx context.Context, contactID, tagName string) error {
|
|
fields := url.Values{}
|
|
fields.Set("tagName", tagName)
|
|
raw, err := c.deleteForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = unmarshalResponseObject(raw)
|
|
return err
|
|
}
|
|
|
|
// ListContactsByTag returns contacts filtered by a single CRM tag name.
|
|
func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, startIndex int) ([]map[string]any, int, error) {
|
|
if count <= 0 {
|
|
count = 50
|
|
}
|
|
q := url.Values{}
|
|
q.Set("count", strconv.Itoa(count))
|
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
|
q.Set("tags", tagName)
|
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var env struct {
|
|
Response []map[string]any `json:"response"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
total := env.Total
|
|
if total == 0 && len(env.Response) > 0 {
|
|
total = len(env.Response)
|
|
}
|
|
return env.Response, total, nil
|
|
}
|
|
|
|
// ListAllContacts paginates through every CRM contact.
|
|
func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
|
|
const page = 100
|
|
var all []map[string]any
|
|
for start := 0; ; start += page {
|
|
chunk, total, err := c.ListContacts(ctx, page, start, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, chunk...)
|
|
if start+page >= total || len(chunk) == 0 {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// MergeContacts merges secondary into primary (secondary is removed).
|
|
func (c *Client) MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error) {
|
|
fields := url.Values{}
|
|
fields.Set("fromContactId", secondaryID)
|
|
fields.Set("toContactId", primaryID)
|
|
out, err := c.putFormObject(ctx, "/api/2.0/crm/contact/merge.json", fields)
|
|
if err == nil {
|
|
return out, nil
|
|
}
|
|
// Some instances expect JSON body with alternate field names.
|
|
body := map[string]any{
|
|
"fromContactId": secondaryID,
|
|
"toContactId": primaryID,
|
|
}
|
|
return c.putJSONObject(ctx, "/api/2.0/crm/contact/merge.json", body)
|
|
}
|
|
|
|
// ListCompanyPersons returns persons linked to a company.
|
|
func (c *Client) ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error) {
|
|
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s/person.json", url.PathEscape(companyID)))
|
|
}
|
|
|
|
// DeleteContactInfo removes one info row from a contact.
|
|
func (c *Client) DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error) {
|
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data/%s.json", url.PathEscape(contactID), url.PathEscape(dataID)))
|
|
}
|
|
|
|
// ContactInfoRows returns commonData/info rows from a contact map.
|
|
func ContactInfoRows(contact map[string]any) []map[string]any {
|
|
for _, key := range []string{"commonData", "data", "contactData"} {
|
|
if rows, ok := contact[key].([]any); ok {
|
|
return mapsFromAnySlice(rows)
|
|
}
|
|
if rows, ok := contact[key].([]map[string]any); ok {
|
|
return rows
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HasContactInfo reports whether a contact already has the given type+value.
|
|
func HasContactInfo(contact map[string]any, infoType, value string) bool {
|
|
key := ContactInfoKey(infoType, value)
|
|
for _, row := range ContactInfoRows(contact) {
|
|
v := fmt.Sprint(row["data"])
|
|
if v == "" || v == "<nil>" {
|
|
v = fmt.Sprint(row["value"])
|
|
}
|
|
if ContactInfoKey(fmt.Sprint(row["infoType"]), v) == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mapsFromAnySlice(rows []any) []map[string]any {
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
if m, ok := row.(map[string]any); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ListOpportunities returns a page of deals/opportunities and the total count.
|
|
func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
|
q := url.Values{}
|
|
q.Set("count", strconv.Itoa(count))
|
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/opportunity/filter.json?"+q.Encode())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var env struct {
|
|
Response []map[string]any `json:"response"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return env.Response, env.Total, nil
|
|
}
|
|
|
|
// GetOpportunity returns a single opportunity (deal) by id.
|
|
func (c *Client) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
|
|
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
|
|
}
|
|
|
|
// CreateOpportunity creates a new deal. An empty responsibleID falls back to
|
|
// the authenticated user's id, and bidCurrency defaults to "EUR".
|
|
func (c *Client) CreateOpportunity(ctx context.Context, title string, stageID int, responsibleID, bidCurrency, description string, bidValue float64) (map[string]any, error) {
|
|
if responsibleID == "" {
|
|
s, err := c.SelfUserID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
responsibleID = s
|
|
}
|
|
if bidCurrency == "" {
|
|
bidCurrency = "EUR"
|
|
}
|
|
fields := url.Values{}
|
|
fields.Set("title", title)
|
|
fields.Set("stageId", strconv.Itoa(stageID))
|
|
fields.Set("responsibleId", responsibleID)
|
|
fields.Set("bidCurrencyAbbr", bidCurrency)
|
|
if bidValue != 0 {
|
|
fields.Set("bidValue", strconv.FormatFloat(bidValue, 'g', -1, 64))
|
|
}
|
|
if description != "" {
|
|
fields.Set("description", description)
|
|
}
|
|
return c.postFormObject(ctx, "/api/2.0/crm/opportunity.json", fields)
|
|
}
|
|
|
|
// AddOpportunityMember links a contact to an opportunity.
|
|
func (c *Client) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
|
|
return c.postFormObject(ctx,
|
|
fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)),
|
|
url.Values{})
|
|
}
|
|
|
|
// ListDealStages returns the configured opportunity stages.
|
|
func (c *Client) ListDealStages(ctx context.Context) ([]map[string]any, error) {
|
|
return c.ResponseArray(ctx, "/api/2.0/crm/opportunity/stage.json")
|
|
}
|
|
|
|
// DeleteOpportunity removes a deal by id.
|
|
func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
|
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
|
|
}
|
|
|
|
// ListAllOpportunities paginates through every opportunity.
|
|
func (c *Client) ListAllOpportunities(ctx context.Context) ([]map[string]any, error) {
|
|
const page = 100
|
|
var all []map[string]any
|
|
for start := 0; ; start += page {
|
|
chunk, total, err := c.ListOpportunities(ctx, page, start)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, chunk...)
|
|
if start+page >= total || len(chunk) == 0 {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// OpportunityMembers extracts the members slice from a GetOpportunity response.
|
|
func OpportunityMembers(opp map[string]any) []map[string]any {
|
|
raw, ok := opp["members"].([]any)
|
|
if !ok {
|
|
if rows, ok := opp["members"].([]map[string]any); ok {
|
|
return rows
|
|
}
|
|
return nil
|
|
}
|
|
return mapsFromAnySlice(raw)
|
|
}
|
|
|
|
// ListOpportunityMembers returns contacts linked to an opportunity.
|
|
func (c *Client) ListOpportunityMembers(ctx context.Context, oppID string) ([]map[string]any, error) {
|
|
opp, err := c.GetOpportunity(ctx, oppID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if members := OpportunityMembers(opp); len(members) > 0 {
|
|
return members, nil
|
|
}
|
|
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact.json", url.PathEscape(oppID)))
|
|
}
|
|
|
|
// RemoveOpportunityMember detaches a contact from an opportunity.
|
|
func (c *Client) RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
|
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)))
|
|
}
|
|
|
|
// IsOpportunityMember reports whether contactID is already on the opportunity.
|
|
func (c *Client) IsOpportunityMember(ctx context.Context, oppID, contactID string) (bool, error) {
|
|
members, err := c.ListOpportunityMembers(ctx, oppID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
want := flexInt(contactID)
|
|
for _, m := range members {
|
|
if flexInt(m["id"]) == want {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// UpdateOpportunityTitle renames a deal; loads full record and PUTs it back.
|
|
func (c *Client) UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error) {
|
|
opp, err := c.GetOpportunity(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body := opportunityUpdateBody(opp, newTitle)
|
|
return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)), body)
|
|
}
|
|
|
|
func opportunityUpdateBody(opp map[string]any, title string) map[string]any {
|
|
body := map[string]any{
|
|
"opportunityid": flexInt(opp["id"]),
|
|
"title": title,
|
|
"description": stringField(opp, "description"),
|
|
"isPrivate": boolField(opp, "isPrivate"),
|
|
"isNotify": false,
|
|
}
|
|
if stage, ok := opp["stage"].(map[string]any); ok {
|
|
body["stageid"] = flexInt(stage["id"])
|
|
}
|
|
if resp, ok := opp["responsible"].(map[string]any); ok {
|
|
body["responsibleid"] = fmt.Sprint(resp["id"])
|
|
}
|
|
if cur, ok := opp["bidCurrency"].(map[string]any); ok {
|
|
body["bidCurrencyAbbr"] = stringField(cur, "abbreviation")
|
|
} else {
|
|
body["bidCurrencyAbbr"] = "EUR"
|
|
}
|
|
body["bidValue"] = floatField(opp, "bidValue")
|
|
body["bidType"] = 0
|
|
body["perPeriodValue"] = 0
|
|
body["successProbability"] = 1
|
|
var memberIDs []int64
|
|
seen := make(map[int64]bool)
|
|
for _, m := range OpportunityMembers(opp) {
|
|
id := flexInt(m["id"])
|
|
if id == 0 || seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
memberIDs = append(memberIDs, id)
|
|
}
|
|
if len(memberIDs) > 0 {
|
|
body["members"] = memberIDs
|
|
body["contactid"] = memberIDs[0]
|
|
}
|
|
if al, ok := opp["accessList"].([]any); ok && len(al) > 0 {
|
|
body["accessList"] = al
|
|
} else {
|
|
body["accessList"] = []any{}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func stringField(m map[string]any, key string) string {
|
|
v := fmt.Sprint(m[key])
|
|
if v == "<nil>" {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
|
|
func boolField(m map[string]any, key string) bool {
|
|
v, _ := m[key].(bool)
|
|
return v
|
|
}
|
|
|
|
func floatField(m map[string]any, key string) float64 {
|
|
switch x := m[key].(type) {
|
|
case float64:
|
|
return x
|
|
case int:
|
|
return float64(x)
|
|
default:
|
|
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
|
|
return f
|
|
}
|
|
}
|
|
|
|
// ListCases returns a page of CRM cases and the total count.
|
|
func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
|
q := url.Values{}
|
|
q.Set("count", strconv.Itoa(count))
|
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/case/filter.json?"+q.Encode())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var env struct {
|
|
Response []map[string]any `json:"response"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return env.Response, env.Total, nil
|
|
}
|
|
|
|
// CreateCase creates a new CRM case.
|
|
func (c *Client) CreateCase(ctx context.Context, title string) (map[string]any, error) {
|
|
fields := url.Values{}
|
|
fields.Set("title", title)
|
|
return c.postFormObject(ctx, "/api/2.0/crm/case.json", fields)
|
|
}
|
|
|
|
// AddCaseMember links a contact to a CRM case.
|
|
func (c *Client) AddCaseMember(ctx context.Context, caseID, contactID string) (map[string]any, error) {
|
|
return c.postFormObject(ctx,
|
|
fmt.Sprintf("/api/2.0/crm/case/%s/contact/%s.json", url.PathEscape(caseID), url.PathEscape(contactID)),
|
|
url.Values{})
|
|
}
|
|
|
|
// DeleteCase removes a case by id.
|
|
func (c *Client) DeleteCase(ctx context.Context, id string) (map[string]any, error) {
|
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/case/%s.json", url.PathEscape(id)))
|
|
}
|
|
|
|
// ListCRMTasks returns a page of CRM tasks (separate from Project tasks).
|
|
func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
|
q := url.Values{}
|
|
q.Set("count", strconv.Itoa(count))
|
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/task/filter.json?"+q.Encode())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var env struct {
|
|
Response []map[string]any `json:"response"`
|
|
Total int `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return env.Response, env.Total, nil
|
|
}
|
|
|
|
// CreateCRMTask creates a CRM task (reminder) attached to an entity.
|
|
// Empty deadline defaults to now+14d (API requires a deadline). Empty
|
|
// responsibleID falls back to the authenticated user — without this the
|
|
// portal assigns a stub "Profile has been removed" owner.
|
|
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
|
|
if deadline == "" {
|
|
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
|
|
}
|
|
if categoryID == 0 {
|
|
categoryID = 2 // Opportunity (default CRM task category)
|
|
}
|
|
uid, err := c.SelfUserID(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreateCRMTask: resolve self: %w", err)
|
|
}
|
|
fields := url.Values{}
|
|
fields.Set("title", title)
|
|
fields.Set("deadline", deadline)
|
|
fields.Set("categoryId", strconv.Itoa(categoryID))
|
|
fields.Set("responsibleId", uid)
|
|
if contactID != 0 {
|
|
fields.Set("contactId", strconv.Itoa(contactID))
|
|
}
|
|
if entityType != "" {
|
|
fields.Set("entityType", entityType)
|
|
}
|
|
if entityID != 0 {
|
|
fields.Set("entityId", strconv.Itoa(entityID))
|
|
}
|
|
if description != "" {
|
|
fields.Set("description", description)
|
|
}
|
|
return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
|
|
}
|
|
|
|
// UpdateCRMTask updates title, deadline, category, and responsible on a CRM task.
|
|
// Empty responsibleID falls back to SelfUserID; empty deadline is left unchanged
|
|
// by the API only if omitted — callers should pass deadLine when known.
|
|
func (c *Client) UpdateCRMTask(ctx context.Context, id, title, deadline string, categoryID int, responsibleID string) (map[string]any, error) {
|
|
if responsibleID == "" {
|
|
uid, err := c.SelfUserID(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpdateCRMTask: resolve self: %w", err)
|
|
}
|
|
responsibleID = uid
|
|
}
|
|
fields := url.Values{}
|
|
fields.Set("title", title)
|
|
fields.Set("responsibleId", responsibleID)
|
|
if deadline != "" {
|
|
fields.Set("deadline", deadline)
|
|
}
|
|
if categoryID != 0 {
|
|
fields.Set("categoryId", strconv.Itoa(categoryID))
|
|
}
|
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)), fields)
|
|
}
|
|
|
|
// DeleteCRMTask removes a CRM task by id.
|
|
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
|
}
|
|
|
|
// CloseCRMTask closes (completes) a CRM task via the task close endpoint.
|
|
func (c *Client) CloseCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s/close.json", url.PathEscape(id)), url.Values{})
|
|
}
|
|
|
|
// ListTaskCategories returns CRM task categories.
|
|
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
|
|
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
|
|
}
|
|
|
|
// AddHistoryNote attaches a history note to a CRM entity. When categoryID is 0,
|
|
// the cached "note" category id is looked up from /api/2.0/crm/history/category.json.
|
|
func (c *Client) AddHistoryNote(ctx context.Context, entityType string, entityID int, content string, categoryID int) (map[string]any, error) {
|
|
if categoryID == 0 {
|
|
id, err := c.historyNoteCategoryID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
categoryID = id
|
|
}
|
|
fields := url.Values{}
|
|
fields.Set("entityType", entityType)
|
|
fields.Set("entityId", strconv.Itoa(entityID))
|
|
fields.Set("content", content)
|
|
fields.Set("categoryId", strconv.Itoa(categoryID))
|
|
return c.postFormObject(ctx, "/api/2.0/crm/history.json", fields)
|
|
}
|
|
|
|
func (c *Client) historyNoteCategoryID(ctx context.Context) (int, error) {
|
|
if c.noteCatID != 0 {
|
|
return c.noteCatID, nil
|
|
}
|
|
list, err := c.ResponseArray(ctx, "/api/2.0/crm/history/category.json")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
for _, row := range list {
|
|
if strings.EqualFold(fmt.Sprint(row["title"]), "note") {
|
|
c.noteCatID = int(flexInt(row["id"]))
|
|
return c.noteCatID, nil
|
|
}
|
|
}
|
|
if len(list) > 0 {
|
|
c.noteCatID = int(flexInt(list[0]["id"]))
|
|
return c.noteCatID, nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// flexInt coerces OnlyOffice numeric fields that JSON unmarshal may surface as
|
|
// float64, int, json.Number, or string into a plain int64.
|
|
func flexInt(v any) int64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return int64(x)
|
|
case int:
|
|
return int64(x)
|
|
case int64:
|
|
return x
|
|
case json.Number:
|
|
n, _ := x.Int64()
|
|
return n
|
|
default:
|
|
n, _ := strconv.ParseInt(fmt.Sprint(x), 10, 64)
|
|
return n
|
|
}
|
|
}
|