Files
upstream-mattermost/server/public/model/scheduled_recap.go
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

270 lines
9.5 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
// Day-of-week bitmask constants following Go's time.Weekday (Sunday=0)
const (
Sunday = 1 << 0 // 1
Monday = 1 << 1 // 2
Tuesday = 1 << 2 // 4
Wednesday = 1 << 3 // 8
Thursday = 1 << 4 // 16
Friday = 1 << 5 // 32
Saturday = 1 << 6 // 64
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday // 62
Weekend = Saturday | Sunday // 65
EveryDay = Weekdays | Weekend // 127
)
// Channel mode constants
const (
ChannelModeSpecific = "specific"
ChannelModeAllUnreads = "all_unreads"
)
// Time period constants
const (
TimePeriodLast24h = "last_24h"
TimePeriodLastWeek = "last_week"
TimePeriodSinceLastRead = "since_last_read"
)
// Validation constants
const (
ScheduledRecapTitleMaxLength = 255
ScheduledRecapCustomInstructionsMaxLength = 500
ScheduledRecapMinDaysOfWeek = 1
ScheduledRecapMaxDaysOfWeek = 127
)
// timeOfDayRegex validates HH:MM format (00:00 to 23:59)
var timeOfDayRegex = regexp.MustCompile(`^([0-1][0-9]|2[0-3]):([0-5][0-9])$`)
// ScheduledRecap represents a user's scheduled recap configuration
type ScheduledRecap struct {
Id string `json:"id"`
UserId string `json:"user_id"`
Title string `json:"title"`
// Schedule configuration (user intent)
DaysOfWeek int `json:"days_of_week"` // Bitmask: Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64
TimeOfDay string `json:"time_of_day"` // HH:MM format (e.g., "09:00")
Timezone string `json:"timezone"` // IANA timezone (e.g., "America/New_York")
TimePeriod string `json:"time_period"` // "last_24h", "last_week", "since_last_read"
// Schedule state (computed)
NextRunAt int64 `json:"next_run_at"` // UTC milliseconds, computed from schedule + timezone
LastRunAt int64 `json:"last_run_at"` // UTC milliseconds, updated after each run
RunCount int `json:"run_count"` // Number of times this schedule has executed
// Channel configuration
ChannelMode string `json:"channel_mode"` // "specific" or "all_unreads"
ChannelIds StringArray `json:"channel_ids,omitempty"` // channel IDs (when mode = "specific"); persisted as jsonb via StringArray
// AI configuration
CustomInstructions string `json:"custom_instructions,omitempty"` // Custom AI instructions
AgentId string `json:"agent_id"` // AI agent to use
// Schedule type and state
IsRecurring bool `json:"is_recurring"` // false for "run once" schedules
Enabled bool `json:"enabled"` // false when paused
// Standard timestamps
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"` // Soft delete
}
func deduplicateChannelIDs(channelIDs []string) []string {
if len(channelIDs) < 2 {
return channelIDs
}
seen := make(map[string]struct{}, len(channelIDs))
deduplicated := make([]string, 0, len(channelIDs))
for _, channelID := range channelIDs {
if _, ok := seen[channelID]; ok {
continue
}
seen[channelID] = struct{}{}
deduplicated = append(deduplicated, channelID)
}
return deduplicated
}
// ComputeNextRunAt calculates the next scheduled execution time in UTC milliseconds.
// It uses the user's timezone to determine the correct local time, handling DST automatically.
func (sr *ScheduledRecap) ComputeNextRunAt(fromTime time.Time) (int64, error) {
// Load user's timezone
loc, err := time.LoadLocation(sr.Timezone)
if err != nil {
return 0, NewAppError("ScheduledRecap.ComputeNextRunAt", "model.scheduled_recap.compute_next_run.timezone.app_error", nil, "timezone="+sr.Timezone, http.StatusBadRequest)
}
// Validate time of day format using regex
if !timeOfDayRegex.MatchString(sr.TimeOfDay) {
return 0, NewAppError("ScheduledRecap.ComputeNextRunAt", "model.scheduled_recap.compute_next_run.time_format.app_error", nil, "time_of_day="+sr.TimeOfDay, http.StatusBadRequest)
}
// Parse time of day
parts := strings.Split(sr.TimeOfDay, ":")
hour, _ := strconv.Atoi(parts[0])
minute, _ := strconv.Atoi(parts[1])
// Validate days of week
if sr.DaysOfWeek < ScheduledRecapMinDaysOfWeek || sr.DaysOfWeek > ScheduledRecapMaxDaysOfWeek {
return 0, NewAppError("ScheduledRecap.ComputeNextRunAt", "model.scheduled_recap.compute_next_run.days_of_week.app_error", nil, "days_of_week="+strconv.Itoa(sr.DaysOfWeek), http.StatusBadRequest)
}
// Convert fromTime to user's timezone
localNow := fromTime.In(loc)
// Start searching from today
candidate := time.Date(
localNow.Year(), localNow.Month(), localNow.Day(),
hour, minute, 0, 0,
loc,
)
// If today's time has passed, start from tomorrow
if !candidate.After(localNow) {
candidate = candidate.AddDate(0, 0, 1)
}
// Find next matching day of week (max 7 iterations)
for range 7 {
weekday := int(candidate.Weekday()) // 0=Sunday
dayBit := 1 << weekday
if sr.DaysOfWeek&dayBit != 0 {
// Found a matching day - return as UTC milliseconds
return candidate.UnixMilli(), nil
}
candidate = candidate.AddDate(0, 0, 1)
}
return 0, NewAppError("ScheduledRecap.ComputeNextRunAt", "model.scheduled_recap.compute_next_run.no_valid_day.app_error", nil, "", http.StatusBadRequest)
}
// IsValid validates the scheduled recap configuration
func (sr *ScheduledRecap) IsValid() *AppError {
if !IsValidId(sr.Id) {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.id.app_error", nil, "id="+sr.Id, http.StatusBadRequest)
}
if !IsValidId(sr.UserId) {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.user_id.app_error", nil, "user_id="+sr.UserId, http.StatusBadRequest)
}
if sr.Title == "" {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.title_empty.app_error", nil, "", http.StatusBadRequest)
}
if len(sr.Title) > ScheduledRecapTitleMaxLength {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.title_length.app_error", nil, "title_length="+strconv.Itoa(len(sr.Title)), http.StatusBadRequest)
}
if len(sr.CustomInstructions) > ScheduledRecapCustomInstructionsMaxLength {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.custom_instructions_length.app_error", nil, "custom_instructions_length="+strconv.Itoa(len(sr.CustomInstructions)), http.StatusBadRequest)
}
if sr.DaysOfWeek < ScheduledRecapMinDaysOfWeek || sr.DaysOfWeek > ScheduledRecapMaxDaysOfWeek {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.days_of_week.app_error", nil, "days_of_week="+strconv.Itoa(sr.DaysOfWeek), http.StatusBadRequest)
}
if !timeOfDayRegex.MatchString(sr.TimeOfDay) {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.time_of_day.app_error", nil, "time_of_day="+sr.TimeOfDay, http.StatusBadRequest)
}
// Validate timezone by attempting to load it
if _, err := time.LoadLocation(sr.Timezone); err != nil {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.timezone.app_error", nil, "timezone="+sr.Timezone, http.StatusBadRequest)
}
if sr.TimePeriod != TimePeriodLast24h && sr.TimePeriod != TimePeriodLastWeek && sr.TimePeriod != TimePeriodSinceLastRead {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.time_period.app_error", nil, "time_period="+sr.TimePeriod, http.StatusBadRequest)
}
if sr.ChannelMode != ChannelModeSpecific && sr.ChannelMode != ChannelModeAllUnreads {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.channel_mode.app_error", nil, "channel_mode="+sr.ChannelMode, http.StatusBadRequest)
}
if sr.ChannelMode == ChannelModeSpecific {
if len(sr.ChannelIds) == 0 {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.channel_ids_empty.app_error", nil, "", http.StatusBadRequest)
}
for _, channelID := range sr.ChannelIds {
if !IsValidId(channelID) {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.channel_id.app_error", nil, "channel_id="+channelID, http.StatusBadRequest)
}
}
}
if sr.AgentId == "" {
return NewAppError("ScheduledRecap.IsValid", "model.scheduled_recap.is_valid.agent_id.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
// PreSave prepares the scheduled recap for initial save
func (sr *ScheduledRecap) PreSave() {
sr.ChannelIds = deduplicateChannelIDs(sr.ChannelIds)
if sr.Id == "" {
sr.Id = NewId()
}
if sr.CreateAt == 0 {
sr.CreateAt = GetMillis()
}
if sr.UpdateAt == 0 {
sr.UpdateAt = sr.CreateAt
}
}
// PreUpdate prepares the scheduled recap for update
func (sr *ScheduledRecap) PreUpdate() {
sr.ChannelIds = deduplicateChannelIDs(sr.ChannelIds)
sr.UpdateAt = GetMillis()
}
// Auditable returns a map of safe-to-log fields for audit logging
func (sr *ScheduledRecap) Auditable() map[string]any {
return map[string]any{
"id": sr.Id,
"user_id": sr.UserId,
"title": sr.Title,
"days_of_week": sr.DaysOfWeek,
"time_of_day": sr.TimeOfDay,
"timezone": sr.Timezone,
"time_period": sr.TimePeriod,
"next_run_at": sr.NextRunAt,
"last_run_at": sr.LastRunAt,
"run_count": sr.RunCount,
"channel_mode": sr.ChannelMode,
"channel_ids": sr.ChannelIds,
"agent_id": sr.AgentId,
"is_recurring": sr.IsRecurring,
"enabled": sr.Enabled,
"create_at": sr.CreateAt,
"update_at": sr.UpdateAt,
"delete_at": sr.DeleteAt,
}
}