diff --git a/internal/handler/api/v1/inbox_handler.go b/internal/handler/api/v1/inbox_handler.go index 0a98e0c0..50fdbf81 100644 --- a/internal/handler/api/v1/inbox_handler.go +++ b/internal/handler/api/v1/inbox_handler.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/response" ) @@ -317,6 +318,8 @@ func applyCreateInboxForm(req *service.CreateInboxRequest, values map[string][]s req.BusinessName = inboxStringPtr(value) case "csat_config": req.CsatConfig = inboxJSONObject(value) + case "working_hours": + req.WorkingHours = inboxWorkingHours(value) } } } @@ -369,6 +372,8 @@ func applyUpdateInboxForm(req *service.UpdateInboxRequest, values map[string][]s req.BusinessName = inboxStringPtr(value) case "csat_config": req.CsatConfig = inboxJSONObject(value) + case "working_hours": + req.WorkingHours = inboxWorkingHours(value) } } } @@ -443,6 +448,17 @@ func inboxJSONObject(value string) map[string]any { 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) // ======================================== diff --git a/internal/handler/api/v1/inbox_handler_parity_test.go b/internal/handler/api/v1/inbox_handler_parity_test.go index 1a1b1c3f..461d7ba5 100644 --- a/internal/handler/api/v1/inbox_handler_parity_test.go +++ b/internal/handler/api/v1/inbox_handler_parity_test.go @@ -33,7 +33,7 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) { _ = sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{})) account := &model.Account{Name: "Inbox Parity", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) @@ -123,7 +123,7 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) { _ = sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{})) account := &model.Account{Name: "Inbox Binding", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) @@ -206,6 +206,41 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) { require.Equal(t, "Rate this conversation", csatConfig["message"]) require.Equal(t, "Please rate us", csatConfig["button_text"]) require.ElementsMatch(t, []any{"vip", "priority"}, csatConfig["survey_rules"].(map[string]any)["values"].([]any)) + + workingHoursUpdate := inboxParityRequest(t, router, http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, websiteID), map[string]any{ + "working_hours_enabled": true, + "out_of_office_message": "We are away", + "timezone": "Asia/Shanghai", + "working_hours": []map[string]any{ + {"day_of_week": 0, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false}, + {"day_of_week": 1, "closed_all_day": false, "open_hour": "9", "open_minutes": "30", "close_hour": "18", "close_minutes": "0", "open_all_day": false}, + {"day_of_week": 2, "closed_all_day": false, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": true}, + {"day_of_week": 3, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false}, + {"day_of_week": 4, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false}, + {"day_of_week": 5, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false}, + {"day_of_week": 6, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false}, + }, + "channel": map[string]any{}, + }) + require.Equal(t, http.StatusOK, workingHoursUpdate.Code, workingHoursUpdate.Body.String()) + workingHoursData := inboxParityObject(t, workingHoursUpdate) + require.Equal(t, true, workingHoursData["working_hours_enabled"]) + require.Equal(t, "We are away", workingHoursData["out_of_office_message"]) + require.Equal(t, "Asia/Shanghai", workingHoursData["timezone"]) + workingHours := workingHoursData["working_hours"].([]any) + require.Len(t, workingHours, 7) + monday := workingHours[1].(map[string]any) + require.Equal(t, float64(1), monday["day_of_week"]) + require.Equal(t, float64(9), monday["open_hour"]) + require.Equal(t, float64(30), monday["open_minutes"]) + tuesday := workingHours[2].(map[string]any) + require.Equal(t, true, tuesday["open_all_day"]) + require.Equal(t, float64(0), tuesday["open_hour"]) + require.Equal(t, float64(23), tuesday["close_hour"]) + + showUpdated := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, websiteID), nil) + require.Equal(t, http.StatusOK, showUpdated.Code, showUpdated.Body.String()) + require.Len(t, inboxParityObject(t, showUpdated)["working_hours"].([]any), 7) } func setupInboxParityRouter(db *gorm.DB) *gin.Engine { diff --git a/internal/handler/api/v1/inbox_serializer.go b/internal/handler/api/v1/inbox_serializer.go index 4bf59b25..7094eeb4 100644 --- a/internal/handler/api/v1/inbox_serializer.go +++ b/internal/handler/api/v1/inbox_serializer.go @@ -32,7 +32,7 @@ func serializeInbox(inbox *model.Inbox) map[string]any { "enable_auto_assignment": inbox.EnableAutoAssignment, "auto_assignment_config": configValue(config, "auto_assignment_config"), "out_of_office_message": inbox.OutOfOfficeMessage, - "working_hours": configArray(config, "working_hours"), + "working_hours": serializeInboxWorkingHours(inbox, config), "timezone": inbox.Timezone, "callback_webhook_url": configValue(config, "callback_webhook_url"), "allow_messages_after_resolved": inbox.AllowMessagesAfterResolved, @@ -96,6 +96,25 @@ func serializeInbox(inbox *model.Inbox) map[string]any { return payload } +func serializeInboxWorkingHours(inbox *model.Inbox, config map[string]any) any { + if len(inbox.WorkingHours) == 0 { + return configArray(config, "working_hours") + } + payload := make([]any, 0, len(inbox.WorkingHours)) + for _, wh := range inbox.WorkingHours { + payload = append(payload, map[string]any{ + "day_of_week": wh.DayOfWeek, + "closed_all_day": wh.ClosedAllDay, + "open_hour": wh.OpenHour, + "open_minutes": wh.OpenMinutes, + "close_hour": wh.CloseHour, + "close_minutes": wh.CloseMinutes, + "open_all_day": wh.OpenAllDay, + }) + } + return payload +} + func chatwootChannelType(channelType string) string { if strings.HasPrefix(channelType, "Channel::") { return channelType diff --git a/internal/model/inbox.go b/internal/model/inbox.go index d9c8b26c..59baec65 100644 --- a/internal/model/inbox.go +++ b/internal/model/inbox.go @@ -4,45 +4,49 @@ package model // Reference: Chatwoot app/models/inbox.rb // // Chatwoot inbox attributes (permitted_params in InboxesController): -// name, avatar, greeting_enabled, greeting_message, enable_email_collect, -// csat_survey_enabled, enable_auto_assignment, working_hours_enabled, -// out_of_office_message, timezone, allow_messages_after_resolved, -// lock_to_single_conversation, portal_id, sender_name_type, business_name, -// csat_config (nested hash) +// +// name, avatar, greeting_enabled, greeting_message, enable_email_collect, +// csat_survey_enabled, enable_auto_assignment, working_hours_enabled, +// out_of_office_message, timezone, allow_messages_after_resolved, +// lock_to_single_conversation, portal_id, sender_name_type, business_name, +// csat_config (nested hash) // // Additional Chatwoot fields not in permitted_params but in model: -// channel_type, channel_id, account_id, email_address (for email channel), -// webhook_url (for API channel), identifier (unique slug) +// +// channel_type, channel_id, account_id, email_address (for email channel), +// webhook_url (for API channel), identifier (unique slug) type Inbox struct { Base - AccountID uint `gorm:"index;not null" json:"account_id"` - Name string `gorm:"size:255;not null" json:"name"` - ChannelType string `gorm:"size:50;index;not null" json:"channel_type"` // web_widget, facebook, twitter, whatsapp, telegram, email, api, etc - ChannelID uint `gorm:"index;not null" json:"channel_id"` - EnableAutoAssignment bool `gorm:"default:false" json:"enable_auto_assignment"` - AutoAssignmentLimit int `gorm:"default:0" json:"auto_assignment_limit"` - Enabled bool `gorm:"default:true" json:"enabled"` - ChannelConfig string `gorm:"type:text" json:"channel_config,omitempty"` // JSON-encoded per-inbox channel configuration + AccountID uint `gorm:"index;not null" json:"account_id"` + Name string `gorm:"size:255;not null" json:"name"` + ChannelType string `gorm:"size:50;index;not null" json:"channel_type"` // web_widget, facebook, twitter, whatsapp, telegram, email, api, etc + ChannelID uint `gorm:"index;not null" json:"channel_id"` + EnableAutoAssignment bool `gorm:"default:false" json:"enable_auto_assignment"` + AutoAssignmentLimit int `gorm:"default:0" json:"auto_assignment_limit"` + Enabled bool `gorm:"default:true" json:"enabled"` + ChannelConfig string `gorm:"type:text" json:"channel_config,omitempty"` // JSON-encoded per-inbox channel configuration // Chatwoot inbox settings (from permitted_params) - GreetingEnabled bool `gorm:"default:false" json:"greeting_enabled"` - GreetingMessage string `gorm:"type:text" json:"greeting_message,omitempty"` - EnableEmailCollect bool `gorm:"default:true" json:"enable_email_collect"` - CsatSurveyEnabled bool `gorm:"default:false" json:"csat_survey_enabled"` - WorkingHoursEnabled bool `gorm:"default:false" json:"working_hours_enabled"` - OutOfOfficeMessage string `gorm:"type:text" json:"out_of_office_message,omitempty"` - Timezone string `gorm:"size:100" json:"timezone,omitempty"` // e.g. "Asia/Kolkata" - AllowMessagesAfterResolved bool `gorm:"default:true" json:"allow_messages_after_resolved"` - LockToSingleConversation bool `gorm:"default:false" json:"lock_to_single_conversation"` - SenderNameType string `gorm:"size:50;default:friendly_name" json:"sender_name_type,omitempty"` // friendly_name, business_name, random - BusinessName string `gorm:"size:255" json:"business_name,omitempty"` - CsatConfig string `gorm:"type:text" json:"csat_config,omitempty"` // JSON-encoded CSAT survey configuration - AvatarURL string `gorm:"size:1024" json:"avatar_url,omitempty"` // URL to inbox avatar image - PortalID *uint `gorm:"index" json:"portal_id,omitempty"` // FK to help-center portal (nullable) + GreetingEnabled bool `gorm:"default:false" json:"greeting_enabled"` + GreetingMessage string `gorm:"type:text" json:"greeting_message,omitempty"` + EnableEmailCollect bool `gorm:"default:true" json:"enable_email_collect"` + CsatSurveyEnabled bool `gorm:"default:false" json:"csat_survey_enabled"` + WorkingHoursEnabled bool `gorm:"default:false" json:"working_hours_enabled"` + OutOfOfficeMessage string `gorm:"type:text" json:"out_of_office_message,omitempty"` + Timezone string `gorm:"size:100" json:"timezone,omitempty"` // e.g. "Asia/Kolkata" + AllowMessagesAfterResolved bool `gorm:"default:true" json:"allow_messages_after_resolved"` + LockToSingleConversation bool `gorm:"default:false" json:"lock_to_single_conversation"` + SenderNameType string `gorm:"size:50;default:friendly_name" json:"sender_name_type,omitempty"` // friendly_name, business_name, random + BusinessName string `gorm:"size:255" json:"business_name,omitempty"` + CsatConfig string `gorm:"type:text" json:"csat_config,omitempty"` // JSON-encoded CSAT survey configuration + AvatarURL string `gorm:"size:1024" json:"avatar_url,omitempty"` // URL to inbox avatar image + PortalID *uint `gorm:"index" json:"portal_id,omitempty"` // FK to help-center portal (nullable) // API inbox specific fields WebhookURL string `gorm:"size:1024" json:"webhook_url,omitempty"` // webhook URL for API inboxes - Secret string `gorm:"size:255" json:"secret,omitempty"` // HMAC secret for API inbox webhook verification + Secret string `gorm:"size:255" json:"secret,omitempty"` // HMAC secret for API inbox webhook verification + + WorkingHours []WorkingHour `gorm:"foreignKey:InboxID" json:"working_hours,omitempty"` } -func (Inbox) TableName() string { return "inboxes" } \ No newline at end of file +func (Inbox) TableName() string { return "inboxes" } diff --git a/internal/repository/inbox_repo.go b/internal/repository/inbox_repo.go index 21ffb825..b427e060 100644 --- a/internal/repository/inbox_repo.go +++ b/internal/repository/inbox_repo.go @@ -27,7 +27,7 @@ func (r *InboxRepo) DB() *gorm.DB { return r.db } // FindByID retrieves an inbox by primary key. func (r *InboxRepo) FindByID(ctx context.Context, id uint) (*model.Inbox, error) { var inbox model.Inbox - err := r.db.WithContext(ctx).First(&inbox, id).Error + err := r.withWorkingHours(r.db.WithContext(ctx)).First(&inbox, id).Error if err != nil { return nil, err } @@ -44,7 +44,7 @@ func (r *InboxRepo) FindByAccount(ctx context.Context, accountID uint, offset, l return nil, 0, err } - err := r.db.WithContext(ctx).Where("account_id = ?", accountID). + err := r.withWorkingHours(r.db.WithContext(ctx)).Where("account_id = ?", accountID). Offset(offset).Limit(limit).Order("id DESC"). Find(&inboxes).Error return inboxes, total, err @@ -57,7 +57,7 @@ func (r *InboxRepo) Create(ctx context.Context, inbox *model.Inbox) error { // Update modifies an existing inbox. func (r *InboxRepo) Update(ctx context.Context, inbox *model.Inbox) error { - return r.db.WithContext(ctx).Save(inbox).Error + return r.db.WithContext(ctx).Omit("WorkingHours").Save(inbox).Error } // Delete soft-deletes an inbox. @@ -68,13 +68,22 @@ func (r *InboxRepo) Delete(ctx context.Context, id uint) error { // FindByAccountAndID retrieves an inbox scoped to an account. func (r *InboxRepo) FindByAccountAndID(ctx context.Context, accountID, id uint) (*model.Inbox, error) { var inbox model.Inbox - err := r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&inbox).Error + err := r.withWorkingHours(r.db.WithContext(ctx)).Where("account_id = ? AND id = ?", accountID, id).First(&inbox).Error if err != nil { return nil, err } return &inbox, nil } +func (r *InboxRepo) withWorkingHours(db *gorm.DB) *gorm.DB { + if r == nil || r.db == nil || !r.db.Migrator().HasTable(&model.WorkingHour{}) { + return db + } + return db.Preload("WorkingHours", func(tx *gorm.DB) *gorm.DB { + return tx.Order("day_of_week ASC") + }) +} + // CountByAccount returns total number of inboxes in an account. func (r *InboxRepo) CountByAccount(ctx context.Context, accountID uint) (int64, error) { var total int64 diff --git a/internal/repository/working_hour_repo.go b/internal/repository/working_hour_repo.go index 48c84ce0..818070cf 100644 --- a/internal/repository/working_hour_repo.go +++ b/internal/repository/working_hour_repo.go @@ -2,6 +2,8 @@ package repository import ( "context" + "encoding/json" + "strconv" "github.com/gochat/gochat/internal/model" "gorm.io/gorm" @@ -52,7 +54,12 @@ func (r *WorkingHourRepo) CreateDefaultWorkingHours(ctx context.Context, inboxID {InboxID: inboxID, AccountID: accountID, DayOfWeek: 5, OpenHour: &nine, OpenMinutes: &zero, CloseHour: &seventeen, CloseMinutes: &zero, OpenAllDay: false}, {InboxID: inboxID, AccountID: accountID, DayOfWeek: 6, ClosedAllDay: true, OpenAllDay: false}, } - return r.db.WithContext(ctx).Create(&defaults).Error + for i := range defaults { + if err := r.db.WithContext(ctx).Create(&defaults[i]).Error; err != nil { + return err + } + } + return nil } // UpdateWorkingHours bulk-updates the weekly schedule for an inbox. @@ -93,4 +100,61 @@ type WorkingHourUpdateParam struct { OpenMinutes *int `json:"open_minutes"` CloseHour *int `json:"close_hour"` CloseMinutes *int `json:"close_minutes"` -} \ No newline at end of file +} + +func (p *WorkingHourUpdateParam) UnmarshalJSON(data []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + p.DayOfWeek = intFromAny(raw["day_of_week"]) + p.ClosedAllDay = boolFromAny(raw["closed_all_day"]) + p.OpenAllDay = boolFromAny(raw["open_all_day"]) + p.OpenHour = intPtrFromAny(raw["open_hour"]) + p.OpenMinutes = intPtrFromAny(raw["open_minutes"]) + p.CloseHour = intPtrFromAny(raw["close_hour"]) + p.CloseMinutes = intPtrFromAny(raw["close_minutes"]) + return nil +} + +func intFromAny(value interface{}) int { + if ptr := intPtrFromAny(value); ptr != nil { + return *ptr + } + return 0 +} + +func intPtrFromAny(value interface{}) *int { + switch typed := value.(type) { + case nil: + return nil + case float64: + v := int(typed) + return &v + case int: + return &typed + case string: + if typed == "" || typed == "null" { + return nil + } + parsed, err := strconv.Atoi(typed) + if err != nil { + return nil + } + return &parsed + default: + return nil + } +} + +func boolFromAny(value interface{}) bool { + switch typed := value.(type) { + case bool: + return typed + case string: + parsed, _ := strconv.ParseBool(typed) + return parsed + default: + return false + } +} diff --git a/internal/service/inbox_service.go b/internal/service/inbox_service.go index c0ca42d5..3776aa52 100644 --- a/internal/service/inbox_service.go +++ b/internal/service/inbox_service.go @@ -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) diff --git a/internal/service/working_hour_service_test.go b/internal/service/working_hour_service_test.go new file mode 100644 index 00000000..1116ceb9 --- /dev/null +++ b/internal/service/working_hour_service_test.go @@ -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) +}