Skip to content

Commit 67e9797

Browse files
committed
Add ability to schedule a message to be sent in the future
1 parent bdf02ac commit 67e9797

9 files changed

Lines changed: 99 additions & 17 deletions

File tree

api/pkg/di/container.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators
464464
container.Logger(),
465465
container.Tracer(),
466466
container.PhoneService(),
467+
container.UserService(),
467468
)
468469
}
469470

api/pkg/entities/message.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ type Message struct {
101101
LastAttemptedAt *time.Time `json:"last_attempted_at" example:"2022-06-05T14:26:09.527976+03:00"`
102102
NotificationScheduledAt *time.Time `json:"scheduled_at" example:"2022-06-05T14:26:09.527976+03:00"`
103103
SentAt *time.Time `json:"sent_at" example:"2022-06-05T14:26:09.527976+03:00"`
104+
ScheduledSendTime *time.Time `json:"scheduled_send_time" example:"2022-06-05T14:26:09.527976+03:00"`
104105
DeliveredAt *time.Time `json:"delivered_at" example:"2022-06-05T14:26:09.527976+03:00"`
105106
ExpiredAt *time.Time `json:"expired_at" example:"2022-06-05T14:26:09.527976+03:00"`
106107
FailedAt *time.Time `json:"failed_at" example:"2022-06-05T14:26:09.527976+03:00"`

api/pkg/entities/user.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,12 @@ func (user User) UserTimeString(timestamp time.Time) string {
9292
}
9393
return timestamp.In(location).Format(time.RFC1123)
9494
}
95+
96+
// Location gets the timezone of a user
97+
func (user User) Location() *time.Location {
98+
location, err := time.LoadLocation(user.Timezone)
99+
if err != nil {
100+
location = time.UTC
101+
}
102+
return location
103+
}

api/pkg/events/message_api_sent_event.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type MessageAPISentPayload struct {
1919
RequestID *string `json:"request_id"`
2020
MaxSendAttempts uint `json:"max_send_attempts"`
2121
Contact string `json:"contact"`
22+
ScheduledSendTime *time.Time `json:"scheduled_send_time"`
2223
RequestReceivedAt time.Time `json:"request_received_at"`
2324
Content string `json:"content"`
2425
SIM entities.SIM `json:"sim"`

api/pkg/requests/bulk_message_request.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import (
1414
// BulkMessage represents a single message in a bulk SMS request
1515
type BulkMessage struct {
1616
request
17-
FromPhoneNumber string `csv:"FromPhoneNumber"`
18-
ToPhoneNumber string `csv:"ToPhoneNumber"`
19-
Content string `csv:"Content"`
17+
FromPhoneNumber string `csv:"FromPhoneNumber"`
18+
ToPhoneNumber string `csv:"ToPhoneNumber"`
19+
Content string `csv:"Content"`
20+
SendTime *time.Time `csv:"SendTime(optional)"`
2021
}
2122

2223
// Sanitize sets defaults to BulkMessage
@@ -35,6 +36,7 @@ func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID
3536
Owner: from,
3637
RequestID: input.sanitizeStringPointer(fmt.Sprintf("bulk-%s", requestID.String())),
3738
UserID: userID,
39+
SendAt: input.SendTime,
3840
RequestReceivedAt: time.Now().UTC(),
3941
Contact: input.sanitizeAddress(input.ToPhoneNumber),
4042
Content: input.Content,

api/pkg/requests/message_send_request.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type MessageSend struct {
2020

2121
// RequestID is an optional parameter used to track a request from the client's perspective
2222
RequestID string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"`
23+
// SendAt is an optional parameter used to schedule a message to be sent at a later time
24+
SendAt *time.Time `json:"send_at" example:"2022-06-05T14:26:09.527976+03:00" validate:"optional"`
2325
}
2426

2527
// Sanitize sets defaults to MessageReceive
@@ -38,6 +40,7 @@ func (input *MessageSend) ToMessageSendParams(userID entities.UserID, source str
3840
Owner: from,
3941
RequestID: input.sanitizeStringPointer(input.RequestID),
4042
UserID: userID,
43+
SendAt: input.SendAt,
4144
RequestReceivedAt: time.Now().UTC(),
4245
Contact: input.sanitizeAddress(input.To),
4346
Content: input.Content,

api/pkg/services/message_service.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ type MessageSendParams struct {
303303
Contact string
304304
Content string
305305
Source string
306+
SendAt *time.Time
306307
RequestID *string
307308
UserID entities.UserID
308309
RequestReceivedAt time.Time
@@ -326,6 +327,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe
326327
Contact: params.Contact,
327328
RequestReceivedAt: params.RequestReceivedAt,
328329
Content: params.Content,
330+
ScheduledSendTime: params.SendAt,
329331
SIM: sim,
330332
}
331333

@@ -336,15 +338,30 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe
336338
}
337339
ctxLogger.Info(fmt.Sprintf("created event [%s] with id [%s] and message id [%s] and user [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID))
338340

339-
if err = service.eventDispatcher.Dispatch(ctx, event); err != nil {
341+
timeout := service.getSendDelay(ctxLogger, eventPayload, params.SendAt)
342+
if _, err = service.eventDispatcher.DispatchWithTimeout(ctx, event, timeout); err != nil {
340343
msg := fmt.Sprintf("cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())
341344
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
342345
}
343-
ctxLogger.Info(fmt.Sprintf("[%s] event with ID [%s] dispatched succesfully for message [%s] and user [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID))
344346

347+
ctxLogger.Info(fmt.Sprintf("[%s] event with ID [%s] dispatched succesfully for message [%s] with user [%s] and delay [%s]", event.Type(), event.ID(), eventPayload.MessageID, eventPayload.UserID, timeout))
345348
return service.storeSentMessage(ctx, eventPayload)
346349
}
347350

351+
func (service *MessageService) getSendDelay(ctxLogger telemetry.Logger, eventPayload events.MessageAPISentPayload, sendAt *time.Time) time.Duration {
352+
if sendAt == nil {
353+
return time.Duration(0)
354+
}
355+
356+
delay := sendAt.Sub(time.Now().UTC())
357+
if delay < 0 {
358+
ctxLogger.Info(fmt.Sprintf("message [%s] has send time [%s] in the past. sending immediately", eventPayload.MessageID, sendAt.String()))
359+
return time.Duration(0)
360+
}
361+
362+
return delay
363+
}
364+
348365
// StoreReceivedMessage a new message
349366
func (service *MessageService) storeReceivedMessage(ctx context.Context, params events.MessagePhoneReceivedPayload) (*entities.Message, error) {
350367
ctx, span := service.tracer.Start(ctx)
@@ -725,6 +742,7 @@ func (service *MessageService) storeSentMessage(ctx context.Context, payload eve
725742
Content: payload.Content,
726743
RequestID: payload.RequestID,
727744
SIM: payload.SIM,
745+
ScheduledSendTime: payload.ScheduledSendTime,
728746
Type: entities.MessageTypeMobileTerminated,
729747
Status: entities.MessageStatusPending,
730748
RequestReceivedAt: payload.RequestReceivedAt,

api/pkg/services/user_service.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ func (service *UserService) Get(ctx context.Context, authUser entities.AuthUser)
6666
return user, nil
6767
}
6868

69+
// GetByID fetches an entities.User
70+
func (service *UserService) GetByID(ctx context.Context, userID entities.UserID) (*entities.User, error) {
71+
ctx, span, _ := service.tracer.StartWithLogger(ctx, service.logger)
72+
defer span.End()
73+
74+
user, err := service.repository.Load(ctx, userID)
75+
if err != nil {
76+
msg := fmt.Sprintf("could not get [%T] with ID [%s]", user, userID)
77+
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
78+
}
79+
80+
return user, nil
81+
}
82+
6983
// UserUpdateParams are parameters for updating an entities.User
7084
type UserUpdateParams struct {
7185
Timezone *time.Location

api/pkg/validators/bulk_message_handler_validator.go

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"mime/multipart"
99
"net/url"
1010
"strings"
11+
"time"
1112

1213
"github.com/xuri/excelize/v2"
1314

@@ -26,6 +27,7 @@ import (
2627
type BulkMessageHandlerValidator struct {
2728
validator
2829
phoneService *services.PhoneService
30+
userService *services.UserService
2931
logger telemetry.Logger
3032
tracer telemetry.Tracer
3133
}
@@ -35,10 +37,12 @@ func NewBulkMessageHandlerValidator(
3537
logger telemetry.Logger,
3638
tracer telemetry.Tracer,
3739
phoneService *services.PhoneService,
40+
userService *services.UserService,
3841
) (v *BulkMessageHandlerValidator) {
3942
return &BulkMessageHandlerValidator{
4043
logger: logger.WithService(fmt.Sprintf("%T", v)),
4144
tracer: tracer,
45+
userService: userService,
4246
phoneService: phoneService,
4347
}
4448
}
@@ -48,7 +52,15 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID
4852
ctx, span, ctxLogger := v.tracer.StartWithLogger(ctx, v.logger)
4953
defer span.End()
5054

51-
messages, result := v.parseFile(ctxLogger, userID, header)
55+
user, err := v.userService.GetByID(ctx, userID)
56+
if err != nil {
57+
result := url.Values{}
58+
result.Add("document", "Cannot load your account. Please try again later or contact support.")
59+
ctxLogger.Error(v.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s]", userID))))
60+
return nil, result
61+
}
62+
63+
messages, result := v.parseFile(ctxLogger, user, header)
5264
if len(result) != 0 {
5365
return messages, result
5466
}
@@ -80,37 +92,37 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID
8092
return messages, result
8193
}
8294

83-
func (v *BulkMessageHandlerValidator) parseFile(ctxLogger telemetry.Logger, userID entities.UserID, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
95+
func (v *BulkMessageHandlerValidator) parseFile(ctxLogger telemetry.Logger, user *entities.User, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
8496
if header.Header.Get("Content-Type") == "text/csv" || strings.HasSuffix(header.Filename, ".csv") {
85-
return v.parseCSV(ctxLogger, userID, header)
97+
return v.parseCSV(ctxLogger, user, header)
8698
}
8799
if header.Header.Get("Content-Type") == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || strings.HasSuffix(header.Filename, ".xlsx") {
88-
return v.parseXlsx(ctxLogger, userID, header)
100+
return v.parseXlsx(ctxLogger, user, header)
89101
}
90102

91-
ctxLogger.Error(stacktrace.NewError(fmt.Sprintf("cannot parse file [%s] for user [%s] with content type [%s]", header.Filename, userID, header.Header.Get("Content-Type"))))
103+
ctxLogger.Error(stacktrace.NewError(fmt.Sprintf("cannot parse file [%s] for user [%s] with content type [%s]", header.Filename, user.ID, header.Header.Get("Content-Type"))))
92104

93105
result := url.Values{}
94106
result.Add("document", fmt.Sprintf("The file [%s] is not a valid CSV or Excel file.", header.Filename))
95107
return nil, result
96108
}
97109

98-
func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, userID entities.UserID, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
99-
content, result := v.parseBytes(ctxLogger, userID, header)
110+
func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user *entities.User, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
111+
content, result := v.parseBytes(ctxLogger, user.ID, header)
100112
if len(result) != 0 {
101113
return nil, result
102114
}
103115

104116
excel, err := excelize.OpenReader(bytes.NewReader(content))
105117
if err != nil {
106-
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot generate excel file from [%s] for user [%s]", header.Filename, userID)))
118+
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot generate excel file from [%s] for user [%s]", header.Filename, user.ID)))
107119
result.Add("document", fmt.Sprintf("Cannot parse the uploaded excel file with name [%s].", header.Filename))
108120
return nil, result
109121
}
110122

111123
rows, err := excel.GetRows(excel.GetSheetName(0))
112124
if err != nil {
113-
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot get rows from excel file [%s] for user [%s]", header.Filename, userID)))
125+
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot get rows from excel file [%s] for user [%s]", header.Filename, user.ID)))
114126
result.Add("document", fmt.Sprintf("Cannot parse the uploaded excel file with name [%s].", header.Filename))
115127
return nil, result
116128
}
@@ -120,16 +132,37 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user
120132
if len(row) < 3 || strings.TrimSpace(row[0]) == "" || index == 0 {
121133
continue
122134
}
135+
136+
var sendAt *time.Time
137+
if len(row) > 3 && strings.TrimSpace(row[3]) != "" {
138+
ctxLogger.Info(fmt.Sprintf("excel time = [%s]", row[3]))
139+
sendAt, err = v.convertExcelTime(user, row[3])
140+
if err != nil {
141+
result.Add("document", fmt.Sprintf("Row [%d]: The SendTime [%s] is not in the correct format e.g [2006-01-02T15:04:05] where 2006 is the year, 01 is January, 02 is the second day of the month and the time is 15:04:05", index+1, row[3]))
142+
return nil, result
143+
}
144+
}
145+
123146
messages = append(messages, &requests.BulkMessage{
124147
FromPhoneNumber: strings.TrimSpace(row[0]),
125148
ToPhoneNumber: strings.TrimSpace(row[1]),
126149
Content: row[2],
150+
SendTime: sendAt,
127151
})
128152
}
129153

130154
return messages, nil
131155
}
132156

157+
func (v *BulkMessageHandlerValidator) convertExcelTime(user *entities.User, value string) (*time.Time, error) {
158+
t, err := time.ParseInLocation("2006-01-02T15:04:05", value, user.Location())
159+
if err != nil {
160+
return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot parse excel time [%s] as [%T]", value, t))
161+
}
162+
163+
return &t, nil
164+
}
165+
133166
func (v *BulkMessageHandlerValidator) parseBytes(ctxLogger telemetry.Logger, userID entities.UserID, header *multipart.FileHeader) ([]byte, url.Values) {
134167
result := url.Values{}
135168

@@ -160,15 +193,15 @@ func (v *BulkMessageHandlerValidator) parseBytes(ctxLogger telemetry.Logger, use
160193
return b.Bytes(), result
161194
}
162195

163-
func (v *BulkMessageHandlerValidator) parseCSV(ctxLogger telemetry.Logger, userID entities.UserID, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
164-
content, result := v.parseBytes(ctxLogger, userID, header)
196+
func (v *BulkMessageHandlerValidator) parseCSV(ctxLogger telemetry.Logger, user *entities.User, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
197+
content, result := v.parseBytes(ctxLogger, user.ID, header)
165198
if len(result) != 0 {
166199
return nil, result
167200
}
168201

169202
var messages []*requests.BulkMessage
170203
if err := csvutil.Unmarshal(content, &messages); err != nil {
171-
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot unmarshall contents [%s] into type [%T] for file [%s] and user [%s]", content, messages, header.Filename, userID)))
204+
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot unmarshall contents [%s] into type [%T] for file [%s] and user [%s]", content, messages, header.Filename, user.ID)))
172205
result.Add("document", fmt.Sprintf("Cannot read the conents of the uploaded file [%s].", header.Filename))
173206
return nil, result
174207
}

0 commit comments

Comments
 (0)