Skip to content
Open
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.

5 changes: 0 additions & 5 deletions api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ require (
github.com/stretchr/testify v1.11.1
github.com/swaggo/swag v1.16.6
github.com/thedevsaddam/govalidator v1.9.10
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc
github.com/uptrace/uptrace-go v1.41.1
github.com/xuri/excelize/v2 v2.10.1
go.opentelemetry.io/otel v1.43.0
Expand All @@ -55,7 +54,6 @@ require (
google.golang.org/api v0.274.0
google.golang.org/protobuf v1.36.11
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
gorm.io/plugin/opentelemetry v0.1.16
)
Expand Down Expand Up @@ -94,13 +92,11 @@ require (
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
Expand Down Expand Up @@ -187,7 +183,6 @@ require (
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
Expand Down
8 changes: 0 additions & 8 deletions api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
Expand All @@ -90,8 +88,6 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw=
github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand Down Expand Up @@ -324,8 +320,6 @@ github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOj
github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
github.com/uptrace/uptrace-go v1.41.1 h1:EtWkkdOQqtuJMZyzeU0zT5VH6ppVY12yOouQK3VRccw=
github.com/uptrace/uptrace-go v1.41.1/go.mod h1:gdn1eRLG3KCtTyiw+L8tG+tb/wnpiyIfLfTH2qh/5Mw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
Expand Down Expand Up @@ -416,8 +410,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
Expand Down
1 change: 0 additions & 1 deletion api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (

"github.com/NdoleStudio/httpsms/docs"
"github.com/NdoleStudio/httpsms/pkg/di"
_ "github.com/tursodatabase/libsql-client-go/libsql"
)

// Version is injected at runtime
Expand Down
77 changes: 68 additions & 9 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ import (
"strings"
"time"

"github.com/NdoleStudio/httpsms/docs"
plunk "github.com/NdoleStudio/plunk-go"
"github.com/pusher/pusher-http-go/v5"
"gorm.io/driver/sqlite"

"github.com/NdoleStudio/httpsms/docs"

otelMetric "go.opentelemetry.io/otel/metric"

Expand Down Expand Up @@ -130,6 +128,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 @@ -234,12 +234,6 @@ func (container *Container) GormLogger() gormLogger.Interface {
}

func (container *Container) connect(dsn string, config *gorm.Config) (db *gorm.DB, err error) {
if strings.HasPrefix(dsn, "libsql://") {
return gorm.Open(sqlite.New(sqlite.Config{
DriverName: "libsql",
DSN: dsn,
}), config)
}
return gorm.Open(postgres.Open(dsn), config)
}

Expand Down Expand Up @@ -364,6 +358,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 +751,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 +1135,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 +1562,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
container.FirebaseMessagingClient(),
container.PhoneRepository(),
container.PhoneNotificationRepository(),
container.SendScheduleRepository(),
container.EventDispatcher(),
)
}
Expand Down Expand Up @@ -1565,6 +1618,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()
Comment on lines +31 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Active schedule with empty windows sends immediately

When IsActive is true but Windows is empty, ResolveScheduledAt returns current.UTC() (the early-exit path on line 32). This means messages are sent immediately instead of being held, which is the opposite of what a user who configured an active-but-window-less schedule might expect. Consider either disallowing an active schedule with zero windows (add a validator rule) or treating it the same as IsActive: false.

}

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
Loading