Skip to content

Commit d7aa243

Browse files
authored
Merge pull request #841 from giresse19/feature/send-schedule-review-fixes
feat: send schedules based on review feedback
2 parents 0e19d14 + 64c351a commit d7aa243

24 files changed

Lines changed: 9050 additions & 5550 deletions

api/docs/docs.go

Lines changed: 377 additions & 0 deletions
Large diffs are not rendered by default.

api/docs/swagger.json

Lines changed: 5113 additions & 4284 deletions
Large diffs are not rendered by default.

api/docs/swagger.yaml

Lines changed: 1413 additions & 1200 deletions
Large diffs are not rendered by default.

api/pkg/di/container.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ func NewContainer(projectID string, version string) (container *Container) {
130130
container.RegisterHeartbeatListeners()
131131

132132
container.RegisterUserRoutes()
133+
container.RegisterSendScheduleRoutes()
134+
container.RegisterSendScheduleListeners()
133135
container.RegisterUserListeners()
134136

135137
container.RegisterPhoneRoutes()
@@ -364,6 +366,10 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
364366
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.User{})))
365367
}
366368

369+
if err = db.AutoMigrate(&entities.MessageSendSchedule{}); err != nil {
370+
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.MessageSendSchedule{})))
371+
}
372+
367373
if err = db.AutoMigrate(&entities.Phone{}); err != nil {
368374
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Phone{})))
369375
}
@@ -753,6 +759,46 @@ func (container *Container) PhoneRepository() (repository repositories.PhoneRepo
753759
)
754760
}
755761

762+
// SendScheduleRepository creates a new instance of repositories.SendScheduleRepository
763+
func (container *Container) SendScheduleRepository() repositories.SendScheduleRepository {
764+
container.logger.Debug("creating GORM repositories.SendScheduleRepository")
765+
return repositories.NewGormSendScheduleRepository(
766+
container.Logger(),
767+
container.Tracer(),
768+
container.DB(),
769+
)
770+
}
771+
772+
// SendScheduleService creates a new instance of services.SendScheduleService
773+
func (container *Container) SendScheduleService() *services.SendScheduleService {
774+
container.logger.Debug("creating services.SendScheduleService")
775+
return services.NewSendScheduleService(
776+
container.Logger(),
777+
container.Tracer(),
778+
container.SendScheduleRepository(),
779+
)
780+
}
781+
782+
// SendScheduleHandlerValidator creates a new instance of validators.SendScheduleHandlerValidator
783+
func (container *Container) SendScheduleHandlerValidator() *validators.SendScheduleHandlerValidator {
784+
container.logger.Debug("creating validators.SendScheduleHandlerValidator")
785+
return validators.NewSendScheduleHandlerValidator(
786+
container.Logger(),
787+
container.Tracer(),
788+
)
789+
}
790+
791+
// SendScheduleHandler creates a new instance of handlers.SendScheduleHandler
792+
func (container *Container) SendScheduleHandler() *handlers.SendScheduleHandler {
793+
container.logger.Debug("creating handlers.SendScheduleHandler")
794+
return handlers.NewSendScheduleHandler(
795+
container.Logger(),
796+
container.Tracer(),
797+
container.SendScheduleHandlerValidator(),
798+
container.SendScheduleService(),
799+
)
800+
}
801+
756802
// BillingUsageRepository creates a new instance of repositories.BillingUsageRepository
757803
func (container *Container) BillingUsageRepository() (repository repositories.BillingUsageRepository) {
758804
container.logger.Debug("creating GORM repositories.BillingUsageRepository")
@@ -1097,6 +1143,20 @@ func (container *Container) RegisterMessageListeners() {
10971143
}
10981144
}
10991145

1146+
// RegisterSendScheduleListeners registers event listeners for listeners.SendScheduleListener
1147+
func (container *Container) RegisterSendScheduleListeners() {
1148+
container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.SendScheduleListener{}))
1149+
_, routes := listeners.NewSendScheduleListener(
1150+
container.Logger(),
1151+
container.Tracer(),
1152+
container.SendScheduleService(),
1153+
)
1154+
1155+
for event, handler := range routes {
1156+
container.EventDispatcher().Subscribe(event, handler)
1157+
}
1158+
}
1159+
11001160
// LemonsqueezyService creates a new instance of services.LemonsqueezyService
11011161
func (container *Container) LemonsqueezyService() (service *services.LemonsqueezyService) {
11021162
container.logger.Debug(fmt.Sprintf("creating %T", service))
@@ -1510,6 +1570,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
15101570
container.FirebaseMessagingClient(),
15111571
container.PhoneRepository(),
15121572
container.PhoneNotificationRepository(),
1573+
container.SendScheduleRepository(),
15131574
container.EventDispatcher(),
15141575
)
15151576
}
@@ -1565,6 +1626,12 @@ func (container *Container) RegisterUserRoutes() {
15651626
container.UserHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
15661627
}
15671628

1629+
// RegisterSendScheduleRoutes registers routes for the /send-schedules prefix
1630+
func (container *Container) RegisterSendScheduleRoutes() {
1631+
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.SendScheduleHandler{}))
1632+
container.SendScheduleHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
1633+
}
1634+
15681635
// RegisterEventRoutes registers routes for the /events prefix
15691636
func (container *Container) RegisterEventRoutes() {
15701637
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 *MessageSendSchedule `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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package entities
2+
3+
import (
4+
"time"
5+
6+
"github.com/google/uuid"
7+
)
8+
9+
// MessageSendScheduleWindow represents a single availability window for a day of the week.
10+
type MessageSendScheduleWindow 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+
// MessageSendSchedule controls when a phone is allowed to send outgoing SMS messages.
17+
type MessageSendSchedule 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 []MessageSendScheduleWindow `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+
}
27+
28+
// ResolveScheduledAt returns the next allowed send time based on the schedule.
29+
// If the schedule is inactive, has no windows, or has an invalid timezone,
30+
// the current time is returned in UTC.
31+
func (schedule *MessageSendSchedule) ResolveScheduledAt(current time.Time) time.Time {
32+
if schedule == nil || !schedule.IsActive || len(schedule.Windows) == 0 {
33+
return current.UTC()
34+
}
35+
36+
location, err := time.LoadLocation(schedule.Timezone)
37+
if err != nil {
38+
return current.UTC()
39+
}
40+
41+
base := current.In(location)
42+
var best time.Time
43+
44+
for dayOffset := 0; dayOffset <= 7; dayOffset++ {
45+
day := base.AddDate(0, 0, dayOffset)
46+
weekday := int(day.Weekday())
47+
48+
for _, window := range schedule.Windows {
49+
if window.DayOfWeek != weekday {
50+
continue
51+
}
52+
53+
start := time.Date(
54+
day.Year(),
55+
day.Month(),
56+
day.Day(),
57+
0,
58+
0,
59+
0,
60+
0,
61+
location,
62+
).Add(time.Duration(window.StartMinute) * time.Minute)
63+
64+
end := time.Date(
65+
day.Year(),
66+
day.Month(),
67+
day.Day(),
68+
0,
69+
0,
70+
0,
71+
0,
72+
location,
73+
).Add(time.Duration(window.EndMinute) * time.Minute)
74+
75+
var candidate time.Time
76+
77+
switch {
78+
case dayOffset == 0 && base.Before(start):
79+
candidate = start
80+
case dayOffset == 0 && (base.Equal(start) || (base.After(start) && base.Before(end))):
81+
candidate = base
82+
case dayOffset > 0:
83+
candidate = start
84+
default:
85+
continue
86+
}
87+
88+
if best.IsZero() || candidate.Before(best) {
89+
best = candidate
90+
}
91+
}
92+
93+
if !best.IsZero() {
94+
break
95+
}
96+
}
97+
98+
if best.IsZero() {
99+
return current.UTC()
100+
}
101+
102+
return best.UTC()
103+
}

0 commit comments

Comments
 (0)