Files
gochat/backend/internal/handler/api/v1/contact_handler.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

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(&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 {
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))
}