Files
minstrel/internal/api/admin_scan_schedule.go
T
bvandeusen 58766dbebe fix(server/api): preserve 500-class messages via apierror.InternalMsg
The PR1-T2 admin handler migration replaced bespoke 500-class messages
("lookup failed", "refetch failed", "trigger failed", "bump failed",
"remove failed", "update failed", "schedule row missing", "re-read
failed", "read failed") with apierror.Internal(err), which forces the
wire Message to "internal server error". That violates the PR1
contract that errEnvelope{Code, Message} must remain byte-identical
for existing clients.

Add apierror.InternalMsg(message, cause) — a 500-class constructor
that preserves the call site's user-facing message while keeping the
cause attached for logging and errors.Is. Re-migrate the 12 admin
sites whose pre-1cc7eb6 source had a non-empty bespoke Message so
they restore the original wire string. Sites whose original Message
was empty stay on Internal(err) (humanization to "internal server
error" is a tolerable improvement, not a regression).

admin_smtp.go's send_failed site (line 129) was already preserved
via a raw &apierror.Error literal in 1cc7eb6 and needs no fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:10:18 -04:00

156 lines
4.2 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
type scanScheduleResp struct {
Mode string `json:"mode"`
IntervalHours *int `json:"interval_hours"`
TimeOfDay *string `json:"time_of_day"`
WeeklyDay *int `json:"weekly_day"`
NextScheduledAt *string `json:"next_scheduled_at"`
}
type scanScheduleReq struct {
Mode string `json:"mode"`
IntervalHours *int `json:"interval_hours"`
TimeOfDay *string `json:"time_of_day"`
WeeklyDay *int `json:"weekly_day"`
}
func (h *handlers) handleGetScanSchedule(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
row, err := q.GetScanSchedule(r.Context())
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
h.logger.Error("admin: scan_schedule singleton row missing")
writeErr(w, apierror.InternalMsg("schedule row missing", errors.New("schedule row missing")))
return
}
writeErrWithLog(w, h.logger, "admin: get scan schedule", apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, h.scheduler.NextFire()))
}
func (h *handlers) handlePatchScanSchedule(w http.ResponseWriter, r *http.Request) {
var req scanScheduleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
if err := validateScheduleReq(&req); err != nil {
writeErr(w, apierror.BadRequest("validation", err.Error()))
return
}
q := dbq.New(h.pool)
if err := q.UpdateScanSchedule(r.Context(), dbq.UpdateScanScheduleParams{
Mode: req.Mode,
IntervalHours: pgInt32Ptr(req.IntervalHours),
TimeOfDay: req.TimeOfDay,
WeeklyDay: pgInt32Ptr(req.WeeklyDay),
}); err != nil {
writeErrWithLog(w, h.logger, "admin: update scan schedule", apierror.InternalMsg("update failed", err))
return
}
h.scheduler.Refresh()
row, err := q.GetScanSchedule(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin: re-read scan schedule", apierror.InternalMsg("re-read failed", err))
return
}
cfg := library.ScheduleConfig{Mode: row.Mode}
if row.IntervalHours != nil {
cfg.IntervalHours = int(*row.IntervalHours)
}
if row.TimeOfDay != nil {
cfg.TimeOfDay = *row.TimeOfDay
}
if row.WeeklyDay != nil {
cfg.WeeklyDay = int(*row.WeeklyDay)
}
next := cfg.NextFire(time.Now())
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, next))
}
func validateScheduleReq(req *scanScheduleReq) error {
switch req.Mode {
case "off":
req.IntervalHours = nil
req.TimeOfDay = nil
req.WeeklyDay = nil
return nil
case "interval":
if req.IntervalHours == nil || *req.IntervalHours <= 0 {
return fmt.Errorf("interval_hours required and > 0 when mode=interval")
}
req.TimeOfDay = nil
req.WeeklyDay = nil
return nil
case "daily":
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
return fmt.Errorf("time_of_day required when mode=daily")
}
req.IntervalHours = nil
req.WeeklyDay = nil
return nil
case "weekly":
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
return fmt.Errorf("time_of_day required when mode=weekly")
}
if req.WeeklyDay == nil || *req.WeeklyDay < 1 || *req.WeeklyDay > 7 {
return fmt.Errorf("weekly_day required and in [1, 7] when mode=weekly")
}
req.IntervalHours = nil
return nil
default:
return fmt.Errorf("invalid mode %q (want off / interval / daily / weekly)", req.Mode)
}
}
func scanScheduleRespFromRow(row dbq.GetScanScheduleRow, nextFire time.Time) scanScheduleResp {
resp := scanScheduleResp{Mode: row.Mode}
if row.IntervalHours != nil {
v := int(*row.IntervalHours)
resp.IntervalHours = &v
}
if row.TimeOfDay != nil {
v := *row.TimeOfDay
resp.TimeOfDay = &v
}
if row.WeeklyDay != nil {
v := int(*row.WeeklyDay)
resp.WeeklyDay = &v
}
if !nextFire.IsZero() {
s := nextFire.UTC().Format(time.RFC3339)
resp.NextScheduledAt = &s
}
return resp
}
func pgInt32Ptr(src *int) *int32 {
if src == nil {
return nil
}
v := int32(*src)
return &v
}