- Add Calendar helpers (ListCalendars/ListEvents/AddEvent/DeleteEvent) - Add CRM helpers: contacts, opportunities, cases, CRM tasks, history notes - Add project-task extras (ListTasks/ListAllTasks/GetTaskByID/AddTask/ AddSubtask/UpdateTaskStatus/DeleteTask, GetProjectByID) - Add UploadOpportunityFile (multipart) and SelfUserID caching - Introduce Defaults struct, SetDefaults, GetEnvironmentDefaults; GetEnvironmentCredentials now accepts ONLYOFFICE_HOST/NAME/PASSWORD aliases - Add public Authenticate() helper that eagerly primes the token - Bundle Cobra CLI at cmd/oo-cli with full command surface (calendar, tasks, subtasks, CRM, applications-sync); CLI-only deps (cobra, godotenv) kept out of the library surface - Add httptest-based unit tests for form/multipart helpers - Docs: CHANGELOG, AGENTS, .env.example, README updates for v0.3 features Consolidates the standalone eSlider/oo-cli tool into this canonical Go module (see eSlider/inventar ASR-0008). Made-with: Cursor
502 lines
14 KiB
Go
502 lines
14 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-cli and the applications-sync workflow.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// 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", fmt.Sprintf("%d", count))
|
|
q.Set("startIndex", fmt.Sprintf("%d", 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) {
|
|
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// FindCompany searches for a company contact with an exact (case-insensitive)
|
|
// displayName match. Returns nil when not found.
|
|
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
|
|
items, _, err := c.ListContacts(ctx, 50, 0, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
needle := strings.ToLower(strings.TrimSpace(name))
|
|
for _, co := range items {
|
|
if isCompany(co) && strings.ToLower(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) {
|
|
items, _, err := c.ListContacts(ctx, 50, 0, first+" "+last)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, p := range items {
|
|
if isCompany(p) {
|
|
continue
|
|
}
|
|
fn := strings.ToLower(fmt.Sprint(p["firstName"]))
|
|
ln := strings.ToLower(fmt.Sprint(p["lastName"]))
|
|
if fn == strings.ToLower(first) && ln == strings.ToLower(last) {
|
|
return p, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
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)
|
|
raw, err := c.postForm(ctx, "/api/2.0/crm/contact/company.json", fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
raw, err := c.postForm(ctx, "/api/2.0/crm/contact/person.json", fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
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", fmt.Sprintf("%t", isPrimary))
|
|
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data.json", url.PathEscape(contactID)), fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(resp, &out)
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteContact removes a CRM contact by id.
|
|
func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[string]any, error) {
|
|
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(resp, &out)
|
|
return out, nil
|
|
}
|
|
|
|
// 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", fmt.Sprintf("%d", count))
|
|
q.Set("startIndex", fmt.Sprintf("%d", 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) {
|
|
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// 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", fmt.Sprintf("%g", bidValue))
|
|
}
|
|
if description != "" {
|
|
fields.Set("description", description)
|
|
}
|
|
raw, err := c.postForm(ctx, "/api/2.0/crm/opportunity.json", fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AddOpportunityMember links a contact to an opportunity.
|
|
func (c *Client) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
|
|
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)), url.Values{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(resp, &out)
|
|
return out, nil
|
|
}
|
|
|
|
// 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) {
|
|
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(resp, &out)
|
|
return out, nil
|
|
}
|
|
|
|
// 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", fmt.Sprintf("%d", count))
|
|
q.Set("startIndex", fmt.Sprintf("%d", 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)
|
|
raw, err := c.postForm(ctx, "/api/2.0/crm/case.json", fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AddCaseMember links a contact to a CRM case.
|
|
func (c *Client) AddCaseMember(ctx context.Context, caseID, contactID string) (map[string]any, error) {
|
|
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/case/%s/contact/%s.json", url.PathEscape(caseID), url.PathEscape(contactID)), url.Values{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(resp, &out)
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteCase removes a case by id.
|
|
func (c *Client) DeleteCase(ctx context.Context, id string) (map[string]any, error) {
|
|
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/case/%s.json", url.PathEscape(id)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(resp, &out)
|
|
return out, nil
|
|
}
|
|
|
|
// 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", fmt.Sprintf("%d", count))
|
|
q.Set("startIndex", fmt.Sprintf("%d", 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.
|
|
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
|
|
fields := url.Values{}
|
|
fields.Set("title", title)
|
|
fields.Set("deadline", deadline)
|
|
fields.Set("categoryId", strconv.Itoa(categoryID))
|
|
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)
|
|
}
|
|
raw, err := c.postForm(ctx, "/api/2.0/crm/task.json", fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteCRMTask removes a CRM task by id.
|
|
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
|
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(resp, &out)
|
|
return out, nil
|
|
}
|
|
|
|
// 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))
|
|
raw, err := c.postForm(ctx, "/api/2.0/crm/history.json", fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(resp, &out)
|
|
return out, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|