Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions api/pkg/handlers/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"time"

"github.com/NdoleStudio/httpsms/pkg/entities"

"github.com/NdoleStudio/httpsms/pkg/repositories"
"github.com/google/uuid"

Expand Down Expand Up @@ -46,6 +48,7 @@ func NewMessageHandler(
// RegisterRoutes registers the routes for the MessageHandler
func (h *MessageHandler) RegisterRoutes(router fiber.Router) {
router.Post("/messages/send", h.PostSend)
router.Post("/messages/bulk-send", h.BulkSend)
router.Post("/messages/receive", h.PostReceive)
router.Get("/messages/outstanding", h.GetOutstanding)
router.Get("/messages", h.Index)
Expand Down Expand Up @@ -100,6 +103,59 @@ func (h *MessageHandler) PostSend(c *fiber.Ctx) error {
return h.responseOK(c, "message added to queue", message)
}

// BulkSend a bulk entities.Message
// @Summary Send bulk SMS messages
// @Description Add bulk SMS messages to be sent by the android phone
// @Security ApiKeyAuth
// @Tags Messages
// @Accept json
// @Produce json
// @Param payload body requests.MessageBulkSend true "Bulk send message request payload"
// @Success 200 {object} []responses.MessagesResponse
// @Failure 400 {object} responses.BadRequest
// @Failure 401 {object} responses.Unauthorized
// @Failure 422 {object} responses.UnprocessableEntity
// @Failure 500 {object} responses.InternalServerError
// @Router /messages/bulk-send [post]
func (h *MessageHandler) BulkSend(c *fiber.Ctx) error {
ctx, span := h.tracer.StartFromFiberCtx(c)
defer span.End()

ctxLogger := h.tracer.CtxLogger(h.logger, span)

var request requests.MessageBulkSend
if err := c.BodyParser(&request); err != nil {
msg := fmt.Sprintf("cannot marshall [%s] into %T", c.Body(), request)
ctxLogger.Warn(stacktrace.Propagate(err, msg))
return h.responseBadRequest(c, err)
}

if errors := h.validator.ValidateMessageBulkSend(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 {
msg := fmt.Sprintf("validation errors [%s], while sending payload [%s]", spew.Sdump(errors), c.Body())
ctxLogger.Warn(stacktrace.NewError(msg))
return h.responseUnprocessableEntity(c, errors, "validation errors while sending messages")
}

var responses []*entities.Message
params := request.ToMessageSendParams(h.userIDFomContext(c), c.OriginalURL())
for _, param := range params {
if msg := h.billingService.IsEntitled(ctx, h.userIDFomContext(c)); msg != nil {
ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] can't send a message", h.userIDFomContext(c))))
break
}

message, err := h.service.SendMessage(ctx, param)
if err != nil {
msg := fmt.Sprintf("cannot send message with paylod [%s]", c.Body())
ctxLogger.Error(stacktrace.Propagate(err, msg))
break
}
responses = append(responses, message)
}

return h.responseOK(c, "messages added to queue", responses)
}

// GetOutstanding returns an entities.Message which is still to be sent by the mobile phone
// @Summary Get an outstanding message
// @Description Get an outstanding message to be sent by an android phone
Expand Down
49 changes: 49 additions & 0 deletions api/pkg/requests/message_bulk_send_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package requests

import (
"time"

"github.com/NdoleStudio/httpsms/pkg/entities"

"github.com/nyaruka/phonenumbers"

"github.com/NdoleStudio/httpsms/pkg/services"
)

// MessageBulkSend is the payload for sending bulk SMS messages
type MessageBulkSend struct {
request
From string `json:"from" example:"+18005550199"`
To []string `json:"to" example:"[+18005550100,+18005550100]"`
Content string `json:"content" example:"This is a sample text message"`
}

// Sanitize sets defaults to MessageReceive
func (input *MessageBulkSend) Sanitize() MessageBulkSend {
var to []string
for _, address := range input.To {
to = append(to, input.sanitizeAddress(address))
}
input.To = to
input.From = input.sanitizeAddress(input.From)
return *input
}

// ToMessageSendParams converts MessageSend to services.MessageSendParams
func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source string) []services.MessageSendParams {
from, _ := phonenumbers.Parse(input.From, phonenumbers.UNKNOWN_REGION)
var result []services.MessageSendParams
for _, to := range input.To {
toAddress, _ := phonenumbers.Parse(to, phonenumbers.UNKNOWN_REGION)
result = append(result, services.MessageSendParams{
Source: source,
Owner: *from,
UserID: userID,
RequestReceivedAt: time.Now().UTC(),
Contact: *toAddress,
Content: input.Content,
})
}

return result
}
46 changes: 46 additions & 0 deletions api/pkg/validators/message_handler_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,52 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context
return result
}

// ValidateMessageBulkSend validates the requests.MessageBulkSend request
func (validator MessageHandlerValidator) ValidateMessageBulkSend(ctx context.Context, userID entities.UserID, request requests.MessageBulkSend) url.Values {
ctx, span := validator.tracer.Start(ctx)
defer span.End()

ctxLogger := validator.tracer.CtxLogger(validator.logger, span)

v := govalidator.New(govalidator.Options{
Data: &request,
Rules: govalidator.MapData{
"to": []string{
"required",
"max:50",
"min:1",
multiplePhoneNumberRule,
},
"from": []string{
"required",
phoneNumberRule,
},
"content": []string{
"required",
"min:1",
"max:1024",
},
},
})

result := v.ValidateStruct()
if len(result) != 0 {
return result
}

_, err := validator.phoneService.Load(ctx, userID, request.From)
if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
result.Add("from", fmt.Sprintf("no phone found with with 'from' number [%s]. Install the android app on your phone to start sending messages", request.From))
}

if err != nil {
ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("could not load phone for user [%s] and phone [%s]", userID, request.From))))
result.Add("from", fmt.Sprintf("could not validate 'from' number [%s], please try again later", request.From))
}

return result
}

// ValidateMessageOutstanding validates the requests.MessageOutstanding request
func (validator MessageHandlerValidator) ValidateMessageOutstanding(_ context.Context, request requests.MessageOutstanding) url.Values {
v := govalidator.New(govalidator.Options{
Expand Down
21 changes: 19 additions & 2 deletions api/pkg/validators/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import (
type validator struct{}

const (
phoneNumberRule = "phoneNumber"
webhookEventsRule = "webhookEvents"
phoneNumberRule = "phoneNumber"
multiplePhoneNumberRule = "multiplePhoneNumber"
webhookEventsRule = "webhookEvents"
)

func init() {
Expand All @@ -35,6 +36,22 @@ func init() {
return nil
})

govalidator.AddCustomRule(multiplePhoneNumberRule, func(field string, rule string, message string, value interface{}) error {
phoneNumbers, ok := value.([]string)
if !ok {
return fmt.Errorf("the %s field must be an array of a valid E.164 phone number: https://en.wikipedia.org/wiki/E.164", field)
}

for index, number := range phoneNumbers {
_, err := phonenumbers.Parse(number, phonenumbers.UNKNOWN_REGION)
if err != nil {
return fmt.Errorf("the %s value in index [%d] must be a valid E.164 phone number: https://en.wikipedia.org/wiki/E.164", field, index)
}
}

return nil
})

govalidator.AddCustomRule(webhookEventsRule, func(field string, rule string, message string, value interface{}) error {
input, ok := value.([]string)
if !ok {
Expand Down