From d2c69c6a5e72c40edb2bb468b2e3c5e781dbada7 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 13:13:10 +0700 Subject: [PATCH] feat(api): schedule + runs endpoints; next_run_at on task detail Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/httpapi/router.go | 2 + internal/httpapi/tasks.go | 84 +++++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index c418817..b7be610 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -22,6 +22,8 @@ func (s *Server) Router() http.Handler { api.HandleFunc("POST /api/tasks/{id}/accounts", s.handleCreateAccount) api.HandleFunc("POST /api/tasks/{id}/probe", s.handleProbeFolders) api.HandleFunc("PUT /api/tasks/{id}/folder-mapping", s.handleSetFolderMapping) + api.HandleFunc("PUT /api/tasks/{id}/schedule", s.handleSetSchedule) + api.HandleFunc("GET /api/tasks/{id}/runs", s.handleListRuns) api.HandleFunc("DELETE /api/tasks/{id}/accounts/{accountId}", s.handleDeleteAccount) api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV) api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts) diff --git a/internal/httpapi/tasks.go b/internal/httpapi/tasks.go index b1a84fe..17dcbd1 100644 --- a/internal/httpapi/tasks.go +++ b/internal/httpapi/tasks.go @@ -1,9 +1,12 @@ package httpapi import ( + "context" "encoding/json" "net/http" + "time" + "github.com/vasyansk/imap-copier/internal/scheduler" "github.com/vasyansk/imap-copier/internal/store" ) @@ -50,5 +53,84 @@ func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { for _, a := range accs { views = append(views, accountDTO(a)) } - writeJSON(w, http.StatusOK, map[string]any{"task": task, "accounts": views}) + writeJSON(w, http.StatusOK, map[string]any{"task": s.taskViewFor(r.Context(), task), "accounts": views}) +} + +// taskView augments the stored task with the server-computed next scheduled run +// (RFC3339 UTC; null when the schedule is off, the task is running, or broken). +type taskView struct { + store.Task + NextRunAt *string `json:"next_run_at"` +} + +func (s *Server) taskViewFor(ctx context.Context, t store.Task) taskView { + var nextRunAt *string + if t.ScheduleIntervalSeconds > 0 && !t.Broken && t.Status != "running" && t.ScheduleAnchor != nil { + last, _ := s.store.LastFinishedRunAt(ctx, t.ID) + n := scheduler.NextRun(time.Duration(t.ScheduleIntervalSeconds)*time.Second, *t.ScheduleAnchor, last) + str := n.UTC().Format(time.RFC3339) + nextRunAt = &str + } + return taskView{Task: t, NextRunAt: nextRunAt} +} + +// handleSetSchedule enables/changes/disables a task's recurring schedule. +// Enabling (interval>0) requires every account to pass its connection tests. +func (s *Server) handleSetSchedule(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + var body struct { + IntervalSeconds int64 `json:"interval_seconds"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if body.IntervalSeconds > 0 { + accs, err := s.store.ListAccountsByTask(r.Context(), taskID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !allAccountsTested(accs) { + http.Error(w, "all accounts must pass connection tests before scheduling", http.StatusConflict) + return + } + } + if err := s.store.SetTaskSchedule(r.Context(), taskID, body.IntervalSeconds); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// allAccountsTested mirrors the orchestrator's run gate: non-empty and every +// account OK on both sides. +func allAccountsTested(accs []store.Account) bool { + if len(accs) == 0 { + return false + } + for _, a := range accs { + if a.TestSrcStatus != "ok" || a.TestDstStatus != "ok" { + return false + } + } + return true +} + +func (s *Server) handleListRuns(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + runs, err := s.store.ListRunsByTask(r.Context(), taskID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, runs) }