Skip to content

Commit f80acce

Browse files
committed
feat: Refactor send schedules based on review feedback
1 parent 521014b commit f80acce

22 files changed

Lines changed: 8145 additions & 5197 deletions

api/docs/docs.go

Lines changed: 570 additions & 7 deletions
Large diffs are not rendered by default.

api/docs/swagger.json

Lines changed: 5055 additions & 4060 deletions
Large diffs are not rendered by default.

api/docs/swagger.yaml

Lines changed: 1456 additions & 1116 deletions
Large diffs are not rendered by default.

api/pkg/di/container.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func NewContainer(projectID string, version string) (container *Container) {
127127
container.RegisterHeartbeatListeners()
128128

129129
container.RegisterUserRoutes()
130+
container.RegisterSendScheduleRoutes()
130131
container.RegisterUserListeners()
131132

132133
container.RegisterPhoneRoutes()
@@ -744,6 +745,46 @@ func (container *Container) PhoneRepository() (repository repositories.PhoneRepo
744745
)
745746
}
746747

748+
// SendScheduleRepository creates a new instance of repositories.SendScheduleRepository
749+
func (container *Container) SendScheduleRepository() repositories.SendScheduleRepository {
750+
container.logger.Debug("creating GORM repositories.SendScheduleRepository")
751+
return repositories.NewGormSendScheduleRepository(
752+
container.Logger(),
753+
container.Tracer(),
754+
container.DB(),
755+
)
756+
}
757+
758+
// SendScheduleService creates a new instance of services.SendScheduleService
759+
func (container *Container) SendScheduleService() *services.SendScheduleService {
760+
container.logger.Debug("creating services.SendScheduleService")
761+
return services.NewSendScheduleService(
762+
container.Logger(),
763+
container.Tracer(),
764+
container.SendScheduleRepository(),
765+
)
766+
}
767+
768+
// SendScheduleHandlerValidator creates a new instance of validators.SendScheduleHandlerValidator
769+
func (container *Container) SendScheduleHandlerValidator() *validators.SendScheduleHandlerValidator {
770+
container.logger.Debug("creating validators.SendScheduleHandlerValidator")
771+
return validators.NewSendScheduleHandlerValidator(
772+
container.Logger(),
773+
container.Tracer(),
774+
)
775+
}
776+
777+
// SendScheduleHandler creates a new instance of handlers.SendScheduleHandler
778+
func (container *Container) SendScheduleHandler() *handlers.SendScheduleHandler {
779+
container.logger.Debug("creating handlers.SendScheduleHandler")
780+
return handlers.NewSendScheduleHandler(
781+
container.Logger(),
782+
container.Tracer(),
783+
container.SendScheduleHandlerValidator(),
784+
container.SendScheduleService(),
785+
)
786+
}
787+
747788
// BillingUsageRepository creates a new instance of repositories.BillingUsageRepository
748789
func (container *Container) BillingUsageRepository() (repository repositories.BillingUsageRepository) {
749790
container.logger.Debug("creating GORM repositories.BillingUsageRepository")
@@ -1447,6 +1488,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
14471488
container.FirebaseMessagingClient(),
14481489
container.PhoneRepository(),
14491490
container.PhoneNotificationRepository(),
1491+
container.SendScheduleRepository(),
14501492
container.EventDispatcher(),
14511493
)
14521494
}
@@ -1502,6 +1544,12 @@ func (container *Container) RegisterUserRoutes() {
15021544
container.UserHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
15031545
}
15041546

1547+
// RegisterSendScheduleRoutes registers routes for the /send-schedules prefix
1548+
func (container *Container) RegisterSendScheduleRoutes() {
1549+
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.SendScheduleHandler{}))
1550+
container.SendScheduleHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
1551+
}
1552+
15051553
// RegisterEventRoutes registers routes for the /events prefix
15061554
func (container *Container) RegisterEventRoutes() {
15071555
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.EventsHandler{}))

api/pkg/entities/phone.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import (
88

99
// Phone represents an android phone which has installed the http sms app
1010
type Phone struct {
11-
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
12-
UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
13-
FcmToken *string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." validate:"optional"`
14-
PhoneNumber string `json:"phone_number" example:"+18005550199"`
15-
MessagesPerMinute uint `json:"messages_per_minute" example:"1"`
16-
SIM SIM `json:"sim" gorm:"default:SIM1"`
11+
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
12+
UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
13+
FcmToken *string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." validate:"optional"`
14+
PhoneNumber string `json:"phone_number" example:"+18005550199"`
15+
MessagesPerMinute uint `json:"messages_per_minute" example:"1"`
16+
SIM SIM `json:"sim" gorm:"default:SIM1"`
17+
ScheduleID *uuid.UUID `json:"schedule_id" gorm:"type:uuid" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
18+
Schedule *SendSchedule `json:"-" gorm:"foreignKey:ScheduleID;constraint:OnDelete:SET NULL"`
1719
// MaxSendAttempts determines how many times to retry sending an SMS message
1820
MaxSendAttempts uint `json:"max_send_attempts" example:"2"`
1921

api/pkg/entities/send_schedule.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package entities
2+
3+
import (
4+
"time"
5+
6+
"github.com/google/uuid"
7+
)
8+
9+
// SendScheduleWindow represents a single availability window for a day of the week.
10+
type SendScheduleWindow struct {
11+
DayOfWeek int `json:"day_of_week" example:"1"`
12+
StartMinute int `json:"start_minute" example:"540"`
13+
EndMinute int `json:"end_minute" example:"1020"`
14+
}
15+
16+
// SendSchedule controls when a phone is allowed to send outgoing SMS messages.
17+
type SendSchedule struct {
18+
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
19+
UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
20+
Name string `json:"name" example:"Business Hours"`
21+
Timezone string `json:"timezone" example:"Europe/Tallinn"`
22+
IsActive bool `json:"is_active" gorm:"default:true" example:"true"`
23+
Windows []SendScheduleWindow `json:"windows" gorm:"type:jsonb;serializer:json"`
24+
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
25+
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
26+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package handlers
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/NdoleStudio/httpsms/pkg/requests"
7+
"github.com/NdoleStudio/httpsms/pkg/services"
8+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
9+
"github.com/NdoleStudio/httpsms/pkg/validators"
10+
"github.com/davecgh/go-spew/spew"
11+
"github.com/gofiber/fiber/v2"
12+
"github.com/google/uuid"
13+
"github.com/palantir/stacktrace"
14+
)
15+
16+
type SendScheduleHandler struct {
17+
handler
18+
logger telemetry.Logger
19+
tracer telemetry.Tracer
20+
validator *validators.SendScheduleHandlerValidator
21+
service *services.SendScheduleService
22+
}
23+
24+
func NewSendScheduleHandler(logger telemetry.Logger, tracer telemetry.Tracer, validator *validators.SendScheduleHandlerValidator, service *services.SendScheduleService) *SendScheduleHandler {
25+
return &SendScheduleHandler{logger: logger.WithService(fmt.Sprintf("%T", &SendScheduleHandler{})), tracer: tracer, validator: validator, service: service}
26+
}
27+
28+
func (h *SendScheduleHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) {
29+
router.Get("/v1/send-schedules", h.computeRoute(middlewares, h.Index)...)
30+
router.Post("/v1/send-schedules", h.computeRoute(middlewares, h.Store)...)
31+
router.Get("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Show)...)
32+
router.Put("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Update)...)
33+
router.Delete("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Delete)...)
34+
}
35+
36+
// Index godoc
37+
// @Summary List send schedules
38+
// @Description Lists the send schedules owned by the authenticated user.
39+
// @Security ApiKeyAuth
40+
// @Tags Send Schedules
41+
// @Produce json
42+
// @Success 200 {object} responses.SendSchedulesResponse
43+
// @Failure 401 {object} responses.Unauthorized
44+
// @Failure 500 {object} responses.InternalServerError
45+
// @Router /send-schedules [get]
46+
func (h *SendScheduleHandler) Index(c *fiber.Ctx) error {
47+
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
48+
defer span.End()
49+
schedules, err := h.service.Index(ctx, h.userIDFomContext(c))
50+
if err != nil {
51+
ctxLogger.Error(stacktrace.Propagate(err, "cannot list send schedules"))
52+
return h.responseInternalServerError(c)
53+
}
54+
return h.responseOK(c, "send schedules fetched successfully", schedules)
55+
}
56+
57+
// Show godoc
58+
// @Summary Show send schedule
59+
// @Description Loads a single send schedule owned by the authenticated user.
60+
// @Security ApiKeyAuth
61+
// @Tags Send Schedules
62+
// @Produce json
63+
// @Param scheduleID path string true "Schedule ID"
64+
// @Success 200 {object} responses.SendScheduleResponse
65+
// @Failure 401 {object} responses.Unauthorized
66+
// @Failure 404 {object} responses.NotFound
67+
// @Failure 500 {object} responses.InternalServerError
68+
// @Router /send-schedules/{scheduleID} [get]
69+
func (h *SendScheduleHandler) Show(c *fiber.Ctx) error {
70+
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
71+
defer span.End()
72+
scheduleID, err := uuid.Parse(c.Params("scheduleID"))
73+
if err != nil {
74+
return h.responseBadRequest(c, err)
75+
}
76+
schedule, err := h.service.Load(ctx, h.userIDFomContext(c), scheduleID)
77+
if err != nil {
78+
ctxLogger.Error(stacktrace.Propagate(err, "cannot load send schedule"))
79+
if stacktrace.GetCode(err) == 404 {
80+
return h.responseNotFound(c, err.Error())
81+
}
82+
return h.responseInternalServerError(c)
83+
}
84+
return h.responseOK(c, "send schedule fetched successfully", schedule)
85+
}
86+
87+
// Store godoc
88+
// @Summary Create send schedule
89+
// @Description Creates a send schedule for the authenticated user.
90+
// @Security ApiKeyAuth
91+
// @Tags Send Schedules
92+
// @Accept json
93+
// @Produce json
94+
// @Param payload body requests.SendScheduleStore true "Payload of new send schedule."
95+
// @Success 201 {object} responses.SendScheduleResponse
96+
// @Failure 400 {object} responses.BadRequest
97+
// @Failure 401 {object} responses.Unauthorized
98+
// @Failure 422 {object} responses.UnprocessableEntity
99+
// @Failure 500 {object} responses.InternalServerError
100+
// @Router /send-schedules [post]
101+
func (h *SendScheduleHandler) Store(c *fiber.Ctx) error {
102+
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
103+
defer span.End()
104+
var request requests.SendScheduleStore
105+
if err := c.BodyParser(&request); err != nil {
106+
return h.responseBadRequest(c, err)
107+
}
108+
request = request.Sanitize()
109+
if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 {
110+
ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("validation errors [%s], while storing send schedule [%+#v]", spew.Sdump(errors), request)))
111+
return h.responseUnprocessableEntity(c, errors, "validation errors while saving send schedule")
112+
}
113+
schedule, err := h.service.Store(ctx, request.ToParams(h.userFromContext(c)))
114+
if err != nil {
115+
ctxLogger.Error(stacktrace.Propagate(err, "cannot create send schedule"))
116+
return h.responseInternalServerError(c)
117+
}
118+
return h.responseCreated(c, "send schedule created successfully", schedule)
119+
}
120+
121+
// Update godoc
122+
// @Summary Update send schedule
123+
// @Description Updates a send schedule owned by the authenticated user.
124+
// @Security ApiKeyAuth
125+
// @Tags Send Schedules
126+
// @Accept json
127+
// @Produce json
128+
// @Param scheduleID path string true "Schedule ID"
129+
// @Param payload body requests.SendScheduleStore true "Payload of updated send schedule."
130+
// @Success 200 {object} responses.SendScheduleResponse
131+
// @Failure 400 {object} responses.BadRequest
132+
// @Failure 401 {object} responses.Unauthorized
133+
// @Failure 404 {object} responses.NotFound
134+
// @Failure 422 {object} responses.UnprocessableEntity
135+
// @Failure 500 {object} responses.InternalServerError
136+
// @Router /send-schedules/{scheduleID} [put]
137+
func (h *SendScheduleHandler) Update(c *fiber.Ctx) error {
138+
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
139+
defer span.End()
140+
scheduleID, err := uuid.Parse(c.Params("scheduleID"))
141+
if err != nil {
142+
return h.responseBadRequest(c, err)
143+
}
144+
var request requests.SendScheduleStore
145+
if err = c.BodyParser(&request); err != nil {
146+
return h.responseBadRequest(c, err)
147+
}
148+
request = request.Sanitize()
149+
if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 {
150+
return h.responseUnprocessableEntity(c, errors, "validation errors while updating send schedule")
151+
}
152+
schedule, err := h.service.Update(ctx, h.userIDFomContext(c), scheduleID, request.ToParams(h.userFromContext(c)))
153+
if err != nil {
154+
ctxLogger.Error(stacktrace.Propagate(err, "cannot update send schedule"))
155+
if stacktrace.GetCode(err) == 404 {
156+
return h.responseNotFound(c, err.Error())
157+
}
158+
return h.responseInternalServerError(c)
159+
}
160+
return h.responseOK(c, "send schedule updated successfully", schedule)
161+
}
162+
163+
// Delete godoc
164+
// @Summary Delete send schedule
165+
// @Description Deletes a send schedule owned by the authenticated user.
166+
// @Security ApiKeyAuth
167+
// @Tags Send Schedules
168+
// @Produce json
169+
// @Param scheduleID path string true "Schedule ID"
170+
// @Success 204 {object} responses.NoContent
171+
// @Failure 400 {object} responses.BadRequest
172+
// @Failure 401 {object} responses.Unauthorized
173+
// @Failure 500 {object} responses.InternalServerError
174+
// @Router /send-schedules/{scheduleID} [delete]
175+
func (h *SendScheduleHandler) Delete(c *fiber.Ctx) error {
176+
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
177+
defer span.End()
178+
scheduleID, err := uuid.Parse(c.Params("scheduleID"))
179+
if err != nil {
180+
return h.responseBadRequest(c, err)
181+
}
182+
if err = h.service.Delete(ctx, h.userIDFomContext(c), scheduleID); err != nil {
183+
ctxLogger.Error(stacktrace.Propagate(err, "cannot delete send schedule"))
184+
return h.responseInternalServerError(c)
185+
}
186+
return h.responseNoContent(c, "send schedule deleted successfully")
187+
}

api/pkg/repositories/gorm_phone_notification_repository.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,12 @@ func (repository *gormPhoneNotificationRepository) UpdateStatus(ctx context.Cont
6666
}
6767

6868
// Schedule a notification to be sent in the future
69-
func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, messagesPerMinute uint, notification *entities.PhoneNotification) error {
69+
func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, messagesPerMinute uint, schedule *entities.SendSchedule, notification *entities.PhoneNotification) error {
7070
ctx, span := repository.tracer.Start(ctx)
7171
defer span.End()
7272

7373
if messagesPerMinute == 0 {
74+
notification.ScheduledAt = repository.resolveScheduledAt(time.Now().UTC(), schedule)
7475
return repository.insert(ctx, notification)
7576
}
7677

@@ -86,12 +87,10 @@ func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context,
8687
return stacktrace.Propagate(err, msg)
8788
}
8889

89-
notification.ScheduledAt = time.Now().UTC()
90+
notification.ScheduledAt = repository.resolveScheduledAt(time.Now().UTC(), schedule)
9091
if err == nil {
91-
notification.ScheduledAt = repository.maxTime(
92-
time.Now().UTC(),
93-
lastNotification.ScheduledAt.Add(time.Duration(60/messagesPerMinute)*time.Second),
94-
)
92+
rateLimitedAt := lastNotification.ScheduledAt.Add(time.Duration(60/messagesPerMinute) * time.Second)
93+
notification.ScheduledAt = repository.resolveScheduledAt(repository.maxTime(notification.ScheduledAt, rateLimitedAt), schedule)
9594
}
9695

9796
if err = tx.WithContext(ctx).Create(notification).Error; err != nil {
@@ -108,6 +107,56 @@ func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context,
108107
return nil
109108
}
110109

110+
func (repository *gormPhoneNotificationRepository) resolveScheduledAt(current time.Time, schedule *entities.SendSchedule) time.Time {
111+
if schedule == nil || !schedule.IsActive || len(schedule.Windows) == 0 {
112+
return current.UTC()
113+
}
114+
115+
location, err := time.LoadLocation(schedule.Timezone)
116+
if err != nil {
117+
return current.UTC()
118+
}
119+
120+
base := current.In(location)
121+
var best time.Time
122+
for dayOffset := 0; dayOffset <= 7; dayOffset++ {
123+
day := base.AddDate(0, 0, dayOffset)
124+
weekday := int(day.Weekday())
125+
for _, window := range schedule.Windows {
126+
if window.DayOfWeek != weekday {
127+
continue
128+
}
129+
130+
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location).Add(time.Duration(window.StartMinute) * time.Minute)
131+
end := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location).Add(time.Duration(window.EndMinute) * time.Minute)
132+
133+
var candidate time.Time
134+
switch {
135+
case dayOffset == 0 && base.Before(start):
136+
candidate = start
137+
case dayOffset == 0 && (base.Equal(start) || (base.After(start) && base.Before(end))):
138+
candidate = base
139+
case dayOffset > 0:
140+
candidate = start
141+
default:
142+
continue
143+
}
144+
145+
if best.IsZero() || candidate.Before(best) {
146+
best = candidate
147+
}
148+
}
149+
if !best.IsZero() {
150+
break
151+
}
152+
}
153+
154+
if best.IsZero() {
155+
return current.UTC()
156+
}
157+
return best.UTC()
158+
}
159+
111160
func (repository *gormPhoneNotificationRepository) maxTime(a, b time.Time) time.Time {
112161
if a.Unix() > b.Unix() {
113162
return a

0 commit comments

Comments
 (0)