Files
gochat/internal/handler/api/v1/contact_handler.go
T

1021 lines
33 KiB
Go

package v1
import (
"errors"
"io"
"net/http"
"strconv"
"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/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
}
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
}
// @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(c.Request.Context(), 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(c.Request.Context(), 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(c.Request.Context(), 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 {
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(c.Request.Context(), 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 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update contact"})
return
}
c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, includeContactInboxes(c)))
}
// @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
}
if svcErr := h.svc.Delete(c.Request.Context(), accountID, contactID); svcErr != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete contact"})
return
}
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(c.Request.Context(), 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) {
contactID, err := parseUintParam(c, "contact_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
return
}
contactInboxes, svcErr := h.svc.ListContactInboxes(c.Request.Context(), contactID)
if svcErr != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact inboxes"})
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) {
contactID, err := parseUintParam(c, "contact_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
return
}
var req struct {
InboxID uint `json:"inbox_id" binding:"required"`
SourceID string `json:"source_id"`
}
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
return
}
ci, svcErr := h.contactInboxSvc.Create(c.Request.Context(), service.CreateContactInboxRequest{
ContactID: contactID,
InboxID: req.InboxID,
SourceID: req.SourceID,
})
if svcErr != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create contact inbox"})
return
}
c.JSON(http.StatusCreated, ci)
}
// 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(c.Request.Context(), 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": "failed to import contacts"})
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(c.Request.Context(), 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
}
c.JSON(http.StatusOK, serializeCRMContact(c.Request.Context(), 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(&params); 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 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to filter contacts"})
return
}
c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), 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(c.Request.Context(), h.svc.DB(), contact, true))
}