Skip to content

feat: Add send schedule settings and backend support#840

Open
giresse19 wants to merge 3 commits intoNdoleStudio:mainfrom
giresse19:feature/send-schedule
Open

feat: Add send schedule settings and backend support#840
giresse19 wants to merge 3 commits intoNdoleStudio:mainfrom
giresse19:feature/send-schedule

Conversation

@giresse19
Copy link

Adds Send Schedule support across frontend and backend.

@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@what-the-diff
Copy link

what-the-diff bot commented Mar 26, 2026

PR Summary

  • Added Documentation for New Feature: A new documentation file has been added which provides detailed information about the scheduling feature, including backend functionality, route handling, and user interface.
  • Created New Entities: Two new elements, SendSchedule and SendScheduleWindow are now present, providing structure and definition for the message scheduling.
  • Established Repository for Schedules: A repository has been set up to handle all Create, Read, Update and Delete (CRUD) operations for send schedules in the database.
  • New Feature Handling: A Handler has been created to manage HTTP requests related to scheduling features, including creating, updating, and deleting schedules.
  • Database Updates: The database can now automatically incorporate changes or 'migrate' in relation to two new elements - SendSchedule and SendScheduleWindow.
  • Enhanced Routing: New routes associated with send schedules have been registered, allowing for better API interaction with these schedules.
  • Improved Error Handling: The error messages in memory and redis cache system files have been cleaned up for clarity.
  • Updated Message Repository: The functioning of the existing message repository has improved, utilizing timing factors to streamline communication.
  • New Send Schedule Operations Interface: A new interface has been created to outline operations related to send schedules.

  • New Definitions: A file has been added that outlines new terms and their sanitization and conversion methods in the context of schedule handling.
  • Expanded Message Services: The existing message service has been updated to include scheduling services and to better incorporate scheduling functionality.
  • Providing Schedule Management: A new file has now been added which includes ways to handle scheduling features, ranging from creation to deletion and calculating optimal send times.
  • New Validation for Requests: A new validating system has been introduced to ensure required fields and windows are correctly entered for schedule changes.
  • Updated Web Interfaces: The web interfaces for the scheduling features have been added to the existing API system.
  • Updated Package Configuration: The versions of several dependencies and packages, including firebase and webpack, have been adjusted for better compatibility.
  • Improved Error Logging: The system for logging errors has been improved with a better format for easier resolution.

@greptile-apps
Copy link

greptile-apps bot commented Mar 26, 2026

Greptile Summary

This PR adds a full Send Schedule feature — allowing users to define weekly time windows during which outgoing SMS messages may be sent, with the backend deferring messages created outside those windows to the next allowed slot. It introduces new SendSchedule/SendScheduleWindow entities, a GORM repository, service, handler, validator, and settings UI.

The architecture follows existing project patterns well. Two notable bugs were introduced:

  • Message stuck in sending state (api/pkg/services/message_service.go): GetOutstanding atomically commits a DB status change to sending inside a CockroachDB transaction, then performs a schedule window check in Go after the commit. If the current time falls outside the active window, the method returns an error but the message is permanently left in sending status and never delivered.
  • Copy-paste error in migration log messages (api/pkg/di/container.go): When Discord migration fails the log reports cannot migrate *entities.Webhook, and when Integration3CX migration fails it reports cannot migrate *entities.Discord — caused by incorrect %T arguments introduced during the fmt.Sprintf refactor.

Additional minor items:

  • The new SendSchedule/SendScheduleWindow migration blocks still use fmt.Sprintf, inconsistent with the refactor applied to adjacent blocks.
  • The frontend saveSendSchedule method fires a redundant POST /v1/send-schedules/{id}/default after a create/update that already persists is_default: true via the same transaction.

Confidence Score: 2/5

Not safe to merge — the GetOutstanding race condition can permanently stall outgoing messages in a 'sending' state under normal usage.

Two P1 issues block a clean merge: the message-stuck-in-sending bug is a production reliability issue that occurs on every phone check-in that arrives just after a schedule window closes, and the container.go copy-paste error produces misleading fatal-level migration logs. Both are straightforward to fix but need to be resolved before shipping.

api/pkg/services/message_service.go (GetOutstanding schedule check ordering) and api/pkg/di/container.go (wrong %T args on lines 378 and 382).

Important Files Changed

Filename Overview
api/pkg/services/message_service.go Integrates SendScheduleService for send-time resolution and outstanding-message gating, but the post-commit schedule check in GetOutstanding can leave messages permanently stuck in "sending" status.
api/pkg/di/container.go Registers SendSchedule routes and DI wiring correctly, but introduces copy-paste bugs in migration error messages for Discord and Integration3CX entities (wrong %T arguments).
api/pkg/services/send_schedule_service.go New service with solid window validation, overlap detection, timezone handling, and NextAllowedTime search logic; clean overall.
api/pkg/repositories/gorm_send_schedule_repository.go Well-structured GORM repository; uses a transaction in Update to safely replace windows, and ClearDefault/SetDefault are correct.
api/pkg/entities/send_schedule.go Clean entity definitions with appropriate GORM tags, cascade delete, and proper index annotations.
api/pkg/handlers/send_schedule_handler.go Handler follows existing project patterns correctly; all CRUD routes handled with proper error code translation.
web/pages/settings/index.vue Full CRUD UI for send schedules with proper loading states; contains a redundant /default API call when saving with is_default:true, and no delete confirmation dialog.
web/models/api.ts Adds correct TypeScript interfaces for SendSchedule and SendScheduleWindow matching the backend entity shapes.

Sequence Diagram

sequenceDiagram
    participant Phone as Phone App
    participant Handler as MessageHandler
    participant MsgSvc as MessageService
    participant SchedSvc as SendScheduleService
    participant DB as Database

    Phone->>Handler: GET /v1/outstanding
    Handler->>MsgSvc: GetOutstanding(params)
    MsgSvc->>DB: BEGIN TX — UPDATE messages SET status='sending'<br/>WHERE status IN (pending,scheduled,expired)<br/>AND (scheduled_send_time IS NULL OR <= timestamp)
    DB-->>MsgSvc: message (status committed as 'sending')
    MsgSvc->>DB: SELECT default send schedule
    DB-->>MsgSvc: schedule
    MsgSvc->>SchedSvc: IsAllowedAt(schedule, now)
    alt Now is INSIDE window
        SchedSvc-->>MsgSvc: true
        MsgSvc-->>Handler: message
        Handler-->>Phone: 200 OK
    else Now is OUTSIDE window
        SchedSvc-->>MsgSvc: false
        Note over MsgSvc,DB: Message already committed as 'sending' — STUCK!
        MsgSvc-->>Handler: ErrCodeNotFound
        Handler-->>Phone: 404
    end
Loading

Reviews (1): Last reviewed commit: "feat: Add send schedule settings and bac..." | Re-trigger Greptile

Comment on lines +378 to +382
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.Webhook{}))
}

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

Choose a reason for hiding this comment

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

P1 Copy-paste error: wrong entity types in migration error messages

Lines 378 and 382 reference the wrong entity types in their stacktrace.Propagate error messages, introduced when converting from fmt.Sprintf. When the Discord migration fails the log says cannot migrate *entities.Webhook, and when the Integration3CX migration fails it says cannot migrate *entities.Discord. This makes diagnosing migration failures confusing.

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

Comment on lines +69 to +79
message, err := service.repository.GetOutstanding(ctx, params.UserID, params.MessageID, params.PhoneNumbers, params.Timestamp)
if err != nil {
msg := fmt.Sprintf("could not fetch outstanding messages with params [%s]", spew.Sdump(params))
return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg))
}

schedule, scheduleErr := service.sendScheduleService.repository.Default(ctx, params.UserID)
if scheduleErr == nil && !service.sendScheduleService.IsAllowedAt(schedule, params.Timestamp) {
msg := fmt.Sprintf("message [%s] is outside the active send schedule", params.MessageID)
return nil, service.tracer.WrapErrorSpan(span, stacktrace.NewErrorWithCode(repositories.ErrCodeNotFound, msg))
}
Copy link

Choose a reason for hiding this comment

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

P1 Message gets stuck in sending status when schedule check fails

The call to service.repository.GetOutstanding at line 69 atomically commits a DB transaction (crdbgorm.ExecuteTx) that changes the message status from pending/scheduled/expiredsending. The schedule check at lines 75–78 runs after that commit. If the current time falls outside the allowed window and IsAllowedAt returns false, the service returns a not-found error — but the message is permanently left in sending status, causing it to never be delivered.

This is a concrete data-integrity issue: any message that arrives at a phone after a send-window closes (e.g., scheduled_send_time IS NULL with an ASAP intent) will get stuck. The SQL-level scheduled_send_time filter already gates future-scheduled messages correctly; the additional Go-level window check needs to be applied before the status is updated (i.e., inside the SQL transaction), or the two checks need to be consolidated so a message is only transitioned to sending when the window check is known to pass.

Comment on lines +389 to +394
if err = db.AutoMigrate(&entities.SendSchedule{}); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.SendSchedule{})))
}

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

Choose a reason for hiding this comment

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

P2 Inconsistent use of fmt.Sprintf in new migration blocks

The rest of this PR converts stacktrace.Propagate(err, fmt.Sprintf(...)) to stacktrace.Propagate(err, "...", arg), but the two newly added SendSchedule migration blocks still use fmt.Sprintf. For consistency:

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +1495 to +1497
error?.response?.data?.message || 'Failed to save send schedule',
})
} finally {
Copy link

Choose a reason for hiding this comment

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

P2 Redundant /default API call when saving a schedule with is_default: true

The Create and Update service methods already call ClearDefault and persist is_default: true as part of the main save. Immediately after, saveSendSchedule also fires POST /v1/send-schedules/{id}/default, causing a second ClearDefault + Update cycle for the same record. This is harmless today but wastes a round-trip and two extra DB writes on every default-schedule save.

Consider removing the extra /default call here, since the is_default flag in the payload is already honoured by the backend:

Suggested change
error?.response?.data?.message || 'Failed to save send schedule',
})
} finally {
// is_default is already handled by Create/Update — no extra call needed


// GetOutstanding fetches messages that still to be sent to the phone
func (repository *gormMessageRepository) GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID, phoneNumbers []string) (*entities.Message, error) {
func (repository *gormMessageRepository) GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID, phoneNumbers []string, timestamp time.Time) (*entities.Message, error) {
Copy link
Member

Choose a reason for hiding this comment

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

The scheduling is not done when we fetch the outstanding message. The schedule is created here

func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, messagesPerMinute uint, notification *entities.PhoneNotification) error {
ctx, span := repository.tracer.Start(ctx)
defer span.End()
if messagesPerMinute == 0 {
return repository.insert(ctx, notification)
}
err := crdbgorm.ExecuteTx(ctx, repository.db, nil, func(tx *gorm.DB) error {
lastNotification := new(entities.PhoneNotification)
err := tx.WithContext(ctx).
Where("phone_id = ?", notification.PhoneID).
Order("scheduled_at desc").
First(lastNotification).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
msg := fmt.Sprintf("cannot fetch last notification with phone ID [%s]", notification.PhoneID)
return stacktrace.Propagate(err, msg)
}
notification.ScheduledAt = time.Now().UTC()
if err == nil {
notification.ScheduledAt = repository.maxTime(
time.Now().UTC(),
lastNotification.ScheduledAt.Add(time.Duration(60/messagesPerMinute)*time.Second),
)
}
if err = tx.WithContext(ctx).Create(notification).Error; err != nil {
msg := fmt.Sprintf("cannot create new notification with id [%s] and schedule [%s]", notification.ID, notification.ScheduledAt.String())
return stacktrace.Propagate(err, msg)
}
return nil
})
if err != nil {
msg := fmt.Sprintf("cannot schedule phone notification with ID [%s]", notification.ID)
return stacktrace.Propagate(err, msg)
}
return nil
}

This is where we schedule when a notification will be sent to the phone to send an outgoing message that is where we need to handle the schduelding logic.

The way the flow works,

When the API receives a new request, it adds the outgoing message in the queue.
The message will be fetched from the queu automatically and we'll figure out when to send the messagae based on the send rate logic in phone_notification_service. This process has locks to ensure that even if multiple messages are sent at once, we will process them sequentially.

Timezone string `json:"timezone"`
IsDefault bool `json:"is_default" gorm:"default:false"`
IsActive bool `json:"is_active" gorm:"default:true"`
Windows []SendScheduleWindow `json:"windows" gorm:"constraint:OnDelete:CASCADE;foreignKey:ScheduleID"`
Copy link
Member

@AchoArnold AchoArnold Mar 26, 2026

Choose a reason for hiding this comment

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

serialize this as JSONb instead of creating a new sql table if possible, it may amke the query much more complex though https://gorm.io/docs/serializer.html

router.Get("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Show)...)
router.Put("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Update)...)
router.Delete("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Delete)...)
router.Post("/v1/send-schedules/:scheduleID/default", h.computeRoute(middlewares, h.SetDefault)...)
Copy link
Member

Choose a reason for hiding this comment

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

There will be no concept of a default schedule. We should be able to attach a schedule to a phone entity in a 1:1 relationshonsip meaning a phone can have only1 schedule. You can add a nullable schedule_id on the phone entity.

@@ -1,3 +1,28 @@

export interface EntitiesSendScheduleWindow {
Copy link
Member

Choose a reason for hiding this comment

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

This file is auto-generated see

"api:models": "npx swagger-typescript-api generate -p ..\\api\\docs\\swagger.json -o ./models -n api.ts --no-client",

</v-btn>
</div>
</div>
<v-dialog v-model="showSendScheduleDialog" :fullscreen="$vuetify.breakpoint.smAndDown" max-width="900">
Copy link
Member

Choose a reason for hiding this comment

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

Let's have a separate page for managing schedules with a back button like the settings page.

Send Schedule
</h5>
<p class="text--secondary">
Define when outgoing SMS messages are allowed to be sent. When a
Copy link
Member

Choose a reason for hiding this comment

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

messages will ALWAYS be sent according to the avaialbiltiy schdule and also respoecting the sending rate https://docs.httpsms.com/features/control-sms-send-rate


if err != nil {
l.Error(stacktrace.Propagate(err, msg))
l.Error(stacktrace.Propagate(err, "%s", msg))
Copy link
Member

Choose a reason for hiding this comment

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

l.Error(stacktrace.Propagate(err, msg))

ctxLogger := service.tracer.CtxLogger(service.logger, span)

sendAttempts, sim := service.phoneSettings(ctx, params.UserID, phonenumbers.Format(params.Owner, phonenumbers.E164))
resolvedSendAt, err := service.sendScheduleService.ResolveScheduledSendTime(ctx, params.UserID, params.SendAt)
Copy link
Member

Choose a reason for hiding this comment

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

The send schedule is determined on the notification flow. See the other comment.

Copy link
Member

Choose a reason for hiding this comment

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

Why are you deleting this file?

Copy link
Member

Choose a reason for hiding this comment

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

use pnpm instead of yarn

You may need this command on windows $env:NODE_OPTIONS = "--openssl-legacy-provider"

return &gormSendScheduleRepository{logger: logger.WithService(fmt.Sprintf("%T", &gormSendScheduleRepository{})), tracer: tracer, db: db}
}

func (r *gormSendScheduleRepository) Store(ctx context.Context, schedule *entities.SendSchedule) error {
Copy link
Member

Choose a reason for hiding this comment

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

Add swag,go documentation on all the endpoints. https://github.com/swaggo/swag

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants