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,119 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CampaignListener listens for campaign_triggered events and builds conversations.
|
||||
// Reference: Chatwoot CampaignListener — handles campaign_triggered event → builds conversation from campaign.
|
||||
type CampaignListener struct {
|
||||
channel.BaseListener
|
||||
db *gorm.DB
|
||||
svc *CampaignService
|
||||
}
|
||||
|
||||
func NewCampaignListener(db *gorm.DB, svc *CampaignService) *CampaignListener {
|
||||
return &CampaignListener{db: db, svc: svc}
|
||||
}
|
||||
|
||||
func (l *CampaignListener) Name() string {
|
||||
return "campaign_listener"
|
||||
}
|
||||
|
||||
func (l *CampaignListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error {
|
||||
// Campaign listener responds to campaign_triggered custom events.
|
||||
// In Chatwoot, campaigns are triggered by the CampaignDispatcher which fires
|
||||
// a campaign_triggered event when a campaign's conditions are met.
|
||||
switch event.Type {
|
||||
case "campaign.triggered":
|
||||
return l.onCampaignTriggered(ctx, event)
|
||||
case channel.EventConversationResolved:
|
||||
// Mark one_off campaigns as completed when their conversation resolves.
|
||||
return l.onConversationResolved(ctx, event)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// onCampaignTriggered processes a campaign trigger event by executing the campaign.
|
||||
func (l *CampaignListener) onCampaignTriggered(ctx context.Context, event *channel.ChannelEvent) error {
|
||||
campaignIDFloat, ok := event.Data["campaign_id"]
|
||||
if !ok {
|
||||
return fmt.Errorf("campaign: campaign_id not found in event data")
|
||||
}
|
||||
|
||||
var campaignID uint
|
||||
switch v := campaignIDFloat.(type) {
|
||||
case uint:
|
||||
campaignID = v
|
||||
case int:
|
||||
campaignID = uint(v)
|
||||
case float64:
|
||||
campaignID = uint(v)
|
||||
default:
|
||||
return fmt.Errorf("campaign: unexpected campaign_id type: %T", campaignIDFloat)
|
||||
}
|
||||
|
||||
applogger.L().Info("campaign: processing campaign trigger",
|
||||
"campaign_id", campaignID,
|
||||
"account_id", event.AccountID)
|
||||
|
||||
if err := l.svc.TriggerCampaign(ctx, campaignID); err != nil {
|
||||
applogger.L().Error("campaign: failed to trigger campaign",
|
||||
"campaign_id", campaignID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// onConversationResolved checks if the resolved conversation belongs to a one_off campaign
|
||||
// and marks the campaign as completed.
|
||||
func (l *CampaignListener) onConversationResolved(ctx context.Context, event *channel.ChannelEvent) error {
|
||||
conv, err := l.ExtractConversation(event)
|
||||
if err != nil {
|
||||
return nil // not all events carry a conversation
|
||||
}
|
||||
|
||||
// Check if this conversation was created from a campaign
|
||||
if conv.ChannelType != "campaign" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Look up the campaign to determine if it's one_off and should be marked completed
|
||||
campaignIDFloat, ok := event.Data["campaign_id"]
|
||||
if !ok {
|
||||
return nil // no campaign linked
|
||||
}
|
||||
|
||||
var campaignID uint
|
||||
switch v := campaignIDFloat.(type) {
|
||||
case uint:
|
||||
campaignID = v
|
||||
case int:
|
||||
campaignID = uint(v)
|
||||
case float64:
|
||||
campaignID = uint(v)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
campaign, err := l.svc.GetByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only one_off campaigns are automatically completed when resolved
|
||||
if campaign.CampaignType == CampaignTypeOneOff {
|
||||
applogger.L().Info("campaign: marking one_off campaign as completed",
|
||||
"campaign_id", campaignID)
|
||||
return l.svc.MarkCompleted(ctx, campaignID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
// CampaignStatus represents the lifecycle state of a campaign.
|
||||
type CampaignStatus string
|
||||
|
||||
const (
|
||||
CampaignStatusActive CampaignStatus = "active"
|
||||
CampaignStatusCompleted CampaignStatus = "completed"
|
||||
CampaignStatusProcessing CampaignStatus = "processing"
|
||||
)
|
||||
|
||||
// CampaignType represents the type of campaign.
|
||||
type CampaignType string
|
||||
|
||||
const (
|
||||
CampaignTypeOngoing CampaignType = "ongoing"
|
||||
CampaignTypeOneOff CampaignType = "one_off"
|
||||
)
|
||||
|
||||
// Campaign represents a proactive outreach campaign.
|
||||
// Reference: Chatwoot Campaign model — account_id, inbox_id, sender_id, display_id,
|
||||
// title, message, description, campaign_status, campaign_type, audience (jsonb),
|
||||
// trigger_rules (jsonb), template_params (jsonb), scheduled_at, enabled,
|
||||
// trigger_only_during_business_hours.
|
||||
type Campaign struct {
|
||||
model.Base
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"`
|
||||
InboxID uint `gorm:"index;not null" json:"inbox_id"`
|
||||
SenderID *uint `gorm:"index" json:"sender_id,omitempty"`
|
||||
DisplayID uint `gorm:"uniqueIndex:idx_campaign_display;not null" json:"display_id"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
Message string `gorm:"type:text;not null" json:"message"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CampaignStatus CampaignStatus `gorm:"size:50;index;default:active" json:"campaign_status"`
|
||||
CampaignType CampaignType `gorm:"size:50;not null" json:"campaign_type"`
|
||||
Audience string `gorm:"type:jsonb;default:'{}'" json:"audience"`
|
||||
TriggerRules string `gorm:"type:jsonb;default:'{}'" json:"trigger_rules"`
|
||||
TemplateParams string `gorm:"type:jsonb;default:'{}'" json:"template_params"`
|
||||
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
TriggerOnlyDuringBusinessHours bool `gorm:"default:false" json:"trigger_only_during_business_hours"`
|
||||
|
||||
Inbox model.Inbox `gorm:"foreignKey:InboxID" json:"-"`
|
||||
Sender *model.User `gorm:"foreignKey:SenderID" json:"-"`
|
||||
}
|
||||
|
||||
func (Campaign) TableName() string { return "campaigns" }
|
||||
@@ -0,0 +1,231 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CampaignService provides business logic for campaign operations.
|
||||
// Reference: Chatwoot CampaignService — CRUD + campaign triggering.
|
||||
type CampaignService struct {
|
||||
db *gorm.DB
|
||||
dispatcher *channel.Dispatcher
|
||||
}
|
||||
|
||||
func NewCampaignService(db *gorm.DB, dispatchers ...*channel.Dispatcher) *CampaignService {
|
||||
var dispatcher *channel.Dispatcher
|
||||
if len(dispatchers) > 0 {
|
||||
dispatcher = dispatchers[0]
|
||||
}
|
||||
return &CampaignService{db: db, dispatcher: dispatcher}
|
||||
}
|
||||
|
||||
// Create creates a new campaign.
|
||||
func (s *CampaignService) Create(ctx context.Context, campaign *Campaign) error {
|
||||
if campaign.DisplayID == 0 {
|
||||
var next uint
|
||||
if err := s.db.WithContext(ctx).Model(&Campaign{}).
|
||||
Select("COALESCE(MAX(display_id), 0) + 1").Scan(&next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
campaign.DisplayID = next
|
||||
}
|
||||
return s.db.WithContext(ctx).Create(campaign).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a campaign by ID.
|
||||
func (s *CampaignService) GetByID(ctx context.Context, id uint) (*Campaign, error) {
|
||||
var c Campaign
|
||||
if err := s.db.WithContext(ctx).First(&c, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListByAccount returns campaigns for an account with pagination.
|
||||
func (s *CampaignService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]Campaign, int64, error) {
|
||||
var campaigns []Campaign
|
||||
var count int64
|
||||
db := s.db.WithContext(ctx).Model(&Campaign{}).Where("account_id = ?", accountID)
|
||||
db.Count(&count)
|
||||
if err := db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&campaigns).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return campaigns, count, nil
|
||||
}
|
||||
|
||||
// ListByInbox returns campaigns for a specific inbox.
|
||||
func (s *CampaignService) ListByInbox(ctx context.Context, accountID, inboxID uint) ([]Campaign, error) {
|
||||
var campaigns []Campaign
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("account_id = ? AND inbox_id = ?", accountID, inboxID).
|
||||
Order("created_at DESC").Find(&campaigns).Error
|
||||
return campaigns, err
|
||||
}
|
||||
|
||||
// Update updates campaign fields.
|
||||
func (s *CampaignService) Update(ctx context.Context, id uint, updates map[string]interface{}) error {
|
||||
return s.db.WithContext(ctx).Model(&Campaign{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes a campaign.
|
||||
func (s *CampaignService) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&Campaign{}, id).Error
|
||||
}
|
||||
|
||||
// MarkCompleted transitions a campaign to completed status.
|
||||
func (s *CampaignService) MarkCompleted(ctx context.Context, id uint) error {
|
||||
return s.Update(ctx, id, map[string]interface{}{
|
||||
"campaign_status": CampaignStatusCompleted,
|
||||
})
|
||||
}
|
||||
|
||||
// TriggerCampaign executes a campaign by creating conversations for the target audience.
|
||||
// Reference: Chatwoot CampaignService.trigger — builds conversation from campaign.
|
||||
func (s *CampaignService) TriggerCampaign(ctx context.Context, campaignID uint) error {
|
||||
campaign, err := s.GetByID(ctx, campaignID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("campaign: get campaign: %w", err)
|
||||
}
|
||||
|
||||
if !campaign.Enabled {
|
||||
applogger.L().Info("campaign: campaign is disabled, skipping trigger", "campaign_id", campaignID)
|
||||
return nil
|
||||
}
|
||||
|
||||
builder := NewCampaignConversationBuilder(s.db, s.dispatcher)
|
||||
return builder.Build(ctx, campaign)
|
||||
}
|
||||
|
||||
// CampaignConversationBuilder builds a conversation from a campaign trigger.
|
||||
// Reference: Chatwoot CampaignListener — on campaign_triggered event -> builds conversation.
|
||||
type CampaignConversationBuilder struct {
|
||||
db *gorm.DB
|
||||
dispatcher *channel.Dispatcher
|
||||
}
|
||||
|
||||
func NewCampaignConversationBuilder(db *gorm.DB, dispatchers ...*channel.Dispatcher) *CampaignConversationBuilder {
|
||||
var dispatcher *channel.Dispatcher
|
||||
if len(dispatchers) > 0 {
|
||||
dispatcher = dispatchers[0]
|
||||
}
|
||||
return &CampaignConversationBuilder{db: db, dispatcher: dispatcher}
|
||||
}
|
||||
|
||||
// Build creates a new conversation from the campaign for each contact in the audience.
|
||||
// The audience field (JSONB) contains contact IDs or filter criteria.
|
||||
func (b *CampaignConversationBuilder) Build(ctx context.Context, campaign *Campaign) error {
|
||||
contactIDs, err := campaignAudienceContactIDs(campaign.Audience)
|
||||
if err != nil {
|
||||
return fmt.Errorf("campaign: parse audience: %w", err)
|
||||
}
|
||||
inbox := b.loadInbox(ctx, campaign)
|
||||
|
||||
for _, contactID := range contactIDs {
|
||||
conv := &model.Conversation{
|
||||
AccountID: campaign.AccountID,
|
||||
InboxID: campaign.InboxID,
|
||||
ContactID: contactID,
|
||||
CampaignID: &campaign.ID,
|
||||
Status: "open",
|
||||
ChannelType: "campaign",
|
||||
}
|
||||
|
||||
if campaign.SenderID != nil {
|
||||
conv.AssigneeID = campaign.SenderID
|
||||
}
|
||||
|
||||
if err := b.db.WithContext(ctx).Create(conv).Error; err != nil {
|
||||
applogger.L().Error("campaign: failed to create conversation for contact",
|
||||
"campaign_id", campaign.ID, "contact_id", contactID, "error", err)
|
||||
continue
|
||||
}
|
||||
b.dispatch(ctx, channel.EventConversationCreated, campaign, inbox, conv, nil)
|
||||
b.dispatch(ctx, channel.EventConversationOpened, campaign, inbox, conv, nil)
|
||||
|
||||
// Create the initial campaign message in the conversation
|
||||
msg := &model.Message{
|
||||
ConversationID: conv.ID,
|
||||
AccountID: campaign.AccountID,
|
||||
InboxID: campaign.InboxID,
|
||||
Content: campaign.Message,
|
||||
ContentType: "template",
|
||||
MessageType: "outgoing",
|
||||
SenderType: "agent",
|
||||
}
|
||||
if campaign.SenderID != nil {
|
||||
msg.SenderID = campaign.SenderID
|
||||
}
|
||||
|
||||
if err := b.db.WithContext(ctx).Create(msg).Error; err != nil {
|
||||
applogger.L().Error("campaign: failed to create campaign message",
|
||||
"campaign_id", campaign.ID, "conversation_id", conv.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
b.dispatch(ctx, channel.EventMessageCreated, campaign, inbox, conv, msg)
|
||||
b.dispatch(ctx, channel.EventMessageOutgoing, campaign, inbox, conv, msg)
|
||||
|
||||
applogger.L().Info("campaign: created conversation from campaign",
|
||||
"campaign_id", campaign.ID, "conversation_id", conv.ID, "contact_id", contactID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *CampaignConversationBuilder) loadInbox(ctx context.Context, c *Campaign) *model.Inbox {
|
||||
if c.Inbox.ID != 0 {
|
||||
return &c.Inbox
|
||||
}
|
||||
var inbox model.Inbox
|
||||
if err := b.db.WithContext(ctx).Where("account_id = ? AND id = ?", c.AccountID, c.InboxID).First(&inbox).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &inbox
|
||||
}
|
||||
|
||||
func (b *CampaignConversationBuilder) dispatch(ctx context.Context, eventType channel.EventType, campaign *Campaign, inbox *model.Inbox, conversation *model.Conversation, message *model.Message) {
|
||||
if b.dispatcher == nil || campaign == nil || conversation == nil {
|
||||
return
|
||||
}
|
||||
channelType := channel.ChannelAPI
|
||||
inboxID := campaign.InboxID
|
||||
if inbox != nil {
|
||||
channelType = channel.ChannelType(inbox.ChannelType)
|
||||
inboxID = inbox.ID
|
||||
}
|
||||
event := channel.NewChannelEvent(eventType, channelType, campaign.AccountID, inboxID)
|
||||
event.ConversationID = conversation.ID
|
||||
event.ContactID = conversation.ContactID
|
||||
event.Data["campaign_id"] = campaign.ID
|
||||
event.Data["conversation"] = conversation
|
||||
if inbox != nil {
|
||||
event.Data["inbox"] = inbox
|
||||
}
|
||||
if message != nil {
|
||||
event.Data["message"] = message
|
||||
}
|
||||
if err := b.dispatcher.DispatchAsync(ctx, event); err != nil {
|
||||
applogger.L().Warn("campaign: failed to dispatch campaign event", "campaign_id", campaign.ID, "event", eventType, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func campaignAudienceContactIDs(raw string) ([]uint, error) {
|
||||
var audienceData struct {
|
||||
ContactIDs []uint `json:"contact_ids"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &audienceData); err == nil {
|
||||
return audienceData.ContactIDs, nil
|
||||
}
|
||||
|
||||
var frontendRules []map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &frontendRules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []uint{}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user