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.
This commit is contained in:
@@ -0,0 +1,961 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
// InboxHandler handles inbox-related API endpoints.
|
||||
// Reference: Chatwoot app/controllers/api/v1/inboxes_controller.rb
|
||||
type InboxHandler struct {
|
||||
svc *service.InboxService
|
||||
auditSvc *service.AuditService
|
||||
}
|
||||
|
||||
// NewInboxHandler creates a new InboxHandler.
|
||||
func NewInboxHandler(svc *service.InboxService) *InboxHandler {
|
||||
return &InboxHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *InboxHandler) WithAuditService(auditSvc *service.AuditService) *InboxHandler {
|
||||
h.auditSvc = auditSvc
|
||||
return h
|
||||
}
|
||||
|
||||
// @Summary List inboxes for an account
|
||||
// @Description Retrieves all inboxes for an account with pagination
|
||||
// @Tags Inboxes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "Account ID"
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param page_size query int false "Items per page" default(25)
|
||||
// @Success 200 {object} []model.Inbox
|
||||
// @Failure 400 {object} model.ErrorResponse
|
||||
// @Failure 401 {object} model.ErrorResponse
|
||||
// @Failure 500 {object} model.ErrorResponse
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /api/v1/accounts/{id}/inboxes [get]
|
||||
// List retrieves all inboxes for an account.
|
||||
// GET /api/v1/accounts/:id/inboxes
|
||||
func (h *InboxHandler) List(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list inboxes")
|
||||
return
|
||||
}
|
||||
|
||||
page := getPage(c)
|
||||
perPage := getPageSize(c)
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
inboxes, _, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage)
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list inboxes"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, inboxListResponse(inboxes, h.svc.DB(), inboxSerializationAdmin(c)))
|
||||
}
|
||||
|
||||
// @Summary Get a single inbox
|
||||
// @Description Retrieves detailed information about a specific inbox within an account
|
||||
// @Tags Inboxes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "Account ID"
|
||||
// @Param inbox_id path uint true "Inbox ID"
|
||||
// @Success 200 {object} model.Inbox
|
||||
// @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}/inboxes/{inbox_id} [get]
|
||||
// Get retrieves a single inbox.
|
||||
// GET /api/v1/accounts/:id/inboxes/:inbox_id
|
||||
func (h *InboxHandler) Get(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to get inbox")
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
inbox, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, inboxID)
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, serializeInbox(inbox, h.svc.DB(), inboxSerializationAdmin(c)))
|
||||
}
|
||||
|
||||
// @Summary Create a new inbox
|
||||
// @Description Creates a new inbox for an account
|
||||
// @Tags Inboxes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "Account ID"
|
||||
// @Param body body service.CreateInboxRequest true "Inbox creation payload"
|
||||
// @Success 201 {object} model.Inbox
|
||||
// @Failure 400 {object} model.ErrorResponse
|
||||
// @Failure 401 {object} model.ErrorResponse
|
||||
// @Failure 500 {object} model.ErrorResponse
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /api/v1/accounts/{id}/inboxes [post]
|
||||
// Create creates a new inbox.
|
||||
// POST /api/v1/accounts/:id/inboxes
|
||||
func (h *InboxHandler) Create(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.CreateInboxRequest
|
||||
if bindErr := bindCreateInboxRequest(c, &req); bindErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
||||
return
|
||||
}
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to create inbox")
|
||||
return
|
||||
}
|
||||
|
||||
inbox, svcErr := h.svc.Create(c.Request.Context(), accountID, req)
|
||||
if svcErr != nil {
|
||||
if renderInboxLimitExceeded(c, svcErr) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
|
||||
return
|
||||
}
|
||||
recordAuditMutation(c, h.auditSvc, auditMutation{
|
||||
AccountID: accountID,
|
||||
AuditableType: "Inbox",
|
||||
AuditableID: inbox.ID,
|
||||
Action: "create",
|
||||
AuditedChanges: serializeInbox(inbox, h.svc.DB(), true),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, serializeInbox(inbox, h.svc.DB(), inboxSerializationAdmin(c)))
|
||||
}
|
||||
|
||||
// WhatsAppAuthorization handles Chatwoot's embedded signup callback for
|
||||
// WhatsApp Cloud channel creation and reauthorization.
|
||||
// POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
func (h *InboxHandler) WhatsAppAuthorization(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
var req service.WhatsAppAuthorizationRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.AuthorizeWhatsAppEmbeddedSignup(c.Request.Context(), accountID, req)
|
||||
if svcErr != nil {
|
||||
status := http.StatusUnprocessableEntity
|
||||
if req.InboxID != nil && strings.Contains(strings.ToLower(svcErr.Error()), "record not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"success": false, "error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
payload := gin.H{
|
||||
"success": true,
|
||||
"id": result.Inbox.ID,
|
||||
"name": result.Inbox.Name,
|
||||
"channel_type": "whatsapp",
|
||||
}
|
||||
if result.Message != "" {
|
||||
payload["message"] = result.Message
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// @Summary Update an inbox
|
||||
// @Description Updates an existing inbox's configuration
|
||||
// @Tags Inboxes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "Account ID"
|
||||
// @Param inbox_id path uint true "Inbox ID"
|
||||
// @Param body body service.UpdateInboxRequest true "Inbox update payload"
|
||||
// @Success 200 {object} model.Inbox
|
||||
// @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}/inboxes/{inbox_id} [put]
|
||||
// Update updates an inbox.
|
||||
// PUT /api/v1/accounts/:id/inboxes/:inbox_id
|
||||
// Reference: Chatwoot inboxes#update
|
||||
func (h *InboxHandler) Update(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.UpdateInboxRequest
|
||||
if bindErr := bindUpdateInboxRequest(c, &req); bindErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
||||
return
|
||||
}
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update inbox")
|
||||
return
|
||||
}
|
||||
|
||||
inbox, svcErr := h.svc.Update(c.Request.Context(), accountID, inboxID, req)
|
||||
if svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update inbox"})
|
||||
return
|
||||
}
|
||||
recordAuditMutation(c, h.auditSvc, auditMutation{
|
||||
AccountID: accountID,
|
||||
AuditableType: "Inbox",
|
||||
AuditableID: inbox.ID,
|
||||
Action: "update",
|
||||
AuditedChanges: serializeInbox(inbox, h.svc.DB(), true),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, serializeInbox(inbox, h.svc.DB(), inboxSerializationAdmin(c)))
|
||||
}
|
||||
|
||||
// @Summary Delete an inbox
|
||||
// @Description Deletes an inbox from an account
|
||||
// @Tags Inboxes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "Account ID"
|
||||
// @Param inbox_id path uint true "Inbox 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}/inboxes/{inbox_id} [delete]
|
||||
// Delete deletes an inbox.
|
||||
// DELETE /api/v1/accounts/:id/inboxes/:inbox_id
|
||||
// Reference: Chatwoot inboxes#destroy
|
||||
func (h *InboxHandler) Delete(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete inbox")
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
if svcErr := h.svc.DeleteByAccount(c.Request.Context(), accountID, inboxID); svcErr != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete inbox"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Your inbox deletion request will be processed in some time."})
|
||||
}
|
||||
|
||||
func renderInboxLimitExceeded(c *gin.Context, err error) bool {
|
||||
if !service.IsInboxLimitExceeded(err) {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"error": service.InboxLimitExceededMessage})
|
||||
return true
|
||||
}
|
||||
|
||||
func bindCreateInboxRequest(c *gin.Context, req *service.CreateInboxRequest) error {
|
||||
if strings.Contains(c.ContentType(), "json") {
|
||||
return bindCreateInboxJSON(c, req)
|
||||
}
|
||||
values, err := inboxFormValues(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyCreateInboxForm(req, values)
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindUpdateInboxRequest(c *gin.Context, req *service.UpdateInboxRequest) error {
|
||||
if strings.Contains(c.ContentType(), "json") {
|
||||
return bindUpdateInboxJSON(c, req)
|
||||
}
|
||||
values, err := inboxFormValues(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyUpdateInboxForm(req, values)
|
||||
return nil
|
||||
}
|
||||
|
||||
func inboxFormValues(c *gin.Context) (map[string][]string, error) {
|
||||
values := map[string][]string{}
|
||||
if strings.Contains(c.ContentType(), "multipart/form-data") {
|
||||
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Request.MultipartForm != nil {
|
||||
for key, list := range c.Request.MultipartForm.Value {
|
||||
values[key] = append(values[key], list...)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
} else if err := c.Request.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, list := range c.Request.PostForm {
|
||||
values[key] = append(values[key], list...)
|
||||
}
|
||||
for key, list := range c.Request.Form {
|
||||
if _, ok := values[key]; !ok {
|
||||
values[key] = append(values[key], list...)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func bindCreateInboxJSON(c *gin.Context, req *service.CreateInboxRequest) error {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(body, req); err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
hoistInboxChannelJSONFields(&req.Channel, raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindUpdateInboxJSON(c *gin.Context, req *service.UpdateInboxRequest) error {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(body, req); err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
hoistInboxChannelJSONFields(&req.Channel, raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hoistInboxChannelJSONFields(channel *map[string]any, raw map[string]any) {
|
||||
for key, value := range raw {
|
||||
if !isInboxChannelConfigKey(key) || value == nil {
|
||||
continue
|
||||
}
|
||||
if *channel == nil {
|
||||
*channel = map[string]any{}
|
||||
}
|
||||
if _, exists := (*channel)[key]; !exists {
|
||||
(*channel)[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isInboxChannelConfigKey(key string) bool {
|
||||
switch key {
|
||||
case "website_url", "widget_color", "welcome_title", "welcome_tagline", "reply_time",
|
||||
"pre_chat_form_enabled", "pre_chat_form_options", "continuity_via_email", "hmac_mandatory",
|
||||
"allowed_domains", "selected_feature_flags", "webhook_url", "additional_attributes",
|
||||
"email", "forward_to_email", "imap_login", "imap_password", "imap_address", "imap_port",
|
||||
"imap_enabled", "imap_enable_ssl", "imap_authentication", "smtp_login", "smtp_password",
|
||||
"smtp_address", "smtp_port", "smtp_enabled", "smtp_domain", "smtp_enable_ssl_tls",
|
||||
"smtp_enable_starttls_auto", "smtp_openssl_verify_mode", "smtp_authentication", "provider",
|
||||
"provider_config", "phone_number", "message_templates", "account_sid", "auth_token", "api_key_sid",
|
||||
"api_key_secret", "messaging_service_sid", "medium", "content_templates", "twiml_app_sid",
|
||||
"voice_enabled", "line_channel_id", "line_channel_secret", "line_channel_token", "bot_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func applyCreateInboxForm(req *service.CreateInboxRequest, values map[string][]string) {
|
||||
req.Channel = map[string]any{}
|
||||
for key, list := range values {
|
||||
value := inboxFormValue(list)
|
||||
if strings.HasPrefix(key, "channel[") {
|
||||
applyInboxNestedValue(req.Channel, key, list)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(key, "csat_config[") {
|
||||
if req.CsatConfig == nil {
|
||||
req.CsatConfig = map[string]any{}
|
||||
}
|
||||
applyInboxNestedValue(req.CsatConfig, key, list)
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "name":
|
||||
req.Name = value
|
||||
case "channel_type":
|
||||
req.ChannelType = value
|
||||
case "enabled":
|
||||
req.Enabled = inboxBool(value)
|
||||
case "enable_auto_assignment":
|
||||
req.EnableAutoAssignment = inboxBool(value)
|
||||
case "greeting_enabled":
|
||||
req.GreetingEnabled = inboxBoolPtr(value)
|
||||
case "greeting_message":
|
||||
req.GreetingMessage = inboxStringPtr(value)
|
||||
case "enable_email_collect":
|
||||
req.EnableEmailCollect = inboxBoolPtr(value)
|
||||
case "csat_survey_enabled":
|
||||
req.CsatSurveyEnabled = inboxBoolPtr(value)
|
||||
case "working_hours_enabled":
|
||||
req.WorkingHoursEnabled = inboxBoolPtr(value)
|
||||
case "out_of_office_message":
|
||||
req.OutOfOfficeMessage = inboxStringPtr(value)
|
||||
case "timezone":
|
||||
req.Timezone = inboxStringPtr(value)
|
||||
case "allow_messages_after_resolved":
|
||||
req.AllowMessagesAfterResolved = inboxBoolPtr(value)
|
||||
case "lock_to_single_conversation":
|
||||
req.LockToSingleConversation = inboxBoolPtr(value)
|
||||
case "portal_id":
|
||||
req.PortalID = inboxUintPtr(value)
|
||||
case "sender_name_type":
|
||||
req.SenderNameType = inboxStringPtr(value)
|
||||
case "business_name":
|
||||
req.BusinessName = inboxStringPtr(value)
|
||||
case "csat_config":
|
||||
req.CsatConfig = inboxJSONObject(value)
|
||||
case "working_hours":
|
||||
req.WorkingHours = inboxWorkingHours(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyUpdateInboxForm(req *service.UpdateInboxRequest, values map[string][]string) {
|
||||
req.Channel = map[string]any{}
|
||||
for key, list := range values {
|
||||
value := inboxFormValue(list)
|
||||
if strings.HasPrefix(key, "channel[") {
|
||||
applyInboxNestedValue(req.Channel, key, list)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(key, "csat_config[") {
|
||||
if req.CsatConfig == nil {
|
||||
req.CsatConfig = map[string]any{}
|
||||
}
|
||||
applyInboxNestedValue(req.CsatConfig, key, list)
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "name":
|
||||
req.Name = value
|
||||
case "enabled":
|
||||
req.Enabled = inboxBoolPtr(value)
|
||||
case "enable_auto_assignment":
|
||||
req.EnableAutoAssignment = inboxBoolPtr(value)
|
||||
case "greeting_enabled":
|
||||
req.GreetingEnabled = inboxBoolPtr(value)
|
||||
case "greeting_message":
|
||||
req.GreetingMessage = inboxStringPtr(value)
|
||||
case "enable_email_collect":
|
||||
req.EnableEmailCollect = inboxBoolPtr(value)
|
||||
case "csat_survey_enabled":
|
||||
req.CsatSurveyEnabled = inboxBoolPtr(value)
|
||||
case "working_hours_enabled":
|
||||
req.WorkingHoursEnabled = inboxBoolPtr(value)
|
||||
case "out_of_office_message":
|
||||
req.OutOfOfficeMessage = inboxStringPtr(value)
|
||||
case "timezone":
|
||||
req.Timezone = inboxStringPtr(value)
|
||||
case "allow_messages_after_resolved":
|
||||
req.AllowMessagesAfterResolved = inboxBoolPtr(value)
|
||||
case "lock_to_single_conversation":
|
||||
req.LockToSingleConversation = inboxBoolPtr(value)
|
||||
case "portal_id":
|
||||
req.PortalID = inboxUintPtr(value)
|
||||
case "sender_name_type":
|
||||
req.SenderNameType = inboxStringPtr(value)
|
||||
case "business_name":
|
||||
req.BusinessName = inboxStringPtr(value)
|
||||
case "csat_config":
|
||||
req.CsatConfig = inboxJSONObject(value)
|
||||
case "working_hours":
|
||||
req.WorkingHours = inboxWorkingHours(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyInboxNestedValue(target map[string]any, key string, values []string) {
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(key[strings.Index(key, "[")+1:], ""), "]")
|
||||
parts := strings.Split(inner, "][")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 || (len(parts) == 2 && parts[1] == "") {
|
||||
if len(parts) == 2 && parts[1] == "" {
|
||||
target[parts[0]] = append([]string(nil), values...)
|
||||
return
|
||||
}
|
||||
target[parts[0]] = inboxFormValue(values)
|
||||
return
|
||||
}
|
||||
nested, _ := target[parts[0]].(map[string]any)
|
||||
if nested == nil {
|
||||
nested = map[string]any{}
|
||||
target[parts[0]] = nested
|
||||
}
|
||||
if len(parts) == 3 && parts[2] == "" {
|
||||
nested[parts[1]] = append([]string(nil), values...)
|
||||
return
|
||||
}
|
||||
nested[parts[1]] = inboxFormValue(values)
|
||||
}
|
||||
|
||||
func inboxFormValue(values []string) string {
|
||||
if len(values) == 0 || values[0] == "null" {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func inboxBool(value string) bool {
|
||||
parsed, _ := strconv.ParseBool(value)
|
||||
return parsed
|
||||
}
|
||||
|
||||
func inboxBoolPtr(value string) *bool {
|
||||
parsed := inboxBool(value)
|
||||
return &parsed
|
||||
}
|
||||
|
||||
func inboxStringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func inboxUintPtr(value string) *uint {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
uintValue := uint(parsed)
|
||||
return &uintValue
|
||||
}
|
||||
|
||||
func inboxJSONObject(value string) map[string]any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func inboxWorkingHours(value string) []repository.WorkingHourUpdateParam {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var data []repository.WorkingHourUpdateParam
|
||||
if err := json.Unmarshal([]byte(value), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Member-action handlers (Chatwoot InboxesController member routes)
|
||||
// ========================================
|
||||
|
||||
// SetAgentBot sets or removes an agent bot from an inbox.
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/set_agent_bot
|
||||
// Reference: Chatwoot InboxesController#set_agent_bot
|
||||
func (h *InboxHandler) SetAgentBot(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
req := service.SetAgentBotRequest{}
|
||||
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if c.Request.ContentLength < 0 && errors.Is(err, io.EOF) {
|
||||
// Chunked empty body behaves like omitted params in Chatwoot and disconnects the bot.
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to set agent bot")
|
||||
return
|
||||
}
|
||||
|
||||
_, svcErr := h.svc.SetAgentBot(c.Request.Context(), accountID, inboxID, req)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Health checks the health status of an inbox's channel connection.
|
||||
// GET /api/v1/accounts/:id/inboxes/:inbox_id/health
|
||||
// Reference: Chatwoot InboxesController#health
|
||||
func (h *InboxHandler) Health(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to check inbox health")
|
||||
return
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.Health(c.Request.Context(), accountID, inboxID)
|
||||
if svcErr != nil {
|
||||
if errors.Is(svcErr, service.ErrInboxHealthWhatsAppCloudOnly) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.InboxHealthWhatsAppCloudOnlyMessage})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SyncTemplates syncs message templates for an inbox's channel (WhatsApp only).
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/sync_templates
|
||||
// Reference: Chatwoot InboxesController#sync_templates
|
||||
func (h *InboxHandler) SyncTemplates(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to sync templates")
|
||||
return
|
||||
}
|
||||
|
||||
svcErr := h.svc.SyncTemplates(c.Request.Context(), accountID, inboxID)
|
||||
if svcErr != nil {
|
||||
if errors.Is(svcErr, service.ErrInboxTemplateSyncWhatsAppOnly) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": service.InboxTemplateSyncWhatsAppOnlyMessage})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": service.InboxTemplateSyncInitiatedMessage})
|
||||
}
|
||||
|
||||
// RegisterWebhook registers a webhook URL with the channel provider for an inbox.
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/register_webhook
|
||||
// Reference: Chatwoot InboxesController#register_webhook
|
||||
func (h *InboxHandler) RegisterWebhook(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.RegisterWebhookRequest
|
||||
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to register webhook")
|
||||
return
|
||||
}
|
||||
|
||||
svcErr := h.svc.RegisterWebhook(c.Request.Context(), accountID, inboxID, req)
|
||||
if svcErr != nil {
|
||||
if errors.Is(svcErr, service.ErrInboxHealthWhatsAppCloudOnly) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.InboxHealthWhatsAppCloudOnlyMessage})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Webhook registered successfully"})
|
||||
}
|
||||
|
||||
// EnableWhatsAppCalling enables WhatsApp Calling for a Cloud API inbox.
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/enable_whatsapp_calling
|
||||
// Reference: Enterprise::Api::V1::Accounts::InboxesController#enable_whatsapp_calling
|
||||
func (h *InboxHandler) EnableWhatsAppCalling(c *gin.Context) {
|
||||
h.handleWhatsAppCallingToggle(c, true)
|
||||
}
|
||||
|
||||
// DisableWhatsAppCalling disables WhatsApp Calling for a Cloud API inbox.
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/disable_whatsapp_calling
|
||||
// Reference: Enterprise::Api::V1::Accounts::InboxesController#disable_whatsapp_calling
|
||||
func (h *InboxHandler) DisableWhatsAppCalling(c *gin.Context) {
|
||||
h.handleWhatsAppCallingToggle(c, false)
|
||||
}
|
||||
|
||||
func (h *InboxHandler) handleWhatsAppCallingToggle(c *gin.Context, enable bool) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update WhatsApp calling")
|
||||
return
|
||||
}
|
||||
|
||||
var svcErr error
|
||||
if enable {
|
||||
svcErr = h.svc.EnableWhatsAppCalling(c.Request.Context(), accountID, inboxID)
|
||||
} else {
|
||||
svcErr = h.svc.DisableWhatsAppCalling(c.Request.Context(), accountID, inboxID)
|
||||
}
|
||||
if svcErr != nil {
|
||||
if errors.Is(svcErr, service.ErrInboxWhatsAppCallingUnsupported) || errors.Is(svcErr, service.ErrInboxWhatsAppCallingFeatureRequired) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
if strings.Contains(strings.ToLower(svcErr.Error()), "not found") {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetAgentBot retrieves the currently active agent bot for an inbox.
|
||||
// GET /api/v1/accounts/:id/inboxes/:inbox_id/agent_bot
|
||||
// Reference: Chatwoot InboxesController#agent_bot
|
||||
func (h *InboxHandler) GetAgentBot(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to get agent bot")
|
||||
return
|
||||
}
|
||||
|
||||
agentBot, svcErr := h.svc.GetAgentBot(c.Request.Context(), accountID, inboxID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
if agentBot == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"agent_bot": gin.H{}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"agent_bot": serializeAgentBot(agentBot)})
|
||||
}
|
||||
|
||||
func serializeAgentBot(bot *model.AgentBot) gin.H {
|
||||
if bot == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
return gin.H{
|
||||
"id": bot.ID,
|
||||
"name": bot.Name,
|
||||
"description": bot.Description,
|
||||
"thumbnail": bot.AvatarURL,
|
||||
"outgoing_url": bot.OutgoingURL,
|
||||
"bot_type": bot.BotType,
|
||||
"bot_config": bot.Config,
|
||||
"account_id": bot.AccountID,
|
||||
"access_token": bot.AccessToken,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAvatar removes the avatar URL from an inbox.
|
||||
// DELETE /api/v1/accounts/:id/inboxes/:inbox_id/avatar
|
||||
// Reference: Chatwoot InboxesController#destroy_avatar
|
||||
func (h *InboxHandler) DeleteAvatar(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete inbox avatar")
|
||||
return
|
||||
}
|
||||
|
||||
_, svcErr := h.svc.DeleteAvatar(c.Request.Context(), accountID, inboxID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// ListCampaigns retrieves all campaigns for a specific inbox.
|
||||
// GET /api/v1/accounts/:id/inboxes/:inbox_id/campaigns
|
||||
// Reference: Chatwoot InboxesController#campaigns
|
||||
func (h *InboxHandler) ListCampaigns(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.svc.Ready() {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to list campaigns")
|
||||
return
|
||||
}
|
||||
|
||||
campaigns, svcErr := h.svc.ListCampaigns(c.Request.Context(), accountID, inboxID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"campaigns": campaigns})
|
||||
}
|
||||
|
||||
// ResetSecret regenerates the HMAC token for an API-type inbox.
|
||||
// POST /api/v1/accounts/:id/inboxes/:inbox_id/reset_secret
|
||||
// Reference: Chatwoot inboxes_controller#reset_secret — only works for API inboxes
|
||||
func (h *InboxHandler) ResetSecret(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
|
||||
inboxID, err := parseUintParam(c, "inbox_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
|
||||
inbox, svcErr := h.svc.ResetSecret(c.Request.Context(), accountID, inboxID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, serializeInbox(inbox, h.svc.DB(), inboxSerializationAdmin(c)))
|
||||
}
|
||||
|
||||
func inboxSerializationAdmin(c *gin.Context) bool {
|
||||
return getRole(c) == "administrator"
|
||||
}
|
||||
Reference in New Issue
Block a user