Skip to content

Commit 39e5e60

Browse files
committed
Add bulk messages handler
1 parent be4b5a9 commit 39e5e60

19 files changed

Lines changed: 582 additions & 31 deletions

api/go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/cockroachdb/cockroach-go/v2 v2.3.5
1515
github.com/davecgh/go-spew v1.1.1
1616
github.com/dgraph-io/ristretto v0.1.1
17+
github.com/dustin/go-humanize v1.0.0
1718
github.com/gofiber/fiber/v2 v2.48.0
1819
github.com/gofiber/swagger v0.1.12
1920
github.com/golang-jwt/jwt v3.2.2+incompatible
@@ -23,6 +24,7 @@ require (
2324
github.com/jinzhu/now v1.1.5
2425
github.com/joho/godotenv v1.5.1
2526
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
27+
github.com/jszwec/csvutil v1.8.0
2628
github.com/lib/pq v1.10.9
2729
github.com/matcornic/hermes/v2 v2.1.0
2830
github.com/nyaruka/phonenumbers v1.1.7
@@ -71,7 +73,6 @@ require (
7173
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
7274
github.com/cespare/xxhash/v2 v2.2.0 // indirect
7375
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
74-
github.com/dustin/go-humanize v1.0.0 // indirect
7576
github.com/go-logr/logr v1.2.4 // indirect
7677
github.com/go-logr/stdr v1.2.2 // indirect
7778
github.com/go-openapi/jsonpointer v0.20.0 // indirect

api/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
228228
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
229229
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
230230
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
231+
github.com/jszwec/csvutil v1.8.0 h1:G7vS2LGdpZZDH1HmHeNbxOaJ/ZnJlpwGFvOkTkJzzNk=
232+
github.com/jszwec/csvutil v1.8.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I=
231233
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
232234
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
233235
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=

api/pkg/di/container.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ func NewContainer(projectID string, version string) (container *Container) {
107107

108108
container.RegisterMessageListeners()
109109
container.RegisterMessageRoutes()
110+
container.RegisterBulkMessageRoutes()
110111

111112
container.RegisterMessageThreadRoutes()
112113
container.RegisterMessageThreadListeners()
@@ -459,6 +460,16 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes
459460
)
460461
}
461462

463+
// BulkMessageHandlerValidator creates a new instance of validators.BulkMessageHandlerValidator
464+
func (container *Container) BulkMessageHandlerValidator() (validator *validators.BulkMessageHandlerValidator) {
465+
container.logger.Debug(fmt.Sprintf("creating %T", validator))
466+
return validators.NewBulkMessageHandlerValidator(
467+
container.Logger(),
468+
container.Tracer(),
469+
container.PhoneService(),
470+
)
471+
}
472+
462473
// HeartbeatHandler creates a new instance of handlers.HeartbeatHandler
463474
func (container *Container) HeartbeatHandler() (h *handlers.HeartbeatHandler) {
464475
container.logger.Debug(fmt.Sprintf("creating %T", h))
@@ -880,6 +891,18 @@ func (container *Container) MessageHandler() (handler *handlers.MessageHandler)
880891
)
881892
}
882893

894+
// BulkMessageHandler creates a new instance of handlers.BulkMessageHandler
895+
func (container *Container) BulkMessageHandler() (handler *handlers.BulkMessageHandler) {
896+
container.logger.Debug(fmt.Sprintf("creating %T", handler))
897+
return handlers.NewBulkMessageHandler(
898+
container.Logger(),
899+
container.Tracer(),
900+
container.BulkMessageHandlerValidator(),
901+
container.BillingService(),
902+
container.MessageService(),
903+
)
904+
}
905+
883906
// UserHandler creates a new instance of handlers.MessageHandler
884907
func (container *Container) UserHandler() (handler *handlers.UserHandler) {
885908
container.logger.Debug(fmt.Sprintf("creating %T", handler))
@@ -1169,6 +1192,12 @@ func (container *Container) RegisterMessageRoutes() {
11691192
container.MessageHandler().RegisterRoutes(container.AuthRouter())
11701193
}
11711194

1195+
// RegisterBulkMessageRoutes registers routes for the /bulk-messages prefix
1196+
func (container *Container) RegisterBulkMessageRoutes() {
1197+
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.BulkMessageHandler{}))
1198+
container.BulkMessageHandler().RegisterRoutes(container.AuthRouter())
1199+
}
1200+
11721201
// RegisterMessageThreadRoutes registers routes for the /message-threads prefix
11731202
func (container *Container) RegisterMessageThreadRoutes() {
11741203
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.MessageThreadHandler{}))
@@ -1223,7 +1252,6 @@ func (container *Container) HeartbeatRepository() repositories.HeartbeatReposito
12231252
return repositories.NewGormHeartbeatRepository(
12241253
container.Logger(),
12251254
container.Tracer(),
1226-
container.DB(),
12271255
container.YugaByteDB(),
12281256
)
12291257
}

api/pkg/entities/billing_usage.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,8 @@ type BillingUsage struct {
2323
func (usage *BillingUsage) TotalMessages() uint {
2424
return usage.SentMessages + usage.ReceivedMessages
2525
}
26+
27+
// IsEntitled checks if a user can send `count` messages
28+
func (usage *BillingUsage) IsEntitled(count, limit uint) bool {
29+
return (usage.TotalMessages() + count) < limit
30+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package handlers
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
7+
"github.com/NdoleStudio/httpsms/pkg/requests"
8+
"github.com/google/uuid"
9+
10+
"github.com/NdoleStudio/httpsms/pkg/services"
11+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
12+
"github.com/NdoleStudio/httpsms/pkg/validators"
13+
"github.com/davecgh/go-spew/spew"
14+
"github.com/gofiber/fiber/v2"
15+
"github.com/palantir/stacktrace"
16+
)
17+
18+
// BulkMessageHandler handles bulk SMS http requests
19+
type BulkMessageHandler struct {
20+
handler
21+
logger telemetry.Logger
22+
tracer telemetry.Tracer
23+
validator *validators.BulkMessageHandlerValidator
24+
messageService *services.MessageService
25+
billingService *services.BillingService
26+
}
27+
28+
// NewBulkMessageHandler creates a new BulkMessageHandler
29+
func NewBulkMessageHandler(
30+
logger telemetry.Logger,
31+
tracer telemetry.Tracer,
32+
validator *validators.BulkMessageHandlerValidator,
33+
billingService *services.BillingService,
34+
messageService *services.MessageService,
35+
) (h *BulkMessageHandler) {
36+
return &BulkMessageHandler{
37+
logger: logger.WithService(fmt.Sprintf("%T", h)),
38+
tracer: tracer,
39+
validator: validator,
40+
messageService: messageService,
41+
billingService: billingService,
42+
}
43+
}
44+
45+
// RegisterRoutes registers the routes for the MessageHandler
46+
func (h *BulkMessageHandler) RegisterRoutes(router fiber.Router) {
47+
router.Post("/bulk-messages", h.Store)
48+
}
49+
50+
// Store sends bulk SMS messages from a CSV file.
51+
// @Summary Store bulk SMS file
52+
// @Description Sends bulk SMS messages to multiple users from a CSV file.
53+
// @Security ApiKeyAuth
54+
// @Tags BulkSMS
55+
// @Accept json
56+
// @Produce json
57+
// @Success 202 {object} responses.NoContent
58+
// @Failure 400 {object} responses.BadRequest
59+
// @Failure 401 {object} responses.Unauthorized
60+
// @Failure 422 {object} responses.UnprocessableEntity
61+
// @Failure 500 {object} responses.InternalServerError
62+
// @Router /bulk-messages [post]
63+
func (h *BulkMessageHandler) Store(c *fiber.Ctx) error {
64+
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
65+
defer span.End()
66+
67+
file, err := c.FormFile("document")
68+
if err != nil {
69+
msg := fmt.Sprintf("cannot fetch file with name [%s] from request", "document")
70+
ctxLogger.Warn(stacktrace.Propagate(err, msg))
71+
return h.responseBadRequest(c, err)
72+
}
73+
74+
messages, validationErrors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), file)
75+
if len(validationErrors) != 0 {
76+
msg := fmt.Sprintf("validation errors [%s], while sending bulk sms from CSV file [%s] for [%s]", spew.Sdump(validationErrors), file.Filename, h.userIDFomContext(c))
77+
ctxLogger.Warn(stacktrace.NewError(msg))
78+
return h.responseUnprocessableEntity(c, validationErrors, "validation errors while sending bulk SMS")
79+
}
80+
81+
if msg := h.billingService.IsEntitledWithCount(ctx, h.userIDFomContext(c), uint(len(messages))); msg != nil {
82+
ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] is not entitled to send [%d] messages", h.userIDFomContext(c), len(messages))))
83+
return h.responsePaymentRequired(c, *msg)
84+
}
85+
86+
requestID := uuid.New()
87+
wg := sync.WaitGroup{}
88+
for _, message := range messages {
89+
wg.Add(1)
90+
go func(message *requests.BulkMessage) {
91+
_, err = h.messageService.SendMessage(
92+
ctx,
93+
message.ToMessageSendParams(h.userIDFomContext(c), requestID, c.OriginalURL()),
94+
)
95+
96+
if err != nil {
97+
msg := fmt.Sprintf("cannot send message with paylod [%s]", c.Body())
98+
ctxLogger.Error(stacktrace.Propagate(err, msg))
99+
}
100+
wg.Done()
101+
}(message)
102+
}
103+
104+
wg.Wait()
105+
return h.responseAccepted(c, fmt.Sprintf("Added [%d] messages added to queue", len(messages)))
106+
}

api/pkg/handlers/handler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ func (h *handler) responseNoContent(c *fiber.Ctx, message string) error {
7171
})
7272
}
7373

74+
func (h *handler) responseAccepted(c *fiber.Ctx, message string) error {
75+
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
76+
"status": "success",
77+
"message": message,
78+
})
79+
}
80+
7481
func (h *handler) responseOK(c *fiber.Ctx, message string, data interface{}) error {
7582
return c.Status(fiber.StatusOK).JSON(fiber.Map{
7683
"status": "success",

api/pkg/repositories/gorm_heartbeat_repository.go

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,21 @@ import (
1414

1515
// gormHeartbeatRepository is responsible for persisting entities.Heartbeat
1616
type gormHeartbeatRepository struct {
17-
logger telemetry.Logger
18-
tracer telemetry.Tracer
19-
db *gorm.DB
20-
yugaByteDB *gorm.DB
17+
logger telemetry.Logger
18+
tracer telemetry.Tracer
19+
db *gorm.DB
2120
}
2221

2322
// NewGormHeartbeatRepository creates the GORM version of the HeartbeatRepository
2423
func NewGormHeartbeatRepository(
2524
logger telemetry.Logger,
2625
tracer telemetry.Tracer,
2726
db *gorm.DB,
28-
yugaByteDB *gorm.DB,
2927
) HeartbeatRepository {
3028
return &gormHeartbeatRepository{
31-
logger: logger.WithService(fmt.Sprintf("%T", &gormHeartbeatRepository{})),
32-
tracer: tracer,
33-
yugaByteDB: yugaByteDB,
34-
db: db,
29+
logger: logger.WithService(fmt.Sprintf("%T", &gormHeartbeatRepository{})),
30+
tracer: tracer,
31+
db: db,
3532
}
3633
}
3734

@@ -88,10 +85,5 @@ func (repository *gormHeartbeatRepository) Store(ctx context.Context, heartbeat
8885
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
8986
}
9087

91-
if err := repository.yugaByteDB.WithContext(ctx).Create(heartbeat).Error; err != nil {
92-
msg := fmt.Sprintf("cannot save heartbeat with ID [%s] in yugaByteDB", heartbeat.ID)
93-
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
94-
}
95-
9688
return nil
9789
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package requests
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/NdoleStudio/httpsms/pkg/entities"
8+
"github.com/NdoleStudio/httpsms/pkg/services"
9+
"github.com/google/uuid"
10+
"github.com/nyaruka/phonenumbers"
11+
)
12+
13+
// BulkMessage represents a single message in a bulk SMS request
14+
type BulkMessage struct {
15+
request
16+
FromPhoneNumber string `csv:"FromPhoneNumber"`
17+
ToPhoneNumber string `csv:"ToPhoneNumber"`
18+
Content string `csv:"Content"`
19+
}
20+
21+
// ToMessageSendParams converts BulkMessage to services.MessageSendParams
22+
func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string) services.MessageSendParams {
23+
from, _ := phonenumbers.Parse(input.FromPhoneNumber, phonenumbers.UNKNOWN_REGION)
24+
return services.MessageSendParams{
25+
Source: source,
26+
Owner: from,
27+
RequestID: input.sanitizeStringPointer(fmt.Sprintf("bulk-%s", requestID.String())),
28+
UserID: userID,
29+
RequestReceivedAt: time.Now().UTC(),
30+
Contact: input.sanitizeAddress(input.ToPhoneNumber),
31+
Content: input.Content,
32+
}
33+
}

api/pkg/requests/integration_3cx_message_request.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func (input *Integration3CXMessage) ToMessageSendParams(userID entities.UserID,
3030
from, _ := phonenumbers.Parse(input.From, phonenumbers.UNKNOWN_REGION)
3131
return services.MessageSendParams{
3232
Source: source,
33-
Owner: *from,
33+
Owner: from,
3434
RequestID: input.sanitizeStringPointer("integration-3cx"),
3535
UserID: userID,
3636
RequestReceivedAt: time.Now().UTC(),

api/pkg/requests/message_bulk_send_request.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ func (input *MessageBulkSend) Sanitize() MessageBulkSend {
3535
// ToMessageSendParams converts MessageSend to services.MessageSendParams
3636
func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source string) []services.MessageSendParams {
3737
from, _ := phonenumbers.Parse(input.From, phonenumbers.UNKNOWN_REGION)
38+
3839
var result []services.MessageSendParams
3940
for _, to := range input.To {
4041
result = append(result, services.MessageSendParams{
4142
Source: source,
42-
Owner: *from,
43+
Owner: from,
4344
RequestID: input.sanitizeStringPointer(input.RequestID),
4445
UserID: userID,
4546
RequestReceivedAt: time.Now().UTC(),

0 commit comments

Comments
 (0)