Skip to content

feat: send schedules based on review feedback#841

Open
giresse19 wants to merge 4 commits intoNdoleStudio:mainfrom
giresse19:feature/send-schedule-review-fixes
Open

feat: send schedules based on review feedback#841
giresse19 wants to merge 4 commits intoNdoleStudio:mainfrom
giresse19:feature/send-schedule-review-fixes

Conversation

@giresse19
Copy link
Copy Markdown

No description provided.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 30, 2026

CLA assistant check
All committers have signed the CLA.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 30, 2026

Greptile Summary

This PR introduces a Send Schedules feature that lets users define weekly availability windows (day-of-week + minute ranges) and attach one schedule to each phone. Outgoing message notifications respect both the configured send rate and the selected schedule, delaying delivery to the next open window when necessary.

Key additions:

  • SendSchedule entity with JSONB windows, a new GORM repository, service, handler, and validator wired into the DI container
  • Phone gains a nullable ScheduleID FK (ON DELETE SET NULL) so one schedule can be attached per phone
  • PhoneNotificationService loads the phone's schedule and passes it to PhoneNotificationRepository.Schedule, which applies window-based time resolution on top of the existing rate-limiting logic
  • A new Vue page (/settings/send-schedules) for CRUD management and an autocomplete in the phone edit dialog

Issues found:

  • ResolveScheduledSendTime on SendScheduleService is never called anywhere — it is dead code while resolveScheduledAt in the repository handles the actual runtime path
  • No ownership check on schedule_id during phone updates: a user can store another user's schedule UUID on their phone; the ON DELETE SET NULL DB cascade would then silently modify that user's phone if the schedule owner deletes it
  • The backend validator permits end_minute: 1440, but minuteToClock(1440) renders \"24:00\" — an invalid value for <input type=\"time\">, which would corrupt that window on the next UI save

Confidence Score: 4/5

Safe to merge after addressing the ownership validation gap and the end_minute/UI mismatch; no data loss or security breach in the primary path

Three P2 findings remain: dead code (ResolveScheduledSendTime is never called), a cross-user FK integrity issue (another user's schedule_id can be stored and their delete cascade silently modifies your phone), and a validator/UI mismatch where end_minute: 1440 round-trips to the invalid time string "24:00". None of these cause immediate data loss on the happy path, but the FK cascade issue and the end_minute corruption are real, reproducible defects on reachable code paths, keeping the score at 4.

api/pkg/services/send_schedule_service.go (dead code), api/pkg/services/phone_notification_service.go (ownership check), api/pkg/validators/send_schedule_handler_validator.go + web/pages/settings/send-schedules/index.vue (end_minute bound mismatch)

Important Files Changed

Filename Overview
api/pkg/services/send_schedule_service.go New service for CRUD + scheduling resolution; ResolveScheduledSendTime is defined but never called — dead code while resolveScheduledAt in the repository does the actual work
api/pkg/repositories/gorm_phone_notification_repository.go Adds resolveScheduledAt helper and threads it into the rate-limited scheduling path; logic is correct but remains a duplicate of the service's ResolveScheduledSendTime
api/pkg/services/phone_notification_service.go Loads the schedule for a phone before calling Schedule(); no ownership validation — a cross-user schedule_id can be stored and its deletion would silently clear another user's FK
api/pkg/validators/send_schedule_handler_validator.go Validates windows correctly; end_minute upper bound of 1440 mismatches the HTML time input maximum (23:59 = 1439)
web/pages/settings/send-schedules/index.vue New schedule management UI; minuteToClock(1440) produces "24:00" which breaks the time input for any API-created schedule with end_minute=1440; Intl.supportedValuesOf lacks a fallback (flagged in prior review)
api/pkg/handlers/send_schedule_handler.go Standard CRUD handler following established patterns; spew import is consistent with other handlers in the codebase
api/pkg/entities/send_schedule.go Clean entity definition with JSONB window storage; FK on Phone with OnDelete:SET NULL is correctly declared
api/pkg/repositories/gorm_send_schedule_repository.go Standard GORM repository; all queries correctly scope by userID
api/pkg/requests/phone_update_request.go Adds optional schedule_id field; invalid UUIDs are silently dropped in ToUpsertParams, but the validator catches them before this point
web/pages/settings/index.vue Adds schedule autocomplete to the phone edit dialog and a navigation button to the new send-schedules page; straightforward UI additions
web/store/index.ts Passes schedule_id (or null) in the phone upsert action; the as any cast is necessary due to the missing type declaration
api/pkg/di/container.go Wires up the new schedule repository, service, validator, and handler; SendScheduleRepository is also injected into NotificationService

Sequence Diagram

sequenceDiagram
    participant Client
    participant PhoneNotificationService
    participant SendScheduleRepository
    participant PhoneNotificationRepository

    Client->>PhoneNotificationService: Schedule(params)
    PhoneNotificationService->>PhoneNotificationService: Load phone by ID
    alt phone.ScheduleID != nil
        PhoneNotificationService->>SendScheduleRepository: Load(userID, scheduleID)
        SendScheduleRepository-->>PhoneNotificationService: schedule / 404
        Note over PhoneNotificationService: If 404, schedule = nil
    end
    PhoneNotificationService->>PhoneNotificationRepository: Schedule(messagesPerMinute, schedule, notification)
    alt messagesPerMinute == 0
        PhoneNotificationRepository->>PhoneNotificationRepository: resolveScheduledAt(now, schedule)
        PhoneNotificationRepository-->>PhoneNotificationService: insert notification
    else messagesPerMinute > 0
        PhoneNotificationRepository->>PhoneNotificationRepository: load last notification (tx lock)
        PhoneNotificationRepository->>PhoneNotificationRepository: resolveScheduledAt(now, schedule)
        PhoneNotificationRepository->>PhoneNotificationRepository: rateLimitedAt = lastScheduledAt + 60/rate
        PhoneNotificationRepository->>PhoneNotificationRepository: resolveScheduledAt(max(now, rateLimitedAt), schedule)
        PhoneNotificationRepository-->>PhoneNotificationService: insert notification with scheduled time
    end
Loading

Reviews (2): Last reviewed commit: "feat: Refactor send schedules based on r..." | Re-trigger Greptile

Comment on lines 107 to 162
return nil
}

func (repository *gormPhoneNotificationRepository) resolveScheduledAt(current time.Time, schedule *entities.SendSchedule) 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()
}

func (repository *gormPhoneNotificationRepository) maxTime(a, b time.Time) time.Time {
if a.Unix() > b.Unix() {
return a
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 Duplicate window-resolution logic

resolveScheduledAt here is an exact copy of the algorithm already implemented as ResolveScheduledSendTime on SendScheduleService (introduced in send_schedule_service.go). Maintaining two independent copies of the same scheduling logic will inevitably lead to subtle divergences.

The service method returns (time.Time, error) and already handles the timezone-error path, so the notification service (or even the repository, if it held a reference to the service) could delegate to that single implementation instead of duplicating it here.

Comment on lines +26 to +44
input.Name = strings.TrimSpace(input.Name)
input.Timezone = strings.TrimSpace(input.Timezone)
windows := make([]SendScheduleWindow, 0, len(input.Windows))
for _, item := range input.Windows {
windows = append(windows, SendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute})
}
sort.SliceStable(windows, func(i, j int) bool {
if windows[i].DayOfWeek == windows[j].DayOfWeek {
return windows[i].StartMinute < windows[j].StartMinute
}
return windows[i].DayOfWeek < windows[j].DayOfWeek
})
input.Windows = windows
return *input
}

func (input *SendScheduleStore) ToParams(user entities.AuthContext) *services.SendScheduleUpsertParams {
windows := make([]entities.SendScheduleWindow, 0, len(input.Windows))
for _, item := range input.Windows {
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 Windows are sorted twice on every create/update

Sanitize() here sorts windows by DayOfWeek / StartMinute, and then sanitizeWindows() in send_schedule_service.go sorts the slice again after converting it to []entities.SendScheduleWindow. One of the two sorts is redundant and can be removed.

@giresse19 giresse19 marked this pull request as draft March 30, 2026 17:42
@giresse19 giresse19 marked this pull request as ready for review March 30, 2026 18:11
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.

2 participants