package v1 import ( "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/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) { 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(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(¶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 { 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)) }