Pulse/internal/api/report_schedules.go
2026-07-07 20:37:18 +01:00

851 lines
29 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
"github.com/rs/zerolog/log"
)
const (
reportScheduleAttachmentLimitBytes = 15 * 1024 * 1024
reportScheduleTickInterval = time.Minute
reportScheduleMissedRunGrace = 24 * time.Hour
)
type reportScheduleListResponse struct {
Schedules []config.ReportSchedule `json:"schedules"`
}
type reportScheduleRunResponse struct {
Schedule config.ReportSchedule `json:"schedule"`
Status string `json:"status"`
Path string `json:"path,omitempty"`
Email string `json:"email,omitempty"`
}
func (h *ReportingHandlers) HandleListReportSchedules(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
store, err := h.loadReportScheduleStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
writeReportScheduleJSON(w, http.StatusOK, reportScheduleListResponse{Schedules: store.Schedules})
}
func (h *ReportingHandlers) HandleCreateReportSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var incoming config.ReportSchedule
if err := decodeReportScheduleBody(w, r, &incoming); err != nil {
return
}
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
now := time.Now().UTC()
incoming.ID = uuid.NewString()
incoming.CreatedAt = now
incoming.UpdatedAt = now
schedule, err := h.prepareReportSchedule(r.Context(), incoming, now, false)
if err != nil {
writeReportScheduleValidationError(w, err)
return
}
store.Schedules = append(store.Schedules, schedule)
if err := persistence.SaveReportScheduleStore(*store); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
return
}
writeReportScheduleJSON(w, http.StatusCreated, schedule)
}
func (h *ReportingHandlers) HandleUpdateReportSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
scheduleID := strings.TrimSpace(r.PathValue("id"))
if scheduleID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
return
}
var incoming config.ReportSchedule
if err := decodeReportScheduleBody(w, r, &incoming); err != nil {
return
}
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
index := findReportScheduleIndex(store.Schedules, scheduleID)
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
return
}
now := time.Now().UTC()
existing := store.Schedules[index]
incoming.ID = existing.ID
incoming.CreatedAt = existing.CreatedAt
incoming.UpdatedAt = now
incoming.LastRunAt = existing.LastRunAt
incoming.LastRunStatus = existing.LastRunStatus
incoming.LastError = existing.LastError
incoming.LastOccurrenceKey = existing.LastOccurrenceKey
schedule, err := h.prepareReportSchedule(r.Context(), incoming, now, false)
if err != nil {
writeReportScheduleValidationError(w, err)
return
}
store.Schedules[index] = schedule
if err := persistence.SaveReportScheduleStore(*store); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
return
}
writeReportScheduleJSON(w, http.StatusOK, schedule)
}
func (h *ReportingHandlers) HandleDeleteReportSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
scheduleID := strings.TrimSpace(r.PathValue("id"))
if scheduleID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
return
}
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
index := findReportScheduleIndex(store.Schedules, scheduleID)
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
return
}
store.Schedules = append(store.Schedules[:index], store.Schedules[index+1:]...)
if err := persistence.SaveReportScheduleStore(*store); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule", nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *ReportingHandlers) HandleRunReportSchedule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
scheduleID := strings.TrimSpace(r.PathValue("id"))
if scheduleID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_schedule_id", "Schedule ID is required", nil)
return
}
persistence, store, err := h.reportSchedulePersistenceAndStore(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_store_unavailable", "Report schedules are unavailable", nil)
return
}
index := findReportScheduleIndex(store.Schedules, scheduleID)
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, "schedule_not_found", "Report schedule not found", nil)
return
}
result, updated := h.runReportSchedule(r.Context(), persistence, store.Schedules[index], time.Now().UTC(), true, "")
store.Schedules[index] = updated
if err := persistence.SaveReportScheduleStore(*store); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "schedule_save_failed", "Failed to save report schedule status", nil)
return
}
status := http.StatusOK
if updated.LastRunStatus == config.ReportScheduleLastRunFailed {
status = http.StatusInternalServerError
}
writeReportScheduleJSON(w, status, reportScheduleRunResponse{
Schedule: updated,
Status: updated.LastRunStatus,
Path: result.path,
Email: result.email,
})
}
func decodeReportScheduleBody(w http.ResponseWriter, r *http.Request, target *config.ReportSchedule) error {
r.Body = http.MaxBytesReader(w, r.Body, reportingMultiReportBodyMax)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid report schedule body", nil)
return err
}
if err := decoder.Decode(&struct{}{}); err != nil && !errors.Is(err, io.EOF) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid report schedule body", nil)
return err
}
return nil
}
func writeReportScheduleJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
type reportScheduleValidationError struct {
code string
message string
}
func (e reportScheduleValidationError) Error() string {
return e.message
}
func writeReportScheduleValidationError(w http.ResponseWriter, err error) {
var validationErr reportScheduleValidationError
if errors.As(err, &validationErr) {
writeErrorResponse(w, http.StatusBadRequest, validationErr.code, validationErr.message, nil)
return
}
writeErrorResponse(w, http.StatusBadRequest, "invalid_schedule", err.Error(), nil)
}
func (h *ReportingHandlers) reportSchedulePersistenceAndStore(ctx context.Context) (*config.ConfigPersistence, *config.ReportScheduleStore, error) {
persistence, err := h.reportSchedulePersistence(ctx)
if err != nil {
return nil, nil, err
}
store, err := persistence.LoadReportScheduleStore()
if err != nil {
return nil, nil, err
}
if store == nil {
empty := config.EmptyReportScheduleStore()
store = &empty
}
return persistence, store, nil
}
func (h *ReportingHandlers) loadReportScheduleStore(ctx context.Context) (*config.ReportScheduleStore, error) {
_, store, err := h.reportSchedulePersistenceAndStore(ctx)
return store, err
}
func (h *ReportingHandlers) reportSchedulePersistence(ctx context.Context) (*config.ConfigPersistence, error) {
if h == nil || h.mtMonitor == nil {
return nil, fmt.Errorf("multi-tenant monitor is not configured")
}
orgID := GetOrgID(ctx)
if strings.TrimSpace(orgID) == "" {
orgID = "default"
}
monitor, err := h.mtMonitor.GetMonitor(orgID)
if err != nil {
return nil, err
}
persistence := monitor.GetConfigPersistence()
if persistence == nil {
return nil, fmt.Errorf("config persistence is not configured")
}
return persistence, nil
}
func findReportScheduleIndex(schedules []config.ReportSchedule, id string) int {
for i := range schedules {
if schedules[i].ID == id {
return i
}
}
return -1
}
func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule config.ReportSchedule, now time.Time, statusOnly bool) (config.ReportSchedule, error) {
schedule = config.NormalizeReportSchedule(schedule)
if schedule.ID == "" {
return schedule, reportScheduleValidationError{code: "invalid_schedule_id", message: "Schedule ID is required"}
}
if statusOnly {
return schedule, nil
}
if schedule.Name == "" || len(schedule.Name) > 80 {
return schedule, reportScheduleValidationError{code: "invalid_name", message: "Schedule name is required and must be 80 characters or fewer"}
}
if schedule.Format == "" {
schedule.Format = config.ReportScheduleFormatPDF
}
if schedule.Format != config.ReportScheduleFormatPDF && schedule.Format != config.ReportScheduleFormatCSV {
return schedule, reportScheduleValidationError{code: "invalid_format", message: "Schedule format must be pdf or csv"}
}
if schedule.Delivery.Method == "" {
schedule.Delivery.Method = config.ReportScheduleDeliveryEmail
}
if schedule.Delivery.Method != config.ReportScheduleDeliveryEmail && schedule.Delivery.Method != config.ReportScheduleDeliveryDisk {
return schedule, reportScheduleValidationError{code: "invalid_delivery", message: "Delivery method must be email or disk"}
}
if !schedule.Delivery.Attach && !schedule.Delivery.SaveToDisk {
schedule.Delivery.Attach = true
schedule.Delivery.SaveToDisk = true
}
if err := validateReportScheduleCadence(schedule.Cadence); err != nil {
return schedule, err
}
if err := h.validateReportScheduleScope(ctx, schedule.Scope); err != nil {
return schedule, err
}
next, err := nextReportScheduleRunAt(schedule, now)
if err != nil {
return schedule, err
}
schedule.NextRunAt = &next
return schedule, nil
}
func validateReportScheduleCadence(cadence config.ReportScheduleCadence) error {
if cadence.Type != config.ReportScheduleCadenceMonthly && cadence.Type != config.ReportScheduleCadenceWeekly {
return reportScheduleValidationError{code: "invalid_cadence", message: "Cadence must be monthly or weekly"}
}
if cadence.Type == config.ReportScheduleCadenceMonthly && (cadence.DayOfMonth < 1 || cadence.DayOfMonth > 28) {
return reportScheduleValidationError{code: "invalid_day_of_month", message: "Monthly schedules must use a day from 1 to 28"}
}
if cadence.Type == config.ReportScheduleCadenceWeekly {
if _, ok := parseReportScheduleWeekday(cadence.Weekday); !ok {
return reportScheduleValidationError{code: "invalid_weekday", message: "Weekly schedules must include a weekday"}
}
}
if _, err := time.Parse("15:04", cadence.Time); err != nil {
return reportScheduleValidationError{code: "invalid_time", message: "Schedule time must use HH:MM"}
}
if _, err := reportScheduleLocation(cadence.Timezone); err != nil {
return reportScheduleValidationError{code: "invalid_timezone", message: "Schedule timezone must be a valid IANA timezone"}
}
return nil
}
func (h *ReportingHandlers) validateReportScheduleScope(ctx context.Context, scope config.ReportScheduleScope) error {
if len(scope.Resources) == 0 && len(scope.Tags) == 0 {
return reportScheduleValidationError{code: "invalid_scope", message: "Select at least one resource or tag"}
}
definition := performanceReportDefinition()
if len(scope.Resources) > definition.MultiResourceMax {
return reportScheduleValidationError{code: "too_many_resources", message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)}
}
for _, res := range scope.Resources {
if !validResourceID.MatchString(res.ResourceID) || len(res.ResourceID) > 128 {
return reportScheduleValidationError{code: "invalid_resource_id", message: "Resource IDs must match [a-zA-Z0-9._:-]+ and be 128 characters or fewer"}
}
if _, err := normalizeReportResourceType(res.ResourceType); err != nil {
return reportScheduleValidationError{code: "invalid_resource_type", message: err.Error()}
}
}
for _, tag := range scope.Tags {
if len(tag) > 64 || strings.ContainsAny(tag, "\r\n\t") {
return reportScheduleValidationError{code: "invalid_tag", message: "Tags must be 64 characters or fewer and cannot contain control characters"}
}
}
if len(scope.Tags) > 0 {
if _, err := h.resolveReportScheduleResources(ctx, GetOrgID(ctx), scope); err != nil {
return err
}
}
return nil
}
func (h *ReportingHandlers) resolveReportScheduleResources(ctx context.Context, orgID string, scope config.ReportScheduleScope) ([]multiReportResourceEntry, error) {
definition := performanceReportDefinition()
entries := make([]multiReportResourceEntry, 0, len(scope.Resources))
seen := map[string]struct{}{}
appendEntry := func(resourceType, resourceID string) error {
resourceType, err := normalizeReportResourceType(resourceType)
if err != nil {
return err
}
key := resourceType + "\x00" + resourceID
if _, exists := seen[key]; exists {
return nil
}
seen[key] = struct{}{}
entries = append(entries, multiReportResourceEntry{ResourceType: resourceType, ResourceID: resourceID})
return nil
}
for _, res := range scope.Resources {
if err := appendEntry(res.ResourceType, res.ResourceID); err != nil {
return nil, reportScheduleValidationError{code: "invalid_resource_type", message: err.Error()}
}
}
if len(scope.Tags) > 0 {
snapshot, ok := h.getReportingEnrichmentSnapshot(ctx, orgID)
if !ok {
return nil, reportScheduleValidationError{code: "scope_unavailable", message: "Tagged schedule scope cannot be resolved until resource inventory is available"}
}
tags := map[string]struct{}{}
for _, tag := range scope.Tags {
tags[strings.ToLower(strings.TrimSpace(tag))] = struct{}{}
}
for _, resource := range snapshot.Resources {
if !resourceHasAnyReportScheduleTag(resource, tags) {
continue
}
resourceType := reporting.CanonicalResourceType(string(resource.Type))
if resourceType == "" || strings.TrimSpace(resource.ID) == "" {
continue
}
if err := appendEntry(resourceType, resource.ID); err != nil {
continue
}
}
}
if len(entries) == 0 {
return nil, reportScheduleValidationError{code: "empty_scope", message: "Schedule scope did not match any reportable resources"}
}
if len(entries) > definition.MultiResourceMax {
return nil, reportScheduleValidationError{code: "too_many_resources", message: fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)}
}
return entries, nil
}
func resourceHasAnyReportScheduleTag(resource unifiedresources.Resource, tags map[string]struct{}) bool {
for _, tag := range resource.Tags {
if _, ok := tags[strings.ToLower(strings.TrimSpace(tag))]; ok {
return true
}
}
return false
}
type reportScheduleRunResult struct {
path string
email string
}
func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence *config.ConfigPersistence, schedule config.ReportSchedule, now time.Time, manual bool, occurrenceKey string) (reportScheduleRunResult, config.ReportSchedule) {
h.scheduleRunMu.Lock()
defer h.scheduleRunMu.Unlock()
schedule = config.NormalizeReportSchedule(schedule)
result := reportScheduleRunResult{}
schedule.LastRunAt = &now
schedule.UpdatedAt = now
if occurrenceKey != "" {
schedule.LastOccurrenceKey = occurrenceKey
}
if next, err := nextReportScheduleRunAt(schedule, now); err == nil {
schedule.NextRunAt = &next
}
orgID := GetOrgID(ctx)
resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
start, end, err := reportScheduleWindow(schedule, now, manual)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
report, err := h.generateMultiReportFromBody(ctx, multiReportRequestBody{
Resources: resources,
Format: schedule.Format,
Start: start.UTC().Format(time.RFC3339),
End: end.UTC().Format(time.RFC3339),
Title: schedule.Name,
}, now)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
path, err := saveGeneratedReport(persistence, schedule, report)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
result.path = path
if schedule.Delivery.Method == config.ReportScheduleDeliveryEmail {
emailStatus, err := sendScheduledReportEmail(persistence, schedule, report, path)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
result.email = emailStatus
}
schedule.LastRunStatus = config.ReportScheduleLastRunOK
schedule.LastError = ""
return result, schedule
}
func markReportScheduleFailed(schedule config.ReportSchedule, now time.Time, err error) config.ReportSchedule {
schedule.LastRunStatus = config.ReportScheduleLastRunFailed
schedule.LastError = strings.TrimSpace(err.Error())
schedule.LastRunAt = &now
schedule.UpdatedAt = now
return schedule
}
func reportScheduleWindow(schedule config.ReportSchedule, now time.Time, manual bool) (time.Time, time.Time, error) {
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
if err != nil {
return time.Time{}, time.Time{}, err
}
end := now.In(location)
if !manual {
occurrence, _, err := lastReportScheduleOccurrenceAt(schedule, now)
if err == nil {
end = occurrence.In(location)
}
}
switch schedule.Cadence.Type {
case config.ReportScheduleCadenceMonthly:
firstThisMonth := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, location)
firstPreviousMonth := firstThisMonth.AddDate(0, -1, 0)
return firstPreviousMonth.UTC(), firstThisMonth.UTC(), nil
case config.ReportScheduleCadenceWeekly:
return end.AddDate(0, 0, -7).UTC(), end.UTC(), nil
default:
return time.Time{}, time.Time{}, fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
}
}
func saveGeneratedReport(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport) (string, error) {
baseDir, err := securityutil.JoinStorageLeaf(persistence.DataDir(), "reports")
if err != nil {
return "", err
}
generatedDir, err := securityutil.JoinStorageLeaf(baseDir, "generated")
if err != nil {
return "", err
}
scheduleDir, err := securityutil.JoinStorageLeaf(generatedDir, schedule.ID)
if err != nil {
return "", err
}
if err := os.MkdirAll(scheduleDir, 0o700); err != nil {
return "", fmt.Errorf("create report output directory: %w", err)
}
filename := sanitizeFilename(report.Filename)
if filename == "" {
filename = "report." + string(report.Format)
}
path := filepath.Join(scheduleDir, filename)
if err := os.WriteFile(path, report.Data, 0o600); err != nil {
return "", fmt.Errorf("write generated report: %w", err)
}
pruneGeneratedReports(scheduleDir, schedule.RetentionCount)
return path, nil
}
func pruneGeneratedReports(dir string, retentionCount int) {
if retentionCount <= 0 {
retentionCount = config.DefaultReportScheduleRetentionCount
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type generatedFile struct {
path string
modTime time.Time
}
files := make([]generatedFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, generatedFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool {
return files[i].modTime.After(files[j].modTime)
})
for i := retentionCount; i < len(files); i++ {
_ = os.Remove(files[i].path)
}
}
func sendScheduledReportEmail(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport, path string) (string, error) {
emailCfg, err := persistence.LoadEmailConfig()
if err != nil {
return "", fmt.Errorf("load email config: %w", err)
}
if emailCfg == nil || !emailCfg.Enabled {
return "saved_to_disk_email_unconfigured", nil
}
recipients := schedule.Delivery.To
if len(recipients) == 0 {
recipients = emailCfg.To
}
if len(recipients) == 0 && strings.TrimSpace(emailCfg.From) != "" {
recipients = []string{emailCfg.From}
}
if len(recipients) == 0 {
return "saved_to_disk_email_unconfigured", nil
}
providerConfig := notifications.EmailProviderConfig{
EmailConfig: notifications.EmailConfig{
Provider: emailCfg.Provider,
From: emailCfg.From,
To: recipients,
SMTPHost: emailCfg.SMTPHost,
SMTPPort: emailCfg.SMTPPort,
Username: emailCfg.Username,
Password: emailCfg.Password,
TLS: emailCfg.TLS,
StartTLS: emailCfg.StartTLS,
RateLimit: emailCfg.RateLimit,
},
Provider: emailCfg.Provider,
MaxRetries: 2,
RetryDelay: 3,
RateLimit: emailCfg.RateLimit,
StartTLS: emailCfg.StartTLS,
AuthRequired: emailCfg.Username != "" && emailCfg.Password != "",
}
manager := notifications.NewEnhancedEmailManager(providerConfig)
subject := "Pulse report: " + schedule.Name
htmlBody := "<p>Your scheduled Pulse report is ready.</p>"
textBody := "Your scheduled Pulse report is ready."
attachments := []notifications.EmailAttachment{}
if schedule.Delivery.Attach && len(report.Data) <= reportScheduleAttachmentLimitBytes {
attachments = append(attachments, notifications.EmailAttachment{
Filename: report.Filename,
ContentType: report.ContentType,
Data: report.Data,
})
} else if schedule.Delivery.Attach && len(report.Data) > reportScheduleAttachmentLimitBytes {
htmlBody = "<p>Your scheduled Pulse report was generated but is too large to attach.</p><p>Saved path: " + htmlEscape(path) + "</p>"
textBody = "Your scheduled Pulse report was generated but is too large to attach.\nSaved path: " + path
} else {
htmlBody = "<p>Your scheduled Pulse report was generated and saved to disk.</p><p>Saved path: " + htmlEscape(path) + "</p>"
textBody = "Your scheduled Pulse report was generated and saved to disk.\nSaved path: " + path
}
if err := manager.SendEmailWithAttachments(subject, htmlBody, textBody, attachments); err != nil {
return "", err
}
if len(attachments) > 0 {
return "sent_with_attachment", nil
}
return "sent_saved_path", nil
}
func htmlEscape(value string) string {
replacer := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;")
return replacer.Replace(value)
}
func reportScheduleLocation(name string) (*time.Location, error) {
name = strings.TrimSpace(name)
if name == "" || name == "Local" {
name = "UTC"
}
return time.LoadLocation(name)
}
func parseReportScheduleClock(clock string) (hour, minute int, err error) {
parsed, err := time.Parse("15:04", strings.TrimSpace(clock))
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func parseReportScheduleWeekday(value string) (time.Weekday, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "sunday", "sun":
return time.Sunday, true
case "monday", "mon":
return time.Monday, true
case "tuesday", "tue":
return time.Tuesday, true
case "wednesday", "wed":
return time.Wednesday, true
case "thursday", "thu":
return time.Thursday, true
case "friday", "fri":
return time.Friday, true
case "saturday", "sat":
return time.Saturday, true
default:
return time.Sunday, false
}
}
func nextReportScheduleRunAt(schedule config.ReportSchedule, after time.Time) (time.Time, error) {
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
if err != nil {
return time.Time{}, err
}
hour, minute, err := parseReportScheduleClock(schedule.Cadence.Time)
if err != nil {
return time.Time{}, err
}
localAfter := after.In(location)
switch schedule.Cadence.Type {
case config.ReportScheduleCadenceMonthly:
candidate := time.Date(localAfter.Year(), localAfter.Month(), schedule.Cadence.DayOfMonth, hour, minute, 0, 0, location)
if !candidate.After(localAfter) {
candidate = candidate.AddDate(0, 1, 0)
}
return candidate.UTC(), nil
case config.ReportScheduleCadenceWeekly:
weekday, ok := parseReportScheduleWeekday(schedule.Cadence.Weekday)
if !ok {
return time.Time{}, fmt.Errorf("invalid weekday")
}
dayDelta := (int(weekday) - int(localAfter.Weekday()) + 7) % 7
candidateDate := localAfter.AddDate(0, 0, dayDelta)
candidate := time.Date(candidateDate.Year(), candidateDate.Month(), candidateDate.Day(), hour, minute, 0, 0, location)
if !candidate.After(localAfter) {
candidate = candidate.AddDate(0, 0, 7)
}
return candidate.UTC(), nil
default:
return time.Time{}, fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
}
}
func lastReportScheduleOccurrenceAt(schedule config.ReportSchedule, now time.Time) (time.Time, string, error) {
location, err := reportScheduleLocation(schedule.Cadence.Timezone)
if err != nil {
return time.Time{}, "", err
}
hour, minute, err := parseReportScheduleClock(schedule.Cadence.Time)
if err != nil {
return time.Time{}, "", err
}
localNow := now.In(location)
var occurrence time.Time
switch schedule.Cadence.Type {
case config.ReportScheduleCadenceMonthly:
occurrence = time.Date(localNow.Year(), localNow.Month(), schedule.Cadence.DayOfMonth, hour, minute, 0, 0, location)
if occurrence.After(localNow) {
occurrence = occurrence.AddDate(0, -1, 0)
}
case config.ReportScheduleCadenceWeekly:
weekday, ok := parseReportScheduleWeekday(schedule.Cadence.Weekday)
if !ok {
return time.Time{}, "", fmt.Errorf("invalid weekday")
}
dayDelta := (int(localNow.Weekday()) - int(weekday) + 7) % 7
occurrenceDate := localNow.AddDate(0, 0, -dayDelta)
occurrence = time.Date(occurrenceDate.Year(), occurrenceDate.Month(), occurrenceDate.Day(), hour, minute, 0, 0, location)
if occurrence.After(localNow) {
occurrence = occurrence.AddDate(0, 0, -7)
}
default:
return time.Time{}, "", fmt.Errorf("unsupported cadence %q", schedule.Cadence.Type)
}
return occurrence.UTC(), occurrenceKey(schedule, occurrence), nil
}
func occurrenceKey(schedule config.ReportSchedule, occurrence time.Time) string {
timezone := strings.TrimSpace(schedule.Cadence.Timezone)
if timezone == "" {
timezone = "UTC"
}
return schedule.Cadence.Type + ":" + occurrence.UTC().Format("2006-01-02T15:04:05Z07:00") + ":" + timezone
}
func (h *ReportingHandlers) RunReportScheduleScheduler(ctx context.Context) {
if h == nil {
return
}
h.runDueReportSchedules(ctx, time.Now().UTC())
ticker := time.NewTicker(reportScheduleTickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
h.runDueReportSchedules(ctx, now.UTC())
}
}
}
func (h *ReportingHandlers) runDueReportSchedules(ctx context.Context, now time.Time) {
if h == nil || h.mtMonitor == nil {
return
}
orgIDs, err := h.mtMonitor.ListOrganizationIDs()
if err != nil {
log.Warn().Err(err).Msg("report schedules: list organizations")
return
}
for _, orgID := range orgIDs {
orgCtx := context.WithValue(ctx, OrgIDContextKey, orgID)
h.runDueReportSchedulesForOrg(orgCtx, orgID, now)
}
}
func (h *ReportingHandlers) runDueReportSchedulesForOrg(ctx context.Context, orgID string, now time.Time) {
persistence, store, err := h.reportSchedulePersistenceAndStore(ctx)
if err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: load store")
return
}
changed := false
for i := range store.Schedules {
schedule := config.NormalizeReportSchedule(store.Schedules[i])
if !schedule.Enabled {
continue
}
occurrence, key, err := lastReportScheduleOccurrenceAt(schedule, now)
if err != nil {
store.Schedules[i] = markReportScheduleFailed(schedule, now, err)
changed = true
continue
}
if key == "" || key == schedule.LastOccurrenceKey || occurrence.After(now) {
continue
}
if now.Sub(occurrence) > reportScheduleMissedRunGrace {
schedule.LastOccurrenceKey = key
if next, err := nextReportScheduleRunAt(schedule, now); err == nil {
schedule.NextRunAt = &next
}
schedule.UpdatedAt = now
store.Schedules[i] = schedule
changed = true
continue
}
_, updated := h.runReportSchedule(ctx, persistence, schedule, now, false, key)
store.Schedules[i] = updated
changed = true
}
if changed {
if err := persistence.SaveReportScheduleStore(*store); err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("report schedules: save statuses")
}
}
}