diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 272ecf52..8f70cf01 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -486,6 +486,8 @@ func Bootstrap(env string) (*App, error) { inboxMemberService := service.NewInboxMemberService(inboxMemberRepo) accountUserRepo := repository.NewAccountUserRepo(db) conversationService := service.NewConversationService(conversationRepo, messageRepo, channelDispatcher, inboxMemberService, accountUserRepo, teamRepo, teamMemberRepo) + appliedSlaService := service.NewAppliedSlaService(appliedSlaRepo, slaEventRepo, slaPolicyRepo, conversationRepo) + conversationService.SetAppliedSlaService(appliedSlaService) conversationParticipantService := service.NewConversationParticipantService(conversationParticipantRepo, conversationRepo) draftMessageService := service.NewDraftMessageService(draftMessageRepo, conversationRepo) inboxService := service.NewInboxService(inboxRepo, agentBotInboxRepo, agentBotRepo, campaignRepo, webhookSubRepo, waService, waRepo) diff --git a/internal/handler/api/v1/conversation_handler_crud_test.go b/internal/handler/api/v1/conversation_handler_crud_test.go index 556ebb72..c37c21f0 100644 --- a/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/internal/handler/api/v1/conversation_handler_crud_test.go @@ -82,6 +82,9 @@ func (s *ConversationCrudTestSuite) SetupSuite() { &model.AccountUser{}, &model.Team{}, &model.TeamMember{}, + &model.SlaPolicy{}, + &model.AppliedSLA{}, + &model.SlaEvent{}, ) s.Require().NoError(err) @@ -95,6 +98,8 @@ func (s *ConversationCrudTestSuite) SetupSuite() { teamRepo := repository.NewTeamRepo(db) teamMemberRepo := repository.NewTeamMemberRepo(db) conversationSvc := service.NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo) + appliedSlaSvc := service.NewAppliedSlaService(repository.NewAppliedSlaRepo(db), repository.NewSlaEventRepo(db), repository.NewSlaPolicyRepo(db), convRepo) + conversationSvc.SetAppliedSlaService(appliedSlaSvc) mockLLM := &mockConvCrudLLMProvider{} messageSvc := service.NewMessageService(msgRepo, dispatcher, mockLLM) handler := NewConversationHandler(conversationSvc, messageSvc) @@ -163,6 +168,8 @@ func (s *ConversationCrudTestSuite) SetupTest() { } func (s *ConversationCrudTestSuite) TearDownTest() { + s.db.Exec("DELETE FROM sla_events") + s.db.Exec("DELETE FROM applied_slas") s.db.Exec("DELETE FROM conversation_labels") s.db.Exec("DELETE FROM conversations") s.db.Exec("DELETE FROM contact_inboxes") @@ -174,6 +181,7 @@ func (s *ConversationCrudTestSuite) TearDownTest() { s.db.Exec("DELETE FROM accounts") s.db.Exec("DELETE FROM teams") s.db.Exec("DELETE FROM team_members") + s.db.Exec("DELETE FROM sla_policies") } func (s *ConversationCrudTestSuite) accountURL() string { @@ -352,6 +360,44 @@ func (s *ConversationCrudTestSuite) TestUpdate_Success() { assert.Equal(s.T(), "resolved", resp["status"]) } +func (s *ConversationCrudTestSuite) TestUpdate_WithSlaPolicyCreatesAppliedSlaPayload() { + policy := &model.SlaPolicy{ + AccountID: s.testAccount.ID, + Name: "Gold SLA", + Description: "Priority customers", + FirstResponseTimeThreshold: 15, + NextResponseTimeThreshold: 30, + ResolutionTimeThreshold: 120, + } + s.Require().NoError(s.db.Create(policy).Error) + + body, _ := json.Marshal(map[string]interface{}{ + "sla_policy_id": policy.ID, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", s.convURL(s.testConv.ID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(s.T(), err) + assert.Equal(s.T(), float64(policy.ID), resp["sla_policy_id"]) + + applied, ok := resp["applied_sla"].(map[string]interface{}) + s.Require().True(ok) + assert.Equal(s.T(), float64(policy.ID), applied["sla_id"]) + assert.Equal(s.T(), "active", applied["sla_status"]) + assert.Equal(s.T(), "Gold SLA", applied["sla_name"]) + assert.Equal(s.T(), float64(15), applied["sla_first_response_time_threshold"]) + + var count int64 + s.Require().NoError(s.db.Model(&model.AppliedSLA{}).Where("conversation_id = ?", s.testConv.ID).Count(&count).Error) + assert.Equal(s.T(), int64(1), count) +} + func (s *ConversationCrudTestSuite) TestUpdate_InvalidAccountID() { body, _ := json.Marshal(map[string]interface{}{ "status": "resolved", diff --git a/internal/handler/api/v1/conversation_serializer.go b/internal/handler/api/v1/conversation_serializer.go index c9691081..932306b5 100644 --- a/internal/handler/api/v1/conversation_serializer.go +++ b/internal/handler/api/v1/conversation_serializer.go @@ -55,6 +55,7 @@ type chatwootConversationPayload struct { Priority string `json:"priority"` WaitingSince int64 `json:"waiting_since"` SlaPolicyID *uint `json:"sla_policy_id"` + AppliedSLA map[string]any `json:"applied_sla,omitempty"` } type chatwootConversationMeta struct { @@ -135,7 +136,7 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model } } - return chatwootConversationPayload{ + payload := chatwootConversationPayload{ Meta: serializeConversationMeta(ctx, db, conversation), ID: conversationDisplayID(conversation), Messages: messages, @@ -163,6 +164,40 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model WaitingSince: int64Value(conversation.WaitingSince), SlaPolicyID: conversation.SlaPolicyID, } + + if appliedSLA := serializeAppliedSlaForConversation(ctx, db, conversation.ID); appliedSLA != nil { + payload.AppliedSLA = appliedSLA + } + + return payload +} + +func serializeAppliedSlaForConversation(ctx context.Context, db *gorm.DB, conversationID uint) map[string]any { + if db == nil || conversationID == 0 { + return nil + } + + var applied model.AppliedSLA + if err := db.WithContext(ctx). + Preload("SlaPolicy"). + Where("conversation_id = ?", conversationID). + First(&applied).Error; err != nil { + return nil + } + + return map[string]any{ + "id": applied.ID, + "sla_id": applied.SlaPolicyID, + "sla_status": applied.SLAStatus, + "created_at": applied.CreatedAt.Unix(), + "updated_at": applied.UpdatedAt.Unix(), + "sla_description": applied.SlaPolicy.Description, + "sla_name": applied.SlaPolicy.Name, + "sla_first_response_time_threshold": applied.SlaPolicy.FirstResponseTimeThreshold, + "sla_next_response_time_threshold": applied.SlaPolicy.NextResponseTimeThreshold, + "sla_only_during_business_hours": applied.SlaPolicy.OnlyDuringBusinessHours, + "sla_resolution_time_threshold": applied.SlaPolicy.ResolutionTimeThreshold, + } } func serializeConversationMeta(ctx context.Context, db *gorm.DB, conversation *model.Conversation) chatwootConversationMeta { diff --git a/internal/service/applied_sla_service.go b/internal/service/applied_sla_service.go index fa975964..60f925c9 100644 --- a/internal/service/applied_sla_service.go +++ b/internal/service/applied_sla_service.go @@ -34,6 +34,17 @@ func NewAppliedSlaService( } } +func (s *AppliedSlaService) ValidateSlaPolicy(ctx context.Context, accountID, slaPolicyID uint) error { + policy, err := s.slaPolicyRepo.FindByID(ctx, slaPolicyID) + if err != nil { + return fmt.Errorf("sla policy not found: %w", err) + } + if policy.AccountID != accountID { + return fmt.Errorf("sla policy %d does not belong to account %d", slaPolicyID, accountID) + } + return nil +} + // CreateFromConversation creates an AppliedSLA record when a conversation's // sla_policy_id is set or changed. // Reference: Chatwoot enterprise/app/models/enterprise/concerns/conversation.rb @@ -66,6 +77,19 @@ func (s *AppliedSlaService) CreateFromConversation(ctx context.Context, accountI if err != nil { return nil, fmt.Errorf("conversation not found: %w", err) } + if conversation.AccountID != accountID { + return nil, fmt.Errorf("conversation %d does not belong to account %d", conversationID, accountID) + } + if conversation.SlaPolicyID != nil && *conversation.SlaPolicyID != slaPolicyID { + return nil, fmt.Errorf("conversation %d already has SLA policy %d, cannot change to %d", + conversationID, *conversation.SlaPolicyID, slaPolicyID) + } + if conversation.SlaPolicyID == nil { + conversation.SlaPolicyID = &slaPolicyID + if err := s.conversationRepo.Update(ctx, conversation); err != nil { + return nil, fmt.Errorf("set conversation sla policy: %w", err) + } + } // Compute target timestamps from policy thresholds + conversation created_at // Chatwoot: threshold is in minutes @@ -335,4 +359,4 @@ func (s *AppliedSlaService) RemoveAppliedSla(ctx context.Context, conversationID applogger.L().Infof("AppliedSLA removed for conversation %d", conversationID) return nil -} \ No newline at end of file +} diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index e783328c..15e7b601 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -29,6 +29,7 @@ type ConversationService struct { teamRepo *repository.TeamRepo teamMemberRepo *repository.TeamMemberRepo searchIndexer SearchIndexer + appliedSlaSvc *AppliedSlaService } // NewConversationService creates a new Conversation service. @@ -40,6 +41,10 @@ func (s *ConversationService) SetSearchIndexer(indexer SearchIndexer) { s.searchIndexer = indexer } +func (s *ConversationService) SetAppliedSlaService(appliedSlaSvc *AppliedSlaService) { + s.appliedSlaSvc = appliedSlaSvc +} + func (s *ConversationService) DB() *gorm.DB { if s == nil || s.repo == nil { return nil @@ -132,10 +137,11 @@ func (s *ConversationService) GetByAccountAndDisplayIDOrID(ctx context.Context, // Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #create // Supports creating conversation with an initial message (like Chatwoot's ConversationBuilder). type CreateConversationRequest struct { - InboxID uint `json:"inbox_id" validate:"required"` - ContactID uint `json:"contact_id" validate:"required"` - Status string `json:"status,omitempty" validate:"omitempty,oneof=open resolved pending snoozed"` - Priority string `json:"priority,omitempty" validate:"omitempty,oneof=urgent high medium low"` + InboxID uint `json:"inbox_id" validate:"required"` + ContactID uint `json:"contact_id" validate:"required"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=open resolved pending snoozed"` + Priority string `json:"priority,omitempty" validate:"omitempty,oneof=urgent high medium low"` + SlaPolicyID *uint `json:"sla_policy_id,omitempty"` // Initial message fields (Chatwoot: conversation + message created together) MessageContent string `json:"message_content,omitempty"` MessageType string `json:"message_type,omitempty" validate:"omitempty,oneof=outgoing incoming"` @@ -155,13 +161,17 @@ func (s *ConversationService) Create(ctx context.Context, accountID uint, req Cr if req.Priority != "" { priority = model.ConversationPriority(req.Priority) } + if err := s.validateSlaPolicy(ctx, accountID, req.SlaPolicyID); err != nil { + return nil, err + } conversation := &model.Conversation{ - AccountID: accountID, - InboxID: req.InboxID, - ContactID: req.ContactID, - Status: string(status), - Priority: string(priority), + AccountID: accountID, + InboxID: req.InboxID, + ContactID: req.ContactID, + Status: string(status), + Priority: string(priority), + SlaPolicyID: req.SlaPolicyID, } if err := s.repo.Create(ctx, conversation); err != nil { @@ -169,6 +179,10 @@ func (s *ConversationService) Create(ctx context.Context, accountID uint, req Cr return nil, err } + if err := s.ensureAppliedSla(ctx, accountID, conversation); err != nil { + return nil, err + } + // Dispatch EventConversationCreated s.dispatchConversationEvent(ctx, channel.EventConversationCreated, conversation) s.indexConversation(ctx, conversation) @@ -208,8 +222,9 @@ func (s *ConversationService) Create(ctx context.Context, accountID uint, req Cr // UpdateConversationRequest is the DTO for updating a conversation. type UpdateConversationRequest struct { - Status string `json:"status,omitempty" validate:"omitempty,oneof=open resolved pending snoozed"` - Priority string `json:"priority,omitempty" validate:"omitempty,oneof=urgent high medium low"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=open resolved pending snoozed"` + Priority string `json:"priority,omitempty" validate:"omitempty,oneof=urgent high medium low"` + SlaPolicyID *uint `json:"sla_policy_id,omitempty"` } // Update modifies an existing conversation. @@ -231,11 +246,27 @@ func (s *ConversationService) Update(ctx context.Context, accountID, id uint, re if req.Priority != "" { conversation.Priority = req.Priority } + if req.SlaPolicyID != nil { + if *req.SlaPolicyID == 0 { + return nil, errors.New("sla policy cannot be removed from conversation") + } + if conversation.SlaPolicyID != nil && *conversation.SlaPolicyID != *req.SlaPolicyID { + return nil, errors.New("conversation already has a different sla") + } + if err := s.validateSlaPolicy(ctx, accountID, req.SlaPolicyID); err != nil { + return nil, err + } + conversation.SlaPolicyID = req.SlaPolicyID + } if err := s.repo.Update(ctx, conversation); err != nil { return nil, err } + if err := s.ensureAppliedSla(ctx, accountID, conversation); err != nil { + return nil, err + } + // Dispatch EventConversationUpdated s.dispatchConversationEvent(ctx, channel.EventConversationUpdated, conversation) s.indexConversation(ctx, conversation) @@ -254,6 +285,21 @@ func (s *ConversationService) Update(ctx context.Context, accountID, id uint, re return conversation, nil } +func (s *ConversationService) ensureAppliedSla(ctx context.Context, accountID uint, conversation *model.Conversation) error { + if s.appliedSlaSvc == nil || conversation == nil || conversation.SlaPolicyID == nil || *conversation.SlaPolicyID == 0 { + return nil + } + _, err := s.appliedSlaSvc.CreateFromConversation(ctx, accountID, conversation.ID, *conversation.SlaPolicyID) + return err +} + +func (s *ConversationService) validateSlaPolicy(ctx context.Context, accountID uint, slaPolicyID *uint) error { + if s.appliedSlaSvc == nil || slaPolicyID == nil || *slaPolicyID == 0 { + return nil + } + return s.appliedSlaSvc.ValidateSlaPolicy(ctx, accountID, *slaPolicyID) +} + // AssignAgentRequest is the DTO for assigning an agent to a conversation. type AssignAgentRequest struct { AssigneeID uint `json:"assignee_id" validate:"required"` diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index f82a2ae9..49cde18e 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -6,10 +6,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" - "gorm.io/datatypes" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" @@ -39,6 +39,9 @@ func setupConversationServiceTestDB(t *testing.T) *gorm.DB { &model.ConversationLabel{}, &model.Team{}, &model.Tag{}, + &model.SlaPolicy{}, + &model.AppliedSLA{}, + &model.SlaEvent{}, ), "failed to auto-migrate") t.Cleanup(func() { @@ -96,9 +99,93 @@ func setupConversationService(t *testing.T) (*ConversationService, *gorm.DB) { teamRepo := repository.NewTeamRepo(db) teamMemberRepo := repository.NewTeamMemberRepo(db) svc := NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo) + appliedSlaSvc := NewAppliedSlaService(repository.NewAppliedSlaRepo(db), repository.NewSlaEventRepo(db), repository.NewSlaPolicyRepo(db), convRepo) + svc.SetAppliedSlaService(appliedSlaSvc) return svc, db } +func createConversationServiceTestSlaPolicy(t *testing.T, db *gorm.DB, accountID uint) *model.SlaPolicy { + t.Helper() + policy := &model.SlaPolicy{ + AccountID: accountID, + Name: "Gold SLA", + FirstResponseTimeThreshold: 10, + NextResponseTimeThreshold: 20, + ResolutionTimeThreshold: 60, + } + require.NoError(t, db.Create(policy).Error) + return policy +} + +func TestConversationService_Create_AppliesSlaPolicy(t *testing.T) { + svc, db := setupConversationService(t) + + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + policy := createConversationServiceTestSlaPolicy(t, db, account.ID) + + conversation, err := svc.Create(context.Background(), account.ID, CreateConversationRequest{ + InboxID: inbox.ID, + ContactID: contact.ID, + SlaPolicyID: &policy.ID, + }) + require.NoError(t, err) + require.NotNil(t, conversation.SlaPolicyID) + assert.Equal(t, policy.ID, *conversation.SlaPolicyID) + + var applied model.AppliedSLA + require.NoError(t, db.Where("conversation_id = ?", conversation.ID).First(&applied).Error) + assert.Equal(t, account.ID, applied.AccountID) + assert.Equal(t, policy.ID, applied.SlaPolicyID) + assert.Equal(t, model.SLAStatusActive, applied.SLAStatus) + require.NotNil(t, applied.FRTTargetAt) + require.NotNil(t, applied.NRTTargetAt) + require.NotNil(t, applied.RTTargetAt) + assert.Equal(t, conversation.CreatedAt.Add(10*60*1e9).Unix(), applied.FRTTargetAt.Unix()) +} + +func TestConversationService_Update_AppliesSlaPolicyOnce(t *testing.T) { + svc, db := setupConversationService(t) + + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + policy := createConversationServiceTestSlaPolicy(t, db, account.ID) + conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + + updated, err := svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{SlaPolicyID: &policy.ID}) + require.NoError(t, err) + require.NotNil(t, updated.SlaPolicyID) + assert.Equal(t, policy.ID, *updated.SlaPolicyID) + + updated, err = svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{SlaPolicyID: &policy.ID}) + require.NoError(t, err) + assert.Equal(t, policy.ID, *updated.SlaPolicyID) + + var count int64 + require.NoError(t, db.Model(&model.AppliedSLA{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error) + assert.Equal(t, int64(1), count) +} + +func TestConversationService_Update_RejectsSlaPolicyReplacement(t *testing.T) { + svc, db := setupConversationService(t) + + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + policy := createConversationServiceTestSlaPolicy(t, db, account.ID) + otherPolicy := createConversationServiceTestSlaPolicy(t, db, account.ID) + conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + + _, err := svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{SlaPolicyID: &policy.ID}) + require.NoError(t, err) + + _, err = svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{SlaPolicyID: &otherPolicy.ID}) + require.Error(t, err) + assert.Contains(t, err.Error(), "different sla") +} + // ========== GetMeta Tests ========== func TestConversationService_GetMeta(t *testing.T) { @@ -151,11 +238,11 @@ func TestConversationService_MarkUnread(t *testing.T) { // Create an incoming message so MarkUnread sets agent_last_seen_at to last_incoming.CreatedAt - 1s incomingMsg := &model.Message{ - AccountID: account.ID, - InboxID: inbox.ID, - ConversationID: conv.ID, - MessageType: string(model.MessageTypeIncoming), - Content: "hello from customer", + AccountID: account.ID, + InboxID: inbox.ID, + ConversationID: conv.ID, + MessageType: string(model.MessageTypeIncoming), + Content: "hello from customer", ContentAttributes: datatypes.JSON(`{"type": "text"}`), } require.NoError(t, db.Create(incomingMsg).Error) @@ -321,21 +408,21 @@ func TestConversationService_UnreadCounts(t *testing.T) { // Create incoming messages msg1 := &model.Message{ - AccountID: account.ID, - InboxID: inbox.ID, - ConversationID: conv1.ID, - MessageType: string(model.MessageTypeIncoming), - Content: "msg1", + AccountID: account.ID, + InboxID: inbox.ID, + ConversationID: conv1.ID, + MessageType: string(model.MessageTypeIncoming), + Content: "msg1", ContentAttributes: datatypes.JSON(`{"type": "text"}`), } require.NoError(t, db.Create(msg1).Error) msg2 := &model.Message{ - AccountID: account.ID, - InboxID: inbox.ID, - ConversationID: conv2.ID, - MessageType: string(model.MessageTypeIncoming), - Content: "msg2", + AccountID: account.ID, + InboxID: inbox.ID, + ConversationID: conv2.ID, + MessageType: string(model.MessageTypeIncoming), + Content: "msg2", ContentAttributes: datatypes.JSON(`{"type": "text"}`), } require.NoError(t, db.Create(msg2).Error) @@ -380,4 +467,4 @@ func TestConversationService_UnreadCounts_NoUnread(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, payload) assert.Empty(t, payload.Inboxes) -} \ No newline at end of file +}