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,222 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
// BulkActionHandler handles generic bulk actions for Conversations and Contacts.
|
||||
// Reference: Chatwoot app/controllers/api/v1/accounts/bulk_actions_controller.rb
|
||||
type BulkActionHandler struct {
|
||||
conversationSvc *service.ConversationService
|
||||
contactSvc *service.ContactService
|
||||
worker *worker.WorkerPool
|
||||
}
|
||||
|
||||
// NewBulkActionHandler creates a new BulkActionHandler.
|
||||
func NewBulkActionHandler(conversationSvc *service.ConversationService, contactSvc *service.ContactService) *BulkActionHandler {
|
||||
return &BulkActionHandler{conversationSvc: conversationSvc, contactSvc: contactSvc}
|
||||
}
|
||||
|
||||
func (h *BulkActionHandler) WithWorkerPool(wp *worker.WorkerPool) *BulkActionHandler {
|
||||
h.worker = wp
|
||||
return h
|
||||
}
|
||||
|
||||
// BulkActionRequest is the DTO for generic bulk actions.
|
||||
// Reference: Chatwoot bulk_actions_controller#create — params[:type], params[:action_name], params[:ids]
|
||||
type BulkActionRequest struct {
|
||||
Type string `json:"type"` // "Conversation" or "Contact"
|
||||
ActionName string `json:"action_name,omitempty"` // legacy local action names
|
||||
IDs []uint `json:"ids"` // Chatwoot sends conversation display IDs
|
||||
Fields service.ConversationBulkActionFields `json:"fields,omitempty"`
|
||||
Labels service.ConversationBulkActionLabels `json:"labels,omitempty"`
|
||||
AssigneeID *uint `json:"assignee_id,omitempty"` // legacy local assign field
|
||||
TeamID *uint `json:"team_id,omitempty"` // legacy local team field
|
||||
SnoozedUntil string `json:"snoozed_until,omitempty"` // for conversation snooze
|
||||
}
|
||||
|
||||
// Create processes a bulk action request.
|
||||
// POST /api/v1/accounts/:account_id/bulk_actions
|
||||
// Reference: Chatwoot bulk_actions_controller#create — returns 200 OK on success
|
||||
func (h *BulkActionHandler) Create(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 BulkActionRequest
|
||||
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, bindErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.Type = normalizeBulkActionType(req.Type)
|
||||
switch req.Type {
|
||||
case "Conversation":
|
||||
h.handleConversationBulk(c, accountID, req)
|
||||
case "Contact":
|
||||
h.handleContactBulk(c, accountID, req)
|
||||
default:
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
|
||||
}
|
||||
}
|
||||
|
||||
// handleConversationBulk processes bulk actions on conversations.
|
||||
func (h *BulkActionHandler) handleConversationBulk(c *gin.Context, accountID uint, req BulkActionRequest) {
|
||||
if h.worker != nil {
|
||||
params := service.ConversationBulkActionParams{
|
||||
Type: req.Type,
|
||||
ActionName: req.ActionName,
|
||||
IDs: req.IDs,
|
||||
Fields: req.Fields,
|
||||
Labels: req.Labels,
|
||||
SnoozedUntil: req.SnoozedUntil,
|
||||
}
|
||||
if req.AssigneeID != nil && params.Fields.AssigneeID == nil {
|
||||
params.Fields.AssigneeID = req.AssigneeID
|
||||
}
|
||||
if req.TeamID != nil && params.Fields.TeamID == nil {
|
||||
params.Fields.TeamID = req.TeamID
|
||||
}
|
||||
if _, err := service.EnqueueConversationBulkAction(c.Request.Context(), h.worker, accountID, getUserID(c), params); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to enqueue bulk action")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
for _, convID := range req.IDs {
|
||||
var svcErr error
|
||||
|
||||
switch req.ActionName {
|
||||
case "resolve":
|
||||
_, svcErr = h.conversationSvc.ToggleStatus(c.Request.Context(), accountID, convID, service.ToggleStatusRequest{Status: string(model.ConversationStatusResolved)})
|
||||
case "open":
|
||||
_, svcErr = h.conversationSvc.ToggleStatus(c.Request.Context(), accountID, convID, service.ToggleStatusRequest{Status: string(model.ConversationStatusOpen)})
|
||||
case "snooze":
|
||||
_, svcErr = h.conversationSvc.ToggleStatus(c.Request.Context(), accountID, convID, service.ToggleStatusRequest{Status: string(model.ConversationStatusSnoozed)})
|
||||
case "assign":
|
||||
if req.AssigneeID != nil {
|
||||
_, svcErr = h.conversationSvc.AssignAgent(c.Request.Context(), accountID, convID, *req.AssigneeID)
|
||||
}
|
||||
case "unassign":
|
||||
_, svcErr = h.conversationSvc.UnassignAgent(c.Request.Context(), accountID, convID)
|
||||
case "assign_team":
|
||||
_, svcErr = h.conversationSvc.AssignTeam(c.Request.Context(), accountID, convID, req.AssigneeID, req.TeamID)
|
||||
case "delete":
|
||||
svcErr = h.conversationSvc.Delete(c.Request.Context(), accountID, convID)
|
||||
case "label_add":
|
||||
if len(req.Labels.Add) > 0 {
|
||||
_, svcErr = h.conversationSvc.UpdateLabels(c.Request.Context(), accountID, convID, req.Labels.Add)
|
||||
}
|
||||
default:
|
||||
failCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if svcErr != nil {
|
||||
failCount++
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"success_count": successCount,
|
||||
"fail_count": failCount,
|
||||
})
|
||||
}
|
||||
|
||||
// handleContactBulk processes bulk actions on contacts.
|
||||
// Reference: Chatwoot only supports "delete" and label operations for contacts in bulk_actions.
|
||||
func (h *BulkActionHandler) handleContactBulk(c *gin.Context, accountID uint, req BulkActionRequest) {
|
||||
if h.worker != nil {
|
||||
params := service.ContactBulkActionParams{
|
||||
Type: req.Type,
|
||||
ActionName: req.ActionName,
|
||||
IDs: req.IDs,
|
||||
Labels: req.Labels,
|
||||
}
|
||||
if _, err := service.EnqueueContactBulkAction(c.Request.Context(), h.worker, accountID, getUserID(c), params); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to enqueue bulk action")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.performContactBulkSync(c, accountID, req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to process bulk action")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *BulkActionHandler) performContactBulkSync(c *gin.Context, accountID uint, req BulkActionRequest) error {
|
||||
if h.contactSvc == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case req.ActionName == "delete":
|
||||
for _, contactID := range req.IDs {
|
||||
if err := h.contactSvc.Delete(c.Request.Context(), accountID, contactID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case len(req.Labels.Add) > 0:
|
||||
for _, contactID := range req.IDs {
|
||||
current, err := h.contactSvc.GetLabels(c.Request.Context(), accountID, contactID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := h.contactSvc.UpdateLabels(c.Request.Context(), accountID, contactID, append(current, req.Labels.Add...)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case len(req.Labels.Remove) > 0:
|
||||
remove := map[string]struct{}{}
|
||||
for _, label := range req.Labels.Remove {
|
||||
remove[strings.TrimSpace(label)] = struct{}{}
|
||||
}
|
||||
for _, contactID := range req.IDs {
|
||||
current, err := h.contactSvc.GetLabels(c.Request.Context(), accountID, contactID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept := current[:0]
|
||||
for _, label := range current {
|
||||
if _, ok := remove[label]; !ok {
|
||||
kept = append(kept, label)
|
||||
}
|
||||
}
|
||||
if _, err := h.contactSvc.UpdateLabels(c.Request.Context(), accountID, contactID, kept); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeBulkActionType(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "conversation":
|
||||
return "Conversation"
|
||||
case "contact":
|
||||
return "Contact"
|
||||
default:
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user