validators.*BulkMessageHandlerValidator.parseXlsx   C
last analyzed

Complexity

Conditions 11

Size

Total Lines 45
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 31
dl 0
loc 45
rs 5.4
c 0
b 0
f 0
nop 3

How to fix   Complexity   

Complexity

Complex classes like validators.*BulkMessageHandlerValidator.parseXlsx often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
package validators
2
3
import (
4
	"bytes"
5
	"context"
6
	"fmt"
7
	"io"
8
	"mime/multipart"
9
	"net/url"
10
	"strings"
11
	"time"
12
13
	"github.com/xuri/excelize/v2"
14
15
	"github.com/NdoleStudio/httpsms/pkg/entities"
16
	"github.com/NdoleStudio/httpsms/pkg/repositories"
17
	"github.com/NdoleStudio/httpsms/pkg/requests"
18
	"github.com/NdoleStudio/httpsms/pkg/services"
19
	"github.com/NdoleStudio/httpsms/pkg/telemetry"
20
	"github.com/dustin/go-humanize"
21
	"github.com/jszwec/csvutil"
22
	"github.com/nyaruka/phonenumbers"
23
	"github.com/palantir/stacktrace"
24
)
25
26
// BulkMessageHandlerValidator validates models used in handlers.BillingHandler
27
type BulkMessageHandlerValidator struct {
28
	validator
29
	phoneService *services.PhoneService
30
	userService  *services.UserService
31
	logger       telemetry.Logger
32
	tracer       telemetry.Tracer
33
}
34
35
// NewBulkMessageHandlerValidator creates a new handlers.BulkMessageHandlerValidator validator
36
func NewBulkMessageHandlerValidator(
37
	logger telemetry.Logger,
38
	tracer telemetry.Tracer,
39
	phoneService *services.PhoneService,
40
	userService *services.UserService,
41
) (v *BulkMessageHandlerValidator) {
42
	return &BulkMessageHandlerValidator{
43
		logger:       logger.WithService(fmt.Sprintf("%T", v)),
44
		tracer:       tracer,
45
		userService:  userService,
46
		phoneService: phoneService,
47
	}
48
}
49
50
// ValidateStore validates the requests.BillingUsageHistory request
51
func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID entities.UserID, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
52
	ctx, span, ctxLogger := v.tracer.StartWithLogger(ctx, v.logger)
53
	defer span.End()
54
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)
64
	if len(result) != 0 {
65
		return messages, result
66
	}
67
68
	if len(messages) == 0 {
69
		result.Add("document", "The uploaded file doesn't contain any valid records. Make sure you are using the official httpSMS template.")
70
		return messages, result
71
	}
72
73
	if len(messages) > 1000 {
74
		result.Add("document", "The uploaded file must contain less than 1000 records.")
75
		return messages, result
76
	}
77
78
	for index, message := range messages {
79
		messages[index] = message.Sanitize()
80
	}
81
82
	result = v.validateMessages(messages)
83
	if len(result) != 0 {
84
		return messages, result
85
	}
86
87
	result = v.validateOwners(ctx, userID, messages)
88
	if len(result) != 0 {
89
		return messages, result
90
	}
91
92
	return messages, result
93
}
94
95
func (v *BulkMessageHandlerValidator) parseFile(ctxLogger telemetry.Logger, user *entities.User, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
96
	if header.Header.Get("Content-Type") == "text/csv" || strings.HasSuffix(header.Filename, ".csv") {
97
		return v.parseCSV(ctxLogger, user, header)
98
	}
99
	if header.Header.Get("Content-Type") == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || strings.HasSuffix(header.Filename, ".xlsx") {
100
		return v.parseXlsx(ctxLogger, user, header)
101
	}
102
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"))))
104
105
	result := url.Values{}
106
	result.Add("document", fmt.Sprintf("The file [%s] is not a valid CSV or Excel file.", header.Filename))
107
	return nil, result
108
}
109
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)
112
	if len(result) != 0 {
113
		return nil, result
114
	}
115
116
	excel, err := excelize.OpenReader(bytes.NewReader(content))
117
	if err != nil {
118
		ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot generate excel file from [%s] for user [%s]", header.Filename, user.ID)))
119
		result.Add("document", fmt.Sprintf("Cannot parse the uploaded excel file with name [%s].", header.Filename))
120
		return nil, result
121
	}
122
123
	rows, err := excel.GetRows(excel.GetSheetName(0))
124
	if err != nil {
125
		ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot get rows from excel file [%s] for user [%s]", header.Filename, user.ID)))
126
		result.Add("document", fmt.Sprintf("Cannot parse the uploaded excel file with name [%s].", header.Filename))
127
		return nil, result
128
	}
129
130
	var messages []*requests.BulkMessage
131
	for index, row := range rows {
132
		if len(row) < 3 || strings.TrimSpace(row[0]) == "" || index == 0 {
133
			continue
134
		}
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
146
		messages = append(messages, &requests.BulkMessage{
147
			FromPhoneNumber: strings.TrimSpace(row[0]),
148
			ToPhoneNumber:   strings.TrimSpace(row[1]),
149
			Content:         row[2],
150
			SendTime:        sendAt,
151
		})
152
	}
153
154
	return messages, url.Values{}
155
}
156
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
166
func (v *BulkMessageHandlerValidator) parseBytes(ctxLogger telemetry.Logger, userID entities.UserID, header *multipart.FileHeader) ([]byte, url.Values) {
167
	result := url.Values{}
168
169
	if header.Size >= 5000000 {
170
		result.Add("document", fmt.Sprintf("The CSV file must be less than 500 KB the file you uploaded is [%s].", humanize.Bytes(uint64(header.Size))))
171
		return nil, result
172
	}
173
174
	file, err := header.Open()
175
	if err != nil {
176
		ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot open file [%s] for reading for user [%s]", header.Filename, userID)))
177
		result.Add("document", fmt.Sprintf("Cannot open the uploaded file with name [%s].", header.Filename))
178
		return nil, result
179
	}
180
	defer func() {
181
		if e := file.Close(); e != nil {
182
			ctxLogger.Error(stacktrace.Propagate(e, fmt.Sprintf("cannot close file [%s] for user [%s]", header.Filename, userID)))
183
		}
184
	}()
185
186
	b := new(bytes.Buffer)
187
	if _, err = io.Copy(b, file); err != nil {
188
		ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot copy file [%s] to buffer for user [%s]", header.Filename, userID)))
189
		result.Add("document", fmt.Sprintf("Cannot read the conents of the uploaded file [%s].", header.Filename))
190
		return nil, result
191
	}
192
193
	return b.Bytes(), result
194
}
195
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)
198
	if len(result) != 0 {
199
		return nil, result
200
	}
201
202
	var messages []*requests.BulkMessage
203
	if err := csvutil.Unmarshal(content, &messages); err != nil {
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)))
205
		result.Add("document", fmt.Sprintf("Cannot read the conents of the uploaded file [%s].", header.Filename))
206
		return nil, result
207
	}
208
209
	return messages, url.Values{}
210
}
211
212
func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.BulkMessage) url.Values {
213
	result := url.Values{}
214
	for index, message := range messages {
215
		if _, err := phonenumbers.Parse(message.FromPhoneNumber, phonenumbers.UNKNOWN_REGION); err != nil {
216
			result.Add("document", fmt.Sprintf("Row [%d]: The FromPhoneNumber [%s] is not a valid E.164 phone number", index+2, message.FromPhoneNumber))
217
		}
218
219
		if _, err := phonenumbers.Parse(message.ToPhoneNumber, phonenumbers.UNKNOWN_REGION); err != nil {
220
			result.Add("document", fmt.Sprintf("Row [%d]: The ToPhoneNumber [%s] is not a valid E.164 phone number", index+2, message.ToPhoneNumber))
221
		}
222
223
		if len(message.Content) > 1024 {
224
			result.Add("document", fmt.Sprintf("Row [%d]: The message content must be less than 1024 characters.", index+2))
225
		}
226
227
		if message.SendTime != nil && message.SendTime.After(time.Now().Add(24*time.Hour)) {
228
			result.Add("document", fmt.Sprintf("Row [%d]: The SendTime [%s] cannot be more than 24 hours in the future.", index+2, message.SendTime.Format(time.RFC3339)))
229
		}
230
	}
231
	return result
232
}
233
234
func (v *BulkMessageHandlerValidator) validateOwners(ctx context.Context, userID entities.UserID, messages []*requests.BulkMessage) url.Values {
235
	numbers := map[string][]int{}
236
	for index, message := range messages {
237
		numbers[message.FromPhoneNumber] = append(numbers[message.FromPhoneNumber], index+2)
238
	}
239
240
	result := url.Values{}
241
	for number, rows := range numbers {
242
		_, err := v.phoneService.Load(ctx, userID, strings.TrimSpace(number))
243
		if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
244
			result.Add("document", fmt.Sprintf("Rows [%s]: The FromPhoneNumber [%s] is not registered on your account", v.toString(rows), number))
245
		}
246
	}
247
	return result
248
}
249
250
func (v *BulkMessageHandlerValidator) toString(value []int) string {
251
	result := strings.Builder{}
252
	for index, row := range value {
253
		if index != 0 {
254
			result.WriteString(", ")
255
		}
256
		result.WriteString(fmt.Sprintf("%d", row))
257
	}
258
	return result.String()
259
}
260