1178 lines
38 KiB
Go
1178 lines
38 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/search"
|
|
"github.com/gochat/gochat/internal/service"
|
|
"github.com/gochat/gochat/internal/ws"
|
|
"github.com/gochat/gochat/pkg/response"
|
|
)
|
|
|
|
// ContactHandler handles contact-related API endpoints.
|
|
// Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb
|
|
type ContactHandler struct {
|
|
svc *service.ContactService
|
|
contactInboxSvc *service.ContactInboxService
|
|
mergeSvc *service.ContactMergeService
|
|
contactNoteSvc *service.ContactNoteService
|
|
conversationSvc *service.ConversationService
|
|
presence contactPresenceReader
|
|
eventPublisher *ws.EventPublisher
|
|
}
|
|
|
|
const chatwootContactResultsPerPage = 15
|
|
|
|
// NewContactHandler creates a new ContactHandler.
|
|
func NewContactHandler(svc *service.ContactService, contactInboxSvc *service.ContactInboxService, mergeSvc *service.ContactMergeService, contactNoteSvc *service.ContactNoteService, conversationSvc ...*service.ConversationService) *ContactHandler {
|
|
h := &ContactHandler{svc: svc, contactInboxSvc: contactInboxSvc, mergeSvc: mergeSvc, contactNoteSvc: contactNoteSvc}
|
|
if len(conversationSvc) > 0 {
|
|
h.conversationSvc = conversationSvc[0]
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (h *ContactHandler) WithEventPublisher(publisher *ws.EventPublisher) *ContactHandler {
|
|
h.eventPublisher = publisher
|
|
return h
|
|
}
|
|
|
|
func (h *ContactHandler) WithContactPresence(presence contactPresenceReader) *ContactHandler {
|
|
h.presence = presence
|
|
return h
|
|
}
|
|
|
|
func (h *ContactHandler) requestContext(c *gin.Context) context.Context {
|
|
return withContactPresence(c.Request.Context(), h.presence)
|
|
}
|
|
|
|
// @Summary List contacts for an account
|
|
// @Description Retrieves all contacts for an account with pagination and optional sort
|
|
// @Tags Contacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path uint true "Account ID"
|
|
// @Param sort query string false "Sort field" default(name)
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param page_size query int false "Items per page" default(25)
|
|
// @Success 200 {object} []model.Contact
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{id}/contacts [get]
|
|
// List retrieves all contacts for an account.
|
|
// GET /api/v1/accounts/:id/contacts?sort=name&page=1&page_size=25
|
|
// Reference: Chatwoot contacts#index (sort param)
|
|
func (h *ContactHandler) List(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
page := getPage(c)
|
|
perPage := chatwootContactResultsPerPage
|
|
offset := (page - 1) * perPage
|
|
sort := c.DefaultQuery("sort", "")
|
|
|
|
contacts, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage, sort, contactLabelsParam(c))
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contacts"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, contactListResponse(h.requestContext(c), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil))
|
|
}
|
|
|
|
// @Summary Search contacts
|
|
// @Description Searches contacts by query string with pagination, sort, and search mode support
|
|
// @Tags Contacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path uint true "Account ID"
|
|
// @Param q query string true "Search query"
|
|
// @Param sort query string false "Sort field"
|
|
// @Param search_mode query string false "Search mode (prefix/semantic/fulltext)" default(prefix)
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param page_size query int false "Items per page" default(25)
|
|
// @Success 200 {object} []model.Contact
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{id}/contacts/search [get]
|
|
// Search searches contacts by query with sort support.
|
|
// GET /api/v1/accounts/:id/contacts/search?q=...&sort=name
|
|
// Reference: Chatwoot contacts#search
|
|
func (h *ContactHandler) Search(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
query := c.Query("q")
|
|
if query == "" {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"})
|
|
return
|
|
}
|
|
page := getPage(c)
|
|
perPage := chatwootContactResultsPerPage
|
|
offset := (page - 1) * perPage
|
|
sort := c.DefaultQuery("sort", "")
|
|
searchMode := search.ParseSearchMode(c.DefaultQuery("search_mode", ""))
|
|
|
|
contacts, total, svcErr := h.svc.Search(c.Request.Context(), accountID, query, offset, perPage, sort, searchMode, contactLabelsParam(c))
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to search contacts"})
|
|
return
|
|
}
|
|
|
|
hasMore := int64(len(contacts)) < total
|
|
c.JSON(http.StatusOK, contactListResponse(h.requestContext(c), h.svc.DB(), contacts, int64(len(contacts)), page, includeContactInboxes(c), &hasMore))
|
|
}
|
|
|
|
// @Summary Get a single contact
|
|
// @Description Retrieves detailed information about a specific contact within an account
|
|
// @Tags Contacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path uint true "Account ID"
|
|
// @Param contact_id path uint true "Contact ID"
|
|
// @Success 200 {object} model.Contact
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{id}/contacts/{contact_id} [get]
|
|
// Get retrieves a single contact.
|
|
// GET /api/v1/accounts/:id/contacts/:contact_id
|
|
func (h *ContactHandler) Get(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
contact, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "contact not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c)))
|
|
}
|
|
|
|
// @Summary Create a new contact
|
|
// @Description Creates a new contact, optionally auto-creating a ContactInbox when inbox_id is provided
|
|
// @Tags Contacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path uint true "Account ID"
|
|
// @Param body body service.CreateContactRequest true "Contact creation payload"
|
|
// @Success 201 {object} model.Contact
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{id}/contacts [post]
|
|
// Create creates a new contact, optionally auto-creating a ContactInbox.
|
|
// POST /api/v1/accounts/:id/contacts
|
|
// Body: {name, email, phone, inbox_id, source_id, ...}
|
|
// Reference: Chatwoot contacts#create (auto-creates ContactInbox when inbox_id provided)
|
|
func (h *ContactHandler) Create(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
var req service.CreateContactRequest
|
|
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
|
return
|
|
}
|
|
|
|
contact, svcErr := h.svc.Create(c.Request.Context(), accountID, req)
|
|
if svcErr != nil {
|
|
var validationErr *service.ContactValidationError
|
|
if errors.As(svcErr, &validationErr) {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": validationErr.Message, "attributes": validationErr.Attributes})
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create contact"})
|
|
return
|
|
}
|
|
|
|
var contactInbox *model.ContactInbox
|
|
if h.svc.DB() != nil {
|
|
var ci model.ContactInbox
|
|
if err := h.svc.DB().WithContext(c.Request.Context()).Preload("Inbox").Where("contact_id = ?", contact.ID).Order("id DESC").First(&ci).Error; err == nil {
|
|
contactInbox = &ci
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, contactCreateResponse(h.requestContext(c), h.svc.DB(), contact, contactInbox))
|
|
}
|
|
|
|
// @Summary Update a contact
|
|
// @Description Modifies an existing contact's details
|
|
// @Tags Contacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path uint true "Account ID"
|
|
// @Param contact_id path uint true "Contact ID"
|
|
// @Param body body service.UpdateContactRequest true "Contact update payload"
|
|
// @Success 200 {object} model.Contact
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{id}/contacts/{contact_id} [put]
|
|
// Update modifies an existing contact.
|
|
// PUT /api/v1/accounts/:id/contacts/:contact_id
|
|
func (h *ContactHandler) Update(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
var req service.UpdateContactRequest
|
|
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
|
return
|
|
}
|
|
|
|
contact, svcErr := h.svc.Update(c.Request.Context(), accountID, contactID, req)
|
|
if svcErr != nil {
|
|
var validationErr *service.ContactValidationError
|
|
if errors.As(svcErr, &validationErr) {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": validationErr.Message, "attributes": validationErr.Attributes})
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update contact"})
|
|
return
|
|
}
|
|
|
|
payload := contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c))
|
|
h.publishContactEvent(accountID, ws.EventContactUpdated, contact)
|
|
c.JSON(http.StatusOK, payload)
|
|
}
|
|
|
|
func (h *ContactHandler) publishContactEvent(accountID uint, eventType string, contact *model.Contact) {
|
|
if h.eventPublisher == nil || contact == nil {
|
|
return
|
|
}
|
|
h.eventPublisher.PublishEvent(accountID, eventType, serializeCRMContact(context.Background(), h.svc.DB(), contact, true))
|
|
}
|
|
|
|
// @Summary Delete a contact
|
|
// @Description Soft-deletes a contact from an account
|
|
// @Tags Contacts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path uint true "Account ID"
|
|
// @Param contact_id path uint true "Contact ID"
|
|
// @Success 200 {object} object
|
|
// @Failure 400 {object} model.ErrorResponse
|
|
// @Failure 401 {object} model.ErrorResponse
|
|
// @Failure 404 {object} model.ErrorResponse
|
|
// @Failure 500 {object} model.ErrorResponse
|
|
// @Security ApiKeyAuth
|
|
// @Router /api/v1/accounts/{id}/contacts/{contact_id} [delete]
|
|
// Delete soft-deletes a contact.
|
|
// DELETE /api/v1/accounts/:id/contacts/:contact_id
|
|
func (h *ContactHandler) Delete(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
contact, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete contact"})
|
|
return
|
|
}
|
|
|
|
if svcErr := h.svc.Delete(c.Request.Context(), accountID, contactID); svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete contact"})
|
|
return
|
|
}
|
|
h.publishContactEvent(accountID, ws.EventContactDeleted, contact)
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
func (h *ContactHandler) InitiateCall(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
InboxID uint `json:"inbox_id" binding:"required"`
|
|
ConversationID *uint `json:"conversation_id"`
|
|
}
|
|
if bindErr := c.ShouldBindJSON(&body); bindErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
|
return
|
|
}
|
|
|
|
result, svcErr := h.svc.InitiateCall(c.Request.Context(), accountID, contactID, service.InitiateContactCallRequest{
|
|
InboxID: body.InboxID,
|
|
ConversationID: body.ConversationID,
|
|
UserID: userID,
|
|
})
|
|
if svcErr != nil {
|
|
if errors.Is(svcErr, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "resource not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (h *ContactHandler) DeleteAvatar(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
|
return
|
|
}
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
|
return
|
|
}
|
|
contact, svcErr := h.svc.DeleteAvatar(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, false))
|
|
}
|
|
|
|
func (h *ContactHandler) ListLabels(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
|
return
|
|
}
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
|
return
|
|
}
|
|
labels, svcErr := h.svc.GetLabels(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"payload": labels})
|
|
}
|
|
|
|
func (h *ContactHandler) UpdateLabels(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
|
return
|
|
}
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
|
return
|
|
}
|
|
var req struct {
|
|
Labels []string `json:"labels"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
|
return
|
|
}
|
|
labels, svcErr := h.svc.UpdateLabels(c.Request.Context(), accountID, contactID, req.Labels)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"payload": labels})
|
|
}
|
|
|
|
// ListContactInboxes retrieves all contact_inboxes for a contact.
|
|
// GET /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes
|
|
// Reference: Chatwoot contacts#contact_inboxes (nested resource)
|
|
func (h *ContactHandler) ListContactInboxes(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
contactInboxes, svcErr := h.svc.ListContactInboxesByAccount(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "contact not found"})
|
|
return
|
|
}
|
|
|
|
payload := make([]any, 0, len(contactInboxes))
|
|
for i := range contactInboxes {
|
|
payload = append(payload, serializeContactInbox(&contactInboxes[i]))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"count": len(payload)}})
|
|
}
|
|
|
|
// ListConversations retrieves recent conversations for a contact.
|
|
// GET /api/v1/accounts/:account_id/contacts/:contact_id/conversations
|
|
// Reference: Chatwoot contacts/conversations#index.
|
|
func (h *ContactHandler) ListConversations(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
if !h.svc.Ready() || h.conversationSvc == nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact conversations"})
|
|
return
|
|
}
|
|
if _, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID); svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
var inboxID *uint
|
|
if rawInboxID := c.Query("inbox_id"); rawInboxID != "" {
|
|
parsed, parseErr := strconv.ParseUint(rawInboxID, 10, 32)
|
|
if parseErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
|
return
|
|
}
|
|
id := uint(parsed)
|
|
inboxID = &id
|
|
}
|
|
|
|
conversations, svcErr := h.conversationSvc.ListRecentByContact(c.Request.Context(), accountID, contactID, inboxID, 20)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact conversations"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"payload": serializeConversationPayloads(c.Request.Context(), h.svc.DB(), conversations)})
|
|
}
|
|
|
|
// ListNotes retrieves notes for a contact.
|
|
// GET /api/v1/accounts/:id/contacts/:contact_id/notes
|
|
// Reference: Chatwoot contacts#notes
|
|
func (h *ContactHandler) ListNotes(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
notes, svcErr := h.svc.ListNotes(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list notes"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, contactNotesResponse(notes))
|
|
}
|
|
|
|
// CreateNote creates a note for a contact.
|
|
// POST /api/v1/accounts/:id/contacts/:contact_id/notes
|
|
// Reference: Chatwoot contacts#create_note
|
|
func (h *ContactHandler) CreateNote(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
|
return
|
|
}
|
|
|
|
req, bindErr := bindContactNoteRequest(c)
|
|
if bindErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
|
return
|
|
}
|
|
|
|
note, svcErr := h.svc.CreateNote(c.Request.Context(), accountID, contactID, userID, req)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create note"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, serializeContactNote(note))
|
|
}
|
|
|
|
// ShowNote retrieves a single note for a contact.
|
|
// GET /api/v1/accounts/:account_id/contacts/:contact_id/notes/:id
|
|
func (h *ContactHandler) ShowNote(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
|
return
|
|
}
|
|
|
|
noteID, err := parseUintParam(c, "note_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid note id")
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
|
return
|
|
}
|
|
|
|
note, svcErr := h.svc.GetNote(c.Request.Context(), accountID, contactID, noteID)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, serializeContactNote(note))
|
|
}
|
|
|
|
// UpdateNote updates a note on a contact.
|
|
// PATCH /api/v1/accounts/:account_id/contacts/:contact_id/notes/:id
|
|
func (h *ContactHandler) UpdateNote(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
|
return
|
|
}
|
|
|
|
noteID, err := parseUintParam(c, "note_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid note id")
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
|
return
|
|
}
|
|
|
|
req, bindErr := bindContactNoteRequest(c)
|
|
if bindErr != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, bindErr.Error())
|
|
return
|
|
}
|
|
|
|
note, svcErr := h.svc.UpdateNote(c.Request.Context(), accountID, contactID, noteID, req)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, serializeContactNote(note))
|
|
}
|
|
|
|
// DestroyNote deletes a note on a contact.
|
|
// DELETE /api/v1/accounts/:account_id/contacts/:contact_id/notes/:id
|
|
func (h *ContactHandler) DestroyNote(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
|
|
return
|
|
}
|
|
|
|
noteID, err := parseUintParam(c, "note_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid note id")
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
|
|
return
|
|
}
|
|
|
|
if svcErr := h.svc.DeleteNote(c.Request.Context(), accountID, contactID, noteID); svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
func bindContactNoteRequest(c *gin.Context) (service.CreateNoteRequest, error) {
|
|
var body struct {
|
|
Content string `json:"content"`
|
|
Note struct {
|
|
Content string `json:"content"`
|
|
} `json:"note"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
return service.CreateNoteRequest{}, err
|
|
}
|
|
content := body.Content
|
|
if content == "" {
|
|
content = body.Note.Content
|
|
}
|
|
return service.CreateNoteRequest{Content: content}, nil
|
|
}
|
|
|
|
func includeContactInboxes(c *gin.Context) bool {
|
|
if raw := c.Query("include_contact_inboxes"); raw != "" {
|
|
return raw == "true"
|
|
}
|
|
return true
|
|
}
|
|
|
|
func contactLabelsParam(c *gin.Context) []string {
|
|
labels := c.QueryArray("labels[]")
|
|
labels = append(labels, c.QueryArray("labels")...)
|
|
return labels
|
|
}
|
|
|
|
// parseIntOrDefault parses an integer query parameter with a default value.
|
|
func parseIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
|
val := c.Query(key)
|
|
if val == "" {
|
|
return defaultVal
|
|
}
|
|
n, err := strconv.Atoi(val)
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
return n
|
|
}
|
|
|
|
// CreateContactInbox adds a contact-inbox association (linking a contact to an inbox).
|
|
// POST /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes
|
|
// Reference: Chatwoot contact_inboxes#create
|
|
func (h *ContactHandler) CreateContactInbox(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
contact, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "contact not found"})
|
|
return
|
|
}
|
|
|
|
req, bindErr := parseNestedContactInboxCreateParams(c)
|
|
if bindErr != nil || req.InboxID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "inbox_id is required"})
|
|
return
|
|
}
|
|
|
|
var inbox model.Inbox
|
|
db := h.svc.DB()
|
|
if db == nil || db.WithContext(c.Request.Context()).Where("account_id = ? AND id = ?", accountID, req.InboxID).First(&inbox).Error != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"})
|
|
return
|
|
}
|
|
|
|
ci, svcErr := h.contactInboxSvc.Create(c.Request.Context(), service.CreateContactInboxRequest{
|
|
ContactID: contact.ID,
|
|
InboxID: inbox.ID,
|
|
SourceID: req.SourceID,
|
|
HMACVerified: req.HMACVerified,
|
|
Contact: contact,
|
|
Inbox: &inbox,
|
|
})
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create contact inbox"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, serializeContactInbox(ci))
|
|
}
|
|
|
|
type nestedContactInboxCreateParams struct {
|
|
InboxID uint
|
|
SourceID string
|
|
HMACVerified bool
|
|
}
|
|
|
|
func parseNestedContactInboxCreateParams(c *gin.Context) (nestedContactInboxCreateParams, error) {
|
|
params := nestedContactInboxCreateParams{}
|
|
if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "application/json") {
|
|
var body map[string]any
|
|
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
return params, err
|
|
}
|
|
params.InboxID = uintValue(body["inbox_id"])
|
|
params.SourceID = stringValue(body["source_id"])
|
|
params.HMACVerified = boolValue(body["hmac_verified"])
|
|
} else {
|
|
if err := c.Request.ParseForm(); err != nil {
|
|
return params, err
|
|
}
|
|
params.InboxID = uintStringValue(c.PostForm("inbox_id"))
|
|
params.SourceID = c.PostForm("source_id")
|
|
params.HMACVerified = boolStringValue(c.PostForm("hmac_verified"))
|
|
}
|
|
if value := c.Query("inbox_id"); value != "" {
|
|
params.InboxID = uintStringValue(value)
|
|
}
|
|
if value := c.Query("source_id"); value != "" {
|
|
params.SourceID = value
|
|
}
|
|
if value := c.Query("hmac_verified"); value != "" {
|
|
params.HMACVerified = boolStringValue(value)
|
|
}
|
|
return params, nil
|
|
}
|
|
|
|
func uintValue(value any) uint {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return uint(v)
|
|
case json.Number:
|
|
if n, err := strconv.ParseUint(string(v), 10, 64); err == nil {
|
|
return uint(n)
|
|
}
|
|
case string:
|
|
return uintStringValue(v)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func uintStringValue(value string) uint {
|
|
n, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return uint(n)
|
|
}
|
|
|
|
func stringValue(value any) string {
|
|
if v, ok := value.(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func boolValue(value any) bool {
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return boolStringValue(v)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func boolStringValue(value string) bool {
|
|
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
|
return err == nil && parsed
|
|
}
|
|
|
|
// DeleteContactInbox removes a contact-inbox association.
|
|
// DELETE /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes/:inbox_id
|
|
// Reference: Chatwoot contact_inboxes#destroy
|
|
func (h *ContactHandler) DeleteContactInbox(c *gin.Context) {
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
inboxID, err := parseUintParam(c, "inbox_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
|
return
|
|
}
|
|
|
|
if svcErr := h.contactInboxSvc.DeleteByContactAndInbox(c.Request.Context(), contactID, inboxID); svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete contact inbox"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"contact_id": contactID, "inbox_id": inboxID, "deleted": true})
|
|
}
|
|
|
|
// Active retrieves contacts with recent activity.
|
|
// GET /api/v1/accounts/:id/contacts/active?sort=name&page=1
|
|
// Reference: Chatwoot contacts#active
|
|
func (h *ContactHandler) Active(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
page := getPage(c)
|
|
perPage := chatwootContactResultsPerPage
|
|
offset := (page - 1) * perPage
|
|
sort := c.DefaultQuery("sort", "")
|
|
if !h.svc.Ready() {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list active contacts"})
|
|
return
|
|
}
|
|
|
|
contacts, total, svcErr := h.svc.ListActive(c.Request.Context(), accountID, offset, perPage, sort)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list active contacts"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, contactListResponse(h.requestContext(c), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil))
|
|
}
|
|
|
|
// Export downloads contacts as CSV.
|
|
// GET /api/v1/accounts/:id/contacts/export
|
|
// Reference: Chatwoot contacts#export
|
|
func (h *ContactHandler) Export(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "text/csv")
|
|
c.Header("Content-Disposition", "attachment; filename=contacts.csv")
|
|
|
|
if svcErr := h.svc.ExportCSV(c.Request.Context(), accountID, c.Writer); svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to export contacts"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// ExportRequest accepts Chatwoot's asynchronous contact export request.
|
|
// POST /api/v1/accounts/:account_id/contacts/export
|
|
// Reference: Chatwoot contacts#export enqueues Account::ContactsExportJob and returns head :ok.
|
|
func (h *ContactHandler) ExportRequest(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
if !h.svc.Ready() {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to export contacts"})
|
|
return
|
|
}
|
|
|
|
var req service.ContactExportRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to export contacts"})
|
|
return
|
|
}
|
|
|
|
if _, svcErr := h.svc.ExportContacts(c.Request.Context(), accountID, getUserID(c), req); svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to export contacts"})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// DownloadExport streams a previously generated contacts export artifact.
|
|
func (h *ContactHandler) DownloadExport(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
exportID, err := parseUintParam(c, "export_id")
|
|
if err != nil || exportID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid export id"})
|
|
return
|
|
}
|
|
export, svcErr := h.svc.GetContactExport(c.Request.Context(), accountID, exportID)
|
|
if svcErr != nil || len(export.CSVData) == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "export not found"})
|
|
return
|
|
}
|
|
c.Header("Content-Disposition", "attachment; filename="+export.FileName)
|
|
c.Data(http.StatusOK, export.ContentType, export.CSVData)
|
|
}
|
|
|
|
// Import uploads contacts from a CSV file.
|
|
// POST /api/v1/accounts/:id/contacts/import
|
|
// Reference: Chatwoot contacts#import
|
|
func (h *ContactHandler) Import(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
file, _, fileErr := c.Request.FormFile("import_file")
|
|
if fileErr != nil {
|
|
file, _, fileErr = c.Request.FormFile("file")
|
|
}
|
|
if fileErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "File is blank"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
_, svcErr := h.svc.ImportContacts(c.Request.Context(), accountID, getUserID(c), file)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to import contacts"})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// ContactableInboxes returns inboxes that a contact can be associated with.
|
|
// GET /api/v1/accounts/:id/contacts/:contact_id/contactable_inboxes
|
|
// Reference: Chatwoot contacts#contactable_inboxes
|
|
func (h *ContactHandler) ContactableInboxes(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
inboxes, svcErr := h.svc.GetContactableInboxes(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to get contactable inboxes"})
|
|
return
|
|
}
|
|
|
|
payload := make([]any, 0, len(inboxes))
|
|
for _, item := range inboxes {
|
|
payload = append(payload, map[string]any{"inbox": serializeInboxSlim(&item.Inbox), "source_id": item.SourceID})
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"payload": payload})
|
|
}
|
|
|
|
// ListAttachments returns a contact's shared files across all visible conversations.
|
|
// GET /api/v1/accounts/:id/contacts/:contact_id/attachments
|
|
// Reference: Chatwoot Api::V1::Accounts::Contacts::AttachmentsController#index
|
|
func (h *ContactHandler) ListAttachments(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
_, offset := fixedPageOffset(c, chatwootAttachmentResultsPerPage)
|
|
attachments, total, svcErr := h.svc.ListAttachments(c.Request.Context(), accountID, contactID, offset, chatwootAttachmentResultsPerPage)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact attachments"})
|
|
return
|
|
}
|
|
|
|
payload := make([]any, 0, len(attachments))
|
|
for i := range attachments {
|
|
payload = append(payload, serializeAttachmentWithConversation(c.Request.Context(), h.svc.DB(), &attachments[i]))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"meta": gin.H{"total_count": total}, "payload": payload})
|
|
}
|
|
|
|
// DeleteCustomAttributes removes all custom attributes from a contact.
|
|
// DELETE /api/v1/accounts/:id/contacts/:contact_id/custom_attributes
|
|
// Reference: Chatwoot contacts#destroy_custom_attributes
|
|
func (h *ContactHandler) DeleteCustomAttributes(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
|
return
|
|
}
|
|
|
|
if svcErr := h.svc.DeleteCustomAttributes(c.Request.Context(), accountID, contactID); svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete custom attributes"})
|
|
return
|
|
}
|
|
|
|
contact, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to load contact"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, true))
|
|
}
|
|
|
|
// Merge two contacts into one. The base contact survives, mergee is deleted.
|
|
// POST /api/v1/accounts/:id/contacts/merge
|
|
// Reference: Chatwoot app/controllers/api/v1/accounts/actions/contact_merges_controller.rb
|
|
func (h *ContactHandler) Merge(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
var req service.MergeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: base_contact_id and mergee_contact_id required"})
|
|
return
|
|
}
|
|
|
|
result, svcErr := h.mergeSvc.MergeWithRequest(c.Request.Context(), accountID, req)
|
|
if svcErr != nil {
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
h.publishContactEvent(accountID, ws.EventContactUpdated, result)
|
|
if req.BaseContactID != req.MergeeContactID {
|
|
h.publishContactEvent(accountID, ws.EventContactDeleted, &model.Contact{Base: model.Base{ID: req.MergeeContactID}, AccountID: accountID})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, serializeCRMContact(h.requestContext(c), h.svc.DB(), result, false))
|
|
}
|
|
|
|
// Filter retrieves contacts matching advanced filter criteria.
|
|
// POST /api/v1/accounts/:id/contacts/filter
|
|
// Reference: Chatwoot contacts#filter — uses ContactFilterService with payload params.
|
|
func (h *ContactHandler) Filter(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
var params repository.ContactFilterParams
|
|
if err := c.ShouldBindJSON(¶ms); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
page := getPage(c)
|
|
perPage := chatwootContactResultsPerPage
|
|
offset := (page - 1) * perPage
|
|
contacts, total, svcErr := h.svc.Filter(c.Request.Context(), accountID, params, offset, perPage)
|
|
if svcErr != nil {
|
|
if len(params.Payload) > 0 {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to filter contacts"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, contactListResponse(h.requestContext(c), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil))
|
|
}
|
|
|
|
// DestroyCustomAttributes removes all custom attributes from a contact.
|
|
// POST /api/v1/accounts/:id/contacts/:contact_id/destroy_custom_attributes
|
|
// Reference: Chatwoot contacts#destroy_custom_attributes — uses POST method.
|
|
// This is an alias for DeleteCustomAttributes (which uses DELETE method) to match Chatwoot's API.
|
|
func (h *ContactHandler) DestroyCustomAttributes(c *gin.Context) {
|
|
accountID := parseAccountIDParam(c)
|
|
if accountID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
|
return
|
|
}
|
|
|
|
contactID, err := parseUintParam(c, "contact_id")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact 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
|
|
}
|
|
contact, svcErr := h.svc.DestroyCustomAttributes(c.Request.Context(), accountID, contactID, req.CustomAttributes)
|
|
if svcErr != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to destroy custom attributes"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, true))
|
|
}
|