package v1 import ( "encoding/json" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/search" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) // CompanyHandler handles Company CRUD + search + nested contacts/conversations/notes. // Reference: Chatwoot app/controllers/api/v1/companies_controller.rb type CompanyHandler struct { svc *service.CompanyService } // NewCompanyHandler creates a new CompanyHandler. func NewCompanyHandler(svc *service.CompanyService) *CompanyHandler { return &CompanyHandler{svc: svc} } // List retrieves all companies for an account. // GET /api/v1/accounts/:id/companies?sort=name&page=1&per_page=25 func (h *CompanyHandler) List(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } pg := parseCompanyPagination(c) sort := c.DefaultQuery("sort", "") companies, total, err := h.svc.List(c.Request.Context(), accountID, pg.Offset, pg.PerPage, sort) if err != nil { applogger.L().Errorf("List companies for account %d: %v", accountID, err) handleServiceError(c, err) return } c.JSON(http.StatusOK, companyListResponse(c.Request.Context(), h.svc.DB(), companies, total, pg.Page)) } // Search searches companies by query. // GET /api/v1/accounts/:id/companies/search?q=Acme&sort=name&page=1&per_page=25 func (h *CompanyHandler) Search(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } pg := parseCompanyPagination(c) query := c.DefaultQuery("q", "") if query == "" { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"}) return } sort := c.DefaultQuery("sort", "") searchMode := search.SearchMode(c.DefaultQuery("search_mode", "ilike")) companies, total, err := h.svc.Search(c.Request.Context(), accountID, query, pg.Offset, pg.PerPage, sort, searchMode) if err != nil { applogger.L().Errorf("Search companies for account %d: %v", accountID, err) handleServiceError(c, err) return } c.JSON(http.StatusOK, companyListResponse(c.Request.Context(), h.svc.DB(), companies, total, pg.Page)) } // Get retrieves a single company by ID. // GET /api/v1/accounts/:id/companies/:company_id func (h *CompanyHandler) Get(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } company, svcErr := h.svc.Get(c.Request.Context(), uint(companyID), accountID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) } // Create creates a new company. // POST /api/v1/accounts/:id/companies func (h *CompanyHandler) Create(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } var req service.CreateCompanyRequest if err := bindCompanyRequest(c, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } company, svcErr := h.svc.Create(c.Request.Context(), accountID, &req) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) } // Update updates an existing company. // PUT /api/v1/accounts/:id/companies/:company_id func (h *CompanyHandler) Update(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } var req service.UpdateCompanyRequest if err := bindCompanyRequest(c, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } company, svcErr := h.svc.Update(c.Request.Context(), uint(companyID), accountID, &req) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) } // Delete deletes a company. // DELETE /api/v1/accounts/:id/companies/:company_id func (h *CompanyHandler) Delete(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } if svcErr := h.svc.Delete(c.Request.Context(), uint(companyID), accountID); svcErr != nil { handleServiceError(c, svcErr) return } c.Status(http.StatusOK) } func (h *CompanyHandler) DestroyCustomAttributes(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } var req struct { CustomAttributes []string `json:"custom_attributes"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.CustomAttributes == nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "custom_attributes must be an array"}) return } company, svcErr := h.svc.DestroyCustomAttributes(c.Request.Context(), uint(companyID), accountID, req.CustomAttributes) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) } func (h *CompanyHandler) DeleteAvatar(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } company, svcErr := h.svc.DeleteAvatar(c.Request.Context(), uint(companyID), accountID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, companyPayloadResponse(c.Request.Context(), h.svc.DB(), company)) } // ListContacts retrieves contacts associated with a company. // GET /api/v1/accounts/:id/companies/:company_id/contacts?page=1&per_page=25 func (h *CompanyHandler) ListContacts(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } pg := parseCompanyPagination(c) contacts, total, svcErr := h.svc.ListContacts(c.Request.Context(), uint(companyID), accountID, pg.Offset, pg.PerPage) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, companyContactsResponse(c.Request.Context(), h.svc.DB(), uint(companyID), contacts, total, pg.Page)) } // ListConversations retrieves conversations for contacts of a company. // GET /api/v1/accounts/:id/companies/:company_id/conversations?page=1&per_page=25 func (h *CompanyHandler) ListConversations(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } conversations, _, svcErr := h.svc.ListConversations(c.Request.Context(), uint(companyID), accountID, 0, 20) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{"payload": serializeConversationPayloads(c.Request.Context(), h.svc.DB(), conversations)}) } func (h *CompanyHandler) SearchContacts(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } query := c.Query("q") if query == "" { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"}) return } pg := parseCompanyPagination(c) contacts, total, svcErr := h.svc.SearchContacts(c.Request.Context(), uint(companyID), accountID, query, pg.Offset, pg.PerPage) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, companyContactsResponse(c.Request.Context(), h.svc.DB(), uint(companyID), contacts, total, pg.Page)) } // ListNotes retrieves notes for a company. // GET /api/v1/accounts/:id/companies/:company_id/notes?page=1&per_page=25 func (h *CompanyHandler) ListNotes(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } pg := pagination.Parse(c) notes, total, svcErr := h.svc.ListNotes(c.Request.Context(), uint(companyID), accountID, pg.Offset, pg.PerPage) if svcErr != nil { handleServiceError(c, svcErr) return } _ = total c.JSON(http.StatusOK, companyNotesResponse(c.Request.Context(), h.svc.DB(), notes, accountID)) } // CreateNote creates a note for a company. // POST /api/v1/accounts/:id/companies/:company_id/notes func (h *CompanyHandler) CreateNote(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not identified") return } var req service.CreateCompanyNoteRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } note, svcErr := h.svc.CreateNote(c.Request.Context(), uint(companyID), accountID, userID, &req) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{"payload": serializeCompanyNote(c.Request.Context(), h.svc.DB(), note, accountID)}) } // DeleteNote deletes a note from a company. // DELETE /api/v1/accounts/:id/companies/:company_id/notes/:note_id func (h *CompanyHandler) DeleteNote(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } noteID, err := strconv.ParseUint(c.Param("note_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid note id") return } if err := h.svc.DeleteNote(c.Request.Context(), uint(noteID), uint(companyID), accountID); err != nil { handleServiceError(c, err) return } c.Status(http.StatusOK) } // AddContact adds a contact to a company. // POST /api/v1/accounts/:id/companies/:company_id/contacts/:contact_id func (h *CompanyHandler) AddContact(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } contactID, err := parseCompanyContactID(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id") return } if err := h.svc.AddContact(c.Request.Context(), uint(companyID), accountID, uint(contactID)); err != nil { handleServiceError(c, err) return } contact, svcErr := h.svc.GetContact(c.Request.Context(), uint(companyID), accountID, contactID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{"payload": serializeCompanyContact(c.Request.Context(), h.svc.DB(), contact, uint(companyID))}) } // RemoveContact removes a contact from a company. // DELETE /api/v1/accounts/:id/companies/:company_id/contacts/:contact_id func (h *CompanyHandler) RemoveContact(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company id") return } contactID, err := strconv.ParseUint(c.Param("contact_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id") return } if err := h.svc.RemoveContact(c.Request.Context(), uint(companyID), accountID, uint(contactID)); err != nil { handleServiceError(c, err) return } c.Status(http.StatusOK) } func parseCompanyContactID(c *gin.Context) (uint, error) { if c.Param("contact_id") != "" { return parseUintParam(c, "contact_id") } var req struct { ContactID uint `json:"contact_id"` } if err := c.ShouldBindJSON(&req); err != nil { return 0, err } if req.ContactID == 0 { return 0, strconv.ErrSyntax } return req.ContactID, nil } type companyRequest interface { *service.CreateCompanyRequest | *service.UpdateCompanyRequest } func parseCompanyPagination(c *gin.Context) pagination.Params { pg := pagination.Parse(c) pg.PerPage = service.CompanyResultsPerPage pg.Offset = (pg.Page - 1) * pg.PerPage return pg } func bindCompanyRequest[T companyRequest](c *gin.Context, req T) error { if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") { return c.ShouldBindJSON(req) } if err := c.Request.ParseMultipartForm(32 << 20); err != nil { return err } name := companyFormValue(c, "name") description := companyFormValue(c, "description") websiteURL := companyFormValue(c, "website_url") faviconURL := companyFormValue(c, "favicon_url") domain := companyFormValue(c, "domain") if file, err := c.FormFile("company[avatar]"); err == nil && file != nil { faviconURL = file.Filename } customAttributes := companyFormJSON(c, "custom_attributes") switch r := any(req).(type) { case *service.CreateCompanyRequest: r.Name = name r.Description = description r.WebsiteURL = websiteURL r.FaviconURL = faviconURL r.Domain = domain r.CustomAttributes = customAttributes case *service.UpdateCompanyRequest: r.Name = name r.Description = description r.WebsiteURL = websiteURL r.FaviconURL = faviconURL r.Domain = domain r.CustomAttributes = customAttributes } return nil } func companyFormValue(c *gin.Context, key string) string { if value := c.PostForm("company[" + key + "]"); value != "" { return value } return c.PostForm(key) } func companyFormJSON(c *gin.Context, key string) []byte { if c.Request.MultipartForm == nil { return nil } if value := companyFormValue(c, key); value != "" { if json.Valid([]byte(value)) { return []byte(value) } } prefix := "company[" + key + "][" attrs := map[string]any{} for formKey, values := range c.Request.MultipartForm.Value { if !strings.HasPrefix(formKey, prefix) || !strings.HasSuffix(formKey, "]") || len(values) == 0 { continue } attrKey := strings.TrimSuffix(strings.TrimPrefix(formKey, prefix), "]") attrs[attrKey] = values[0] } if len(attrs) == 0 { return nil } data, _ := json.Marshal(attrs) return data }