feat(inboxes): persist chatwoot working hours

This commit is contained in:
2026-06-05 05:19:36 +08:00
parent ff98202c38
commit 945782eb03
8 changed files with 347 additions and 75 deletions
+107 -35
View File
@@ -73,24 +73,25 @@ func (s *InboxService) GetByAccountAndID(ctx context.Context, accountID, id uint
// CreateInboxRequest is the DTO for creating an inbox.
type CreateInboxRequest struct {
Name string `json:"name" validate:"omitempty,min=2"`
ChannelType string `json:"channel_type"`
Channel map[string]any `json:"channel,omitempty"`
Enabled bool `json:"enabled"`
EnableAutoAssignment bool `json:"enable_auto_assignment"`
GreetingEnabled *bool `json:"greeting_enabled,omitempty"`
GreetingMessage *string `json:"greeting_message,omitempty"`
EnableEmailCollect *bool `json:"enable_email_collect,omitempty"`
CsatSurveyEnabled *bool `json:"csat_survey_enabled,omitempty"`
WorkingHoursEnabled *bool `json:"working_hours_enabled,omitempty"`
OutOfOfficeMessage *string `json:"out_of_office_message,omitempty"`
Timezone *string `json:"timezone,omitempty"`
AllowMessagesAfterResolved *bool `json:"allow_messages_after_resolved,omitempty"`
LockToSingleConversation *bool `json:"lock_to_single_conversation,omitempty"`
PortalID *uint `json:"portal_id,omitempty"`
SenderNameType *string `json:"sender_name_type,omitempty"`
BusinessName *string `json:"business_name,omitempty"`
CsatConfig map[string]any `json:"csat_config,omitempty"`
Name string `json:"name" validate:"omitempty,min=2"`
ChannelType string `json:"channel_type"`
Channel map[string]any `json:"channel,omitempty"`
Enabled bool `json:"enabled"`
EnableAutoAssignment bool `json:"enable_auto_assignment"`
GreetingEnabled *bool `json:"greeting_enabled,omitempty"`
GreetingMessage *string `json:"greeting_message,omitempty"`
EnableEmailCollect *bool `json:"enable_email_collect,omitempty"`
CsatSurveyEnabled *bool `json:"csat_survey_enabled,omitempty"`
WorkingHoursEnabled *bool `json:"working_hours_enabled,omitempty"`
OutOfOfficeMessage *string `json:"out_of_office_message,omitempty"`
Timezone *string `json:"timezone,omitempty"`
AllowMessagesAfterResolved *bool `json:"allow_messages_after_resolved,omitempty"`
LockToSingleConversation *bool `json:"lock_to_single_conversation,omitempty"`
PortalID *uint `json:"portal_id,omitempty"`
SenderNameType *string `json:"sender_name_type,omitempty"`
BusinessName *string `json:"business_name,omitempty"`
CsatConfig map[string]any `json:"csat_config,omitempty"`
WorkingHours []repository.WorkingHourUpdateParam `json:"working_hours,omitempty"`
}
// Create creates a new inbox.
@@ -151,6 +152,14 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb
return nil, err
}
applyCreateInboxSettings(inbox, req)
if err := s.ensureInboxWorkingHours(ctx, inbox); err != nil {
applogger.L().Warnf("Failed to initialize inbox working hours: %v", err)
}
if len(req.WorkingHours) > 0 {
if err := s.updateInboxWorkingHours(ctx, inbox, req.WorkingHours); err != nil {
return nil, err
}
}
if err := s.repo.Update(ctx, inbox); err != nil {
applogger.L().Errorf("Failed to persist inbox defaults: %v", err)
return nil, err
@@ -160,23 +169,24 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb
// UpdateInboxRequest is the DTO for updating an inbox.
type UpdateInboxRequest struct {
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
Enabled *bool `json:"enabled,omitempty"`
EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"`
Channel map[string]any `json:"channel,omitempty"`
GreetingEnabled *bool `json:"greeting_enabled,omitempty"`
GreetingMessage *string `json:"greeting_message,omitempty"`
EnableEmailCollect *bool `json:"enable_email_collect,omitempty"`
CsatSurveyEnabled *bool `json:"csat_survey_enabled,omitempty"`
WorkingHoursEnabled *bool `json:"working_hours_enabled,omitempty"`
OutOfOfficeMessage *string `json:"out_of_office_message,omitempty"`
Timezone *string `json:"timezone,omitempty"`
AllowMessagesAfterResolved *bool `json:"allow_messages_after_resolved,omitempty"`
LockToSingleConversation *bool `json:"lock_to_single_conversation,omitempty"`
PortalID *uint `json:"portal_id,omitempty"`
SenderNameType *string `json:"sender_name_type,omitempty"`
BusinessName *string `json:"business_name,omitempty"`
CsatConfig map[string]any `json:"csat_config,omitempty"`
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
Enabled *bool `json:"enabled,omitempty"`
EnableAutoAssignment *bool `json:"enable_auto_assignment,omitempty"`
Channel map[string]any `json:"channel,omitempty"`
GreetingEnabled *bool `json:"greeting_enabled,omitempty"`
GreetingMessage *string `json:"greeting_message,omitempty"`
EnableEmailCollect *bool `json:"enable_email_collect,omitempty"`
CsatSurveyEnabled *bool `json:"csat_survey_enabled,omitempty"`
WorkingHoursEnabled *bool `json:"working_hours_enabled,omitempty"`
OutOfOfficeMessage *string `json:"out_of_office_message,omitempty"`
Timezone *string `json:"timezone,omitempty"`
AllowMessagesAfterResolved *bool `json:"allow_messages_after_resolved,omitempty"`
LockToSingleConversation *bool `json:"lock_to_single_conversation,omitempty"`
PortalID *uint `json:"portal_id,omitempty"`
SenderNameType *string `json:"sender_name_type,omitempty"`
BusinessName *string `json:"business_name,omitempty"`
CsatConfig map[string]any `json:"csat_config,omitempty"`
WorkingHours []repository.WorkingHourUpdateParam `json:"working_hours,omitempty"`
}
// Update modifies an existing inbox.
@@ -212,6 +222,11 @@ func (s *InboxService) Update(ctx context.Context, accountID, id uint, req Updat
inbox.WebhookURL = webhookURL
}
}
if len(req.WorkingHours) > 0 {
if err := s.updateInboxWorkingHours(ctx, inbox, req.WorkingHours); err != nil {
return nil, err
}
}
if err := s.repo.Update(ctx, inbox); err != nil {
return nil, err
@@ -478,6 +493,63 @@ func mapString(values map[string]any, key string) string {
}
}
func (s *InboxService) ensureInboxWorkingHours(ctx context.Context, inbox *model.Inbox) error {
if s == nil || s.repo == nil || s.repo.DB() == nil {
return nil
}
db := s.repo.DB()
if !db.Migrator().HasTable(&model.WorkingHour{}) {
return nil
}
var count int64
if err := db.WithContext(ctx).Model(&model.WorkingHour{}).Where("inbox_id = ?", inbox.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return s.loadInboxWorkingHours(ctx, inbox)
}
if err := repository.NewWorkingHourRepo(db).CreateDefaultWorkingHours(ctx, inbox.ID, inbox.AccountID); err != nil {
return err
}
return s.loadInboxWorkingHours(ctx, inbox)
}
func (s *InboxService) updateInboxWorkingHours(ctx context.Context, inbox *model.Inbox, params []repository.WorkingHourUpdateParam) error {
if err := s.ensureInboxWorkingHours(ctx, inbox); err != nil {
return err
}
for i, param := range params {
temp := &model.WorkingHour{
InboxID: inbox.ID,
AccountID: inbox.AccountID,
DayOfWeek: param.DayOfWeek,
ClosedAllDay: param.ClosedAllDay,
OpenAllDay: param.OpenAllDay,
OpenHour: param.OpenHour,
OpenMinutes: param.OpenMinutes,
CloseHour: param.CloseHour,
CloseMinutes: param.CloseMinutes,
}
temp.EnsureOpenAllDayHours()
if errs := temp.Validate(); len(errs) > 0 {
return fmt.Errorf("validation error for day %d (entry %d): %v", param.DayOfWeek, i, errs)
}
}
if err := repository.NewWorkingHourRepo(s.repo.DB()).UpdateWorkingHours(ctx, inbox.ID, params); err != nil {
return err
}
return s.loadInboxWorkingHours(ctx, inbox)
}
func (s *InboxService) loadInboxWorkingHours(ctx context.Context, inbox *model.Inbox) error {
hours, err := repository.NewWorkingHourRepo(s.repo.DB()).FindByInbox(ctx, inbox.ID)
if err != nil {
return err
}
inbox.WorkingHours = hours
return nil
}
// Delete soft-deletes an inbox.
func (s *InboxService) Delete(ctx context.Context, id uint) error {
return s.repo.Delete(ctx, id)
@@ -0,0 +1,53 @@
package service
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
)
func TestWorkingHourService_IsOutOfOffice(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:working_hour_service?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
t.Cleanup(func() {
sqlDB, dbErr := db.DB()
if dbErr == nil {
_ = sqlDB.Close()
}
})
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}))
account := &model.Account{Name: "Working Hours", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget", ChannelID: 1, Enabled: true, WorkingHoursEnabled: true, Timezone: "UTC"}
require.NoError(t, db.Create(inbox).Error)
whRepo := repository.NewWorkingHourRepo(db)
require.NoError(t, whRepo.CreateDefaultWorkingHours(context.Background(), inbox.ID, account.ID))
svc := NewWorkingHourService(whRepo, repository.NewInboxRepo(db), repository.NewAccountRepo(db))
closedWeek := make([]repository.WorkingHourUpdateParam, 0, 7)
for day := 0; day < 7; day++ {
closedWeek = append(closedWeek, repository.WorkingHourUpdateParam{DayOfWeek: day, ClosedAllDay: true})
}
_, err = svc.UpdateWeeklySchedule(context.Background(), inbox.ID, closedWeek)
require.NoError(t, err)
outOfOffice, err := svc.IsOutOfOffice(context.Background(), inbox.ID)
require.NoError(t, err)
require.True(t, outOfOffice)
require.NoError(t, db.Model(inbox).Update("working_hours_enabled", false).Error)
outOfOffice, err = svc.IsOutOfOffice(context.Background(), inbox.ID)
require.NoError(t, err)
require.False(t, outOfOffice)
}