Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
377 changes: 377 additions & 0 deletions api/docs/docs.go

Large diffs are not rendered by default.

9,397 changes: 5,113 additions & 4,284 deletions api/docs/swagger.json

Large diffs are not rendered by default.

2,613 changes: 1,413 additions & 1,200 deletions api/docs/swagger.yaml

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ func NewContainer(projectID string, version string) (container *Container) {
container.RegisterHeartbeatListeners()

container.RegisterUserRoutes()
container.RegisterSendScheduleRoutes()
container.RegisterSendScheduleListeners()
container.RegisterUserListeners()

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

if err = db.AutoMigrate(&entities.MessageSendSchedule{}); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.MessageSendSchedule{})))
}

if err = db.AutoMigrate(&entities.Phone{}); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Phone{})))
}
Expand Down Expand Up @@ -753,6 +759,46 @@ func (container *Container) PhoneRepository() (repository repositories.PhoneRepo
)
}

// SendScheduleRepository creates a new instance of repositories.SendScheduleRepository
func (container *Container) SendScheduleRepository() repositories.SendScheduleRepository {
container.logger.Debug("creating GORM repositories.SendScheduleRepository")
return repositories.NewGormSendScheduleRepository(
container.Logger(),
container.Tracer(),
container.DB(),
)
}

// SendScheduleService creates a new instance of services.SendScheduleService
func (container *Container) SendScheduleService() *services.SendScheduleService {
container.logger.Debug("creating services.SendScheduleService")
return services.NewSendScheduleService(
container.Logger(),
container.Tracer(),
container.SendScheduleRepository(),
)
}

// SendScheduleHandlerValidator creates a new instance of validators.SendScheduleHandlerValidator
func (container *Container) SendScheduleHandlerValidator() *validators.SendScheduleHandlerValidator {
container.logger.Debug("creating validators.SendScheduleHandlerValidator")
return validators.NewSendScheduleHandlerValidator(
container.Logger(),
container.Tracer(),
)
}

// SendScheduleHandler creates a new instance of handlers.SendScheduleHandler
func (container *Container) SendScheduleHandler() *handlers.SendScheduleHandler {
container.logger.Debug("creating handlers.SendScheduleHandler")
return handlers.NewSendScheduleHandler(
container.Logger(),
container.Tracer(),
container.SendScheduleHandlerValidator(),
container.SendScheduleService(),
)
}

// BillingUsageRepository creates a new instance of repositories.BillingUsageRepository
func (container *Container) BillingUsageRepository() (repository repositories.BillingUsageRepository) {
container.logger.Debug("creating GORM repositories.BillingUsageRepository")
Expand Down Expand Up @@ -1097,6 +1143,20 @@ func (container *Container) RegisterMessageListeners() {
}
}

// RegisterSendScheduleListeners registers event listeners for listeners.SendScheduleListener
func (container *Container) RegisterSendScheduleListeners() {
container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.SendScheduleListener{}))
_, routes := listeners.NewSendScheduleListener(
container.Logger(),
container.Tracer(),
container.SendScheduleService(),
)

for event, handler := range routes {
container.EventDispatcher().Subscribe(event, handler)
}
}

// LemonsqueezyService creates a new instance of services.LemonsqueezyService
func (container *Container) LemonsqueezyService() (service *services.LemonsqueezyService) {
container.logger.Debug(fmt.Sprintf("creating %T", service))
Expand Down Expand Up @@ -1510,6 +1570,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
container.FirebaseMessagingClient(),
container.PhoneRepository(),
container.PhoneNotificationRepository(),
container.SendScheduleRepository(),
container.EventDispatcher(),
)
}
Expand Down Expand Up @@ -1565,6 +1626,12 @@ func (container *Container) RegisterUserRoutes() {
container.UserHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
}

// RegisterSendScheduleRoutes registers routes for the /send-schedules prefix
func (container *Container) RegisterSendScheduleRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.SendScheduleHandler{}))
container.SendScheduleHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
}

// RegisterEventRoutes registers routes for the /events prefix
func (container *Container) RegisterEventRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.EventsHandler{}))
Expand Down
14 changes: 8 additions & 6 deletions api/pkg/entities/phone.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import (

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

Expand Down
103 changes: 103 additions & 0 deletions api/pkg/entities/send_schedule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package entities

import (
"time"

"github.com/google/uuid"
)

// MessageSendScheduleWindow represents a single availability window for a day of the week.
type MessageSendScheduleWindow struct {
DayOfWeek int `json:"day_of_week" example:"1"`
StartMinute int `json:"start_minute" example:"540"`
EndMinute int `json:"end_minute" example:"1020"`
}

// MessageSendSchedule controls when a phone is allowed to send outgoing SMS messages.
type MessageSendSchedule struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
Name string `json:"name" example:"Business Hours"`
Timezone string `json:"timezone" example:"Europe/Tallinn"`
IsActive bool `json:"is_active" gorm:"default:true" example:"true"`
Windows []MessageSendScheduleWindow `json:"windows" gorm:"type:jsonb;serializer:json"`
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
}

// ResolveScheduledAt returns the next allowed send time based on the schedule.
// If the schedule is inactive, has no windows, or has an invalid timezone,
// the current time is returned in UTC.
func (schedule *MessageSendSchedule) ResolveScheduledAt(current time.Time) time.Time {
if schedule == nil || !schedule.IsActive || len(schedule.Windows) == 0 {
return current.UTC()
}

location, err := time.LoadLocation(schedule.Timezone)
if err != nil {
return current.UTC()
}

base := current.In(location)
var best time.Time

for dayOffset := 0; dayOffset <= 7; dayOffset++ {
day := base.AddDate(0, 0, dayOffset)
weekday := int(day.Weekday())

for _, window := range schedule.Windows {
if window.DayOfWeek != weekday {
continue
}

start := time.Date(
day.Year(),
day.Month(),
day.Day(),
0,
0,
0,
0,
location,
).Add(time.Duration(window.StartMinute) * time.Minute)

end := time.Date(
day.Year(),
day.Month(),
day.Day(),
0,
0,
0,
0,
location,
).Add(time.Duration(window.EndMinute) * time.Minute)

var candidate time.Time

switch {
case dayOffset == 0 && base.Before(start):
candidate = start
case dayOffset == 0 && (base.Equal(start) || (base.After(start) && base.Before(end))):
candidate = base
case dayOffset > 0:
candidate = start
default:
continue
}

if best.IsZero() || candidate.Before(best) {
best = candidate
}
}

if !best.IsZero() {
break
}
}

if best.IsZero() {
return current.UTC()
}

return best.UTC()
}
Loading