diff --git a/api/requests.http b/api/requests.http index 9ba8e4c..55e0eb0 100644 --- a/api/requests.http +++ b/api/requests.http @@ -63,14 +63,18 @@ Content-Type: application/json [ { - "id": "GKBw_tkVnN8NJz3hse9ue", + "id": "RqqnKoakAc82f6e4SwoMe", "state": "Failed", "recipients": [ { "phoneNumber": "{{phone}}", "state": "Failed" } - ] + ], + "states": { + "Processed": "2024-05-13T16:49:17.357Z", + "Failed": "2024-05-13T16:49:17.357+04:00" + } } ] diff --git a/internal/sms-gateway/handlers/mobile.go b/internal/sms-gateway/handlers/mobile.go index 250635c..60cf5b2 100644 --- a/internal/sms-gateway/handlers/mobile.go +++ b/internal/sms-gateway/handlers/mobile.go @@ -141,11 +141,11 @@ func (h *mobileHandler) patchMessage(device models.Device, c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } - if err := h.Validator.Var(req, "required,dive"); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - for _, v := range req { + if err := h.validateStruct(v); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + err := h.messagesSvc.UpdateState(device.ID, v) if err != nil && !errors.Is(err, repositories.ErrMessageNotFound) { h.Logger.Error("Can't update message status", zap.Error(err)) diff --git a/internal/sms-gateway/models/migrations/mysql/20240515225255_state_history.sql b/internal/sms-gateway/models/migrations/mysql/20240515225255_state_history.sql new file mode 100644 index 0000000..ea64419 --- /dev/null +++ b/internal/sms-gateway/models/migrations/mysql/20240515225255_state_history.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE `message_states` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT, + `message_id` BIGINT UNSIGNED NOT NULL, + `state` enum( + 'Pending', + 'Sent', + 'Processed', + 'Delivered', + 'Failed' + ) NOT NULL, + `updated_at` datetime(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `unq_message_states_message_id_state` (`message_id`, `state`), + CONSTRAINT `fk_messages_states` FOREIGN KEY (`message_id`) REFERENCES `messages`(`id`) ON DELETE CASCADE +); +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +DROP TABLE `message_states`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/sms-gateway/modules/messages/service.go b/internal/sms-gateway/modules/messages/service.go index 78cb4ef..6653fd0 100644 --- a/internal/sms-gateway/modules/messages/service.go +++ b/internal/sms-gateway/modules/messages/service.go @@ -17,6 +17,7 @@ import ( "github.com/nyaruka/phonenumbers" "go.uber.org/fx" "go.uber.org/zap" + "golang.org/x/exp/maps" ) const ( @@ -116,11 +117,18 @@ func (s *Service) UpdateState(deviceID string, message smsgateway.MessageState) return err } - if message.State == smsgateway.MessageStatePending { - message.State = smsgateway.MessageStateProcessed + if message.State == smsgateway.ProcessingStatePending { + message.State = smsgateway.ProcessingStateProcessed } existing.State = models.ProcessingState(message.State) + existing.States = slices.Map(maps.Keys(message.States), func(key string) models.MessageState { + return models.MessageState{ + MessageID: existing.ID, + State: models.ProcessingState(key), + UpdatedAt: message.States[key], + } + }) existing.Recipients = s.recipientsStateToModel(message.Recipients, existing.IsHashed) if err := s.Messages.UpdateState(&existing); err != nil { @@ -148,7 +156,7 @@ func (s *Service) GetState(user models.User, ID string) (smsgateway.MessageState func (s *Service) Enqeue(device models.Device, message smsgateway.Message, opts EnqueueOptions) (smsgateway.MessageState, error) { state := smsgateway.MessageState{ ID: "", - State: smsgateway.MessageStatePending, + State: smsgateway.ProcessingStatePending, Recipients: make([]smsgateway.RecipientState, len(message.PhoneNumbers)), } @@ -167,7 +175,7 @@ func (s *Service) Enqeue(device models.Device, message smsgateway.Message, opts state.Recipients[i] = smsgateway.RecipientState{ PhoneNumber: phone, - State: smsgateway.MessageStatePending, + State: smsgateway.ProcessingStatePending, } } @@ -245,8 +253,8 @@ func (s *Service) recipientsStateToModel(input []smsgateway.RecipientState, hash phoneNumber = "+" + phoneNumber } - if v.State == smsgateway.MessageStatePending { - v.State = smsgateway.MessageStateProcessed + if v.State == smsgateway.ProcessingStatePending { + v.State = smsgateway.ProcessingStateProcessed } if hash { @@ -266,7 +274,7 @@ func (s *Service) recipientsStateToModel(input []smsgateway.RecipientState, hash func modelToMessageState(input models.Message) smsgateway.MessageState { return smsgateway.MessageState{ ID: input.ExtID, - State: smsgateway.ProcessState(input.State), + State: smsgateway.ProcessingState(input.State), IsHashed: input.IsHashed, IsEncrypted: input.IsEncrypted, Recipients: slices.Map(input.Recipients, modelToRecipientState), @@ -276,7 +284,7 @@ func modelToMessageState(input models.Message) smsgateway.MessageState { func modelToRecipientState(input models.MessageRecipient) smsgateway.RecipientState { return smsgateway.RecipientState{ PhoneNumber: input.PhoneNumber, - State: smsgateway.ProcessState(input.State), + State: smsgateway.ProcessingState(input.State), Error: input.Error, } } diff --git a/internal/sms-gateway/repositories/messages.go b/internal/sms-gateway/repositories/messages.go index 4681b09..c2e8288 100644 --- a/internal/sms-gateway/repositories/messages.go +++ b/internal/sms-gateway/repositories/messages.go @@ -6,6 +6,7 @@ import ( "github.com/capcom6/sms-gateway/internal/sms-gateway/models" "gorm.io/gorm" + "gorm.io/gorm/clause" ) const HashingLockName = "36444143-1ace-4dbf-891c-cc505911497e" @@ -59,6 +60,15 @@ func (r *MessagesRepository) UpdateState(message *models.Message) error { return err } + for _, v := range message.States { + v.MessageID = message.ID + if err := tx.Model(&v).Clauses(clause.OnConflict{ + DoNothing: true, + }).Create(&v).Error; err != nil { + return err + } + } + for _, v := range message.Recipients { if err := tx.Model(&v).Where("message_id = ?", message.ID).Select("State", "Error").Updates(&v).Error; err != nil { return err diff --git a/pkg/smsgateway/domain.go b/pkg/smsgateway/domain.go index e2aade6..6a88ef1 100644 --- a/pkg/smsgateway/domain.go +++ b/pkg/smsgateway/domain.go @@ -6,13 +6,21 @@ import ( ) const ( - MessageStatePending ProcessState = "Pending" // Pending - MessageStateProcessed ProcessState = "Processed" // Processed (received by device) - MessageStateSent ProcessState = "Sent" // Sent - MessageStateDelivered ProcessState = "Delivered" // Delivered - MessageStateFailed ProcessState = "Failed" // Failed + ProcessingStatePending ProcessingState = "Pending" // Pending + ProcessingStateProcessed ProcessingState = "Processed" // Processed (received by device) + ProcessingStateSent ProcessingState = "Sent" // Sent + ProcessingStateDelivered ProcessingState = "Delivered" // Delivered + ProcessingStateFailed ProcessingState = "Failed" // Failed ) +var allProcessStates = map[ProcessingState]struct{}{ + ProcessingStatePending: {}, + ProcessingStateProcessed: {}, + ProcessingStateSent: {}, + ProcessingStateDelivered: {}, + ProcessingStateFailed: {}, +} + // Device type Device struct { ID string `json:"id" example:"PyDmBQZZXYmyxMwED8Fzy"` // ID @@ -47,18 +55,29 @@ func (m Message) Validate() error { // Message state type MessageState struct { - ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // Message ID - State ProcessState `json:"state" validate:"required" example:"Pending"` // State - IsHashed bool `json:"isHashed" example:"false"` // Hashed - IsEncrypted bool `json:"isEncrypted" example:"false"` // Encrypted - Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` // Recipients states + ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // Message ID + State ProcessingState `json:"state" validate:"required" example:"Pending"` // State + IsHashed bool `json:"isHashed" example:"false"` // Hashed + IsEncrypted bool `json:"isEncrypted" example:"false"` // Encrypted + Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` // Recipients states + States map[string]time.Time `json:"states"` // History of states +} + +func (m MessageState) Validate() error { + for k := range m.States { + if _, ok := allProcessStates[ProcessingState(k)]; !ok { + return fmt.Errorf("invalid state value: %s", k) + } + } + + return nil } // Recipient state type RecipientState struct { - PhoneNumber string `json:"phoneNumber" validate:"required,min=10,max=128" example:"79990001234"` // Phone number or first 16 symbols of SHA256 hash - State ProcessState `json:"state" validate:"required" example:"Pending"` // State - Error *string `json:"error,omitempty" example:"timeout"` // Error (for `Failed` state) + PhoneNumber string `json:"phoneNumber" validate:"required,min=10,max=128" example:"79990001234"` // Phone number or first 16 symbols of SHA256 hash + State ProcessingState `json:"state" validate:"required" example:"Pending"` // State + Error *string `json:"error,omitempty" example:"timeout"` // Error (for `Failed` state) } // Push notification diff --git a/pkg/smsgateway/types.go b/pkg/smsgateway/types.go index 44c689d..8fc6906 100644 --- a/pkg/smsgateway/types.go +++ b/pkg/smsgateway/types.go @@ -2,6 +2,6 @@ package smsgateway import "errors" -type ProcessState string +type ProcessingState string var ErrConflictFields = errors.New("conflict fields")