package adminapi import ( "encoding/json" "errors" "io" "net/http" "strconv" "strings" "time" "github.com/yeomyeonggeori/blueclaw/internal/task" "github.com/yeomyeonggeori/blueprotocol/agentcontract" ) type ScheduleSummaryRepository interface { SummarizeActiveSchedules(time.Time) (task.ScheduleSummary, error) } type ScheduleListRepository interface { ListSchedules(task.ScheduleListRequest) (task.ScheduleListResult, error) UpsertSchedule(task.Schedule) error UpdateSchedule(task.ScheduleUpdateRequest) (task.ScheduleUpdateResult, error) DeleteSchedule(task.ScheduleDeleteRequest) (task.ScheduleDeleteResult, error) CancelSchedules(task.ScheduleCancelRequest) (task.ScheduleCancelResult, error) } type ScheduleCreatorRepairRepository interface { RepairScheduleCreatorPersonID(task.ScheduleCreatorRepairRequest) (task.ScheduleCreatorRepairResult, error) } type ScheduleHandler struct { SummaryRepository ScheduleSummaryRepository ListRepository ScheduleListRepository RepairRepository ScheduleCreatorRepairRepository CompanyProvider func() agentcontract.CompanyContext ReaderPersonID func(*http.Request) string } func (scheduleHandler ScheduleHandler) companyTimeZone() string { if scheduleHandler.CompanyProvider == nil { return "" } return scheduleHandler.CompanyProvider().TimeZone } type scheduleCreatorRepairRequest struct { FromCreatorPersonID string `json:"fromCreatorPersonID"` ToCreatorPersonID string `json:"toCreatorPersonID"` } type scheduleCancelRequest struct { ScheduleID string `json:"taskScheduleID"` CreatorPersonID string `json:"creatorPersonID"` } type scheduleDeleteRequest struct { ScheduleID string `json:"taskScheduleID"` CreatorPersonID string `json:"creatorPersonID"` } type scheduleUpdateRequest struct { ScheduleID string `json:"taskScheduleID"` CreatorPersonID string `json:"creatorPersonID"` Name *string `json:"name"` Kind *string `json:"kind"` RunAt *string `json:"runAt"` IntervalSecond *int `json:"intervalSecond"` CronExpression *string `json:"cronExpression"` TimeZone *string `json:"timeZone"` ExpiresAt *string `json:"expiresAt"` MaxRunCount *int `json:"maxRunCount"` RepeatPolicy *string `json:"repeatPolicy"` } type scheduleToolListRequest struct { Status string `json:"status"` Limit int `json:"limit"` Cursor string `json:"cursor"` } func (scheduleHandler ScheduleHandler) HandleSummary(responseWriter http.ResponseWriter, request *http.Request) { if scheduleHandler.SummaryRepository == nil { http.Error(responseWriter, "task schedule summary repository is not configured", http.StatusServiceUnavailable) return } summary, errorValue := scheduleHandler.SummaryRepository.SummarizeActiveSchedules(time.Now().UTC()) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } writeJSON(responseWriter, http.StatusOK, summary) } func (scheduleHandler ScheduleHandler) HandleList(responseWriter http.ResponseWriter, request *http.Request) { if scheduleHandler.ListRepository == nil { http.Error(responseWriter, "task schedule list repository is not configured", http.StatusServiceUnavailable) return } result, errorValue := scheduleHandler.ListRepository.ListSchedules(scheduleListRequestFromHTTP(request)) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } writeJSON(responseWriter, http.StatusOK, map[string]any{ "schedules": scheduleListItems(result.Schedules), "count": len(result.Schedules), "totalCount": result.TotalCount, "page": result.Page, "pageSize": result.PageSize, "checkedAt": time.Now().UTC(), }) } func (scheduleHandler ScheduleHandler) HandleToolList(responseWriter http.ResponseWriter, request *http.Request) { creatorPersonID := scheduleHandler.signedPrincipal(request) if creatorPersonID == "" { http.Error(responseWriter, "schedule list authorization required", http.StatusForbidden) return } if scheduleHandler.ListRepository == nil { http.Error(responseWriter, "task schedule list repository is not configured", http.StatusServiceUnavailable) return } var input scheduleToolListRequest decoder := json.NewDecoder(request.Body) decoder.DisallowUnknownFields() if errorValue := decoder.Decode(&input); errorValue != nil { http.Error(responseWriter, "invalid schedule list request", http.StatusBadRequest) return } if errorValue := decoder.Decode(&struct{}{}); errorValue != io.EOF { http.Error(responseWriter, "invalid schedule list request", http.StatusBadRequest) return } referenceTime := time.Now().UTC() schedules, errorValue := scheduleHandler.everyScheduleCreatedBy(creatorPersonID, referenceTime) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } output, errorValue := task.ProjectScheduleList(schedules, task.ScheduleListInput{ Status: input.Status, Limit: input.Limit, Cursor: input.Cursor, }, referenceTime) if errors.Is(errorValue, task.ErrScheduleListCursor) { http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest) return } writeJSON(responseWriter, http.StatusOK, output) } func (scheduleHandler ScheduleHandler) everyScheduleCreatedBy(creatorPersonID string, referenceTime time.Time) ([]task.Schedule, error) { schedules := []task.Schedule{} for page := 1; ; page++ { result, errorValue := scheduleHandler.ListRepository.ListSchedules(task.ScheduleListQuery(creatorPersonID, referenceTime, page)) if errorValue != nil { return nil, errorValue } schedules = append(schedules, result.Schedules...) if len(result.Schedules) == 0 || page*result.PageSize >= result.TotalCount { return schedules, nil } } } func (scheduleHandler ScheduleHandler) HandleCancel(responseWriter http.ResponseWriter, request *http.Request) { if scheduleHandler.ListRepository == nil { http.Error(responseWriter, "task schedule repository is not configured", http.StatusServiceUnavailable) return } var cancelRequest scheduleCancelRequest if errorValue := json.NewDecoder(request.Body).Decode(&cancelRequest); errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest) return } scheduleID := strings.TrimSpace(cancelRequest.ScheduleID) creatorPersonID := strings.TrimSpace(cancelRequest.CreatorPersonID) if scheduleID == "" || creatorPersonID == "" { http.Error(responseWriter, "taskScheduleID and creatorPersonID are required", http.StatusBadRequest) return } schedule, found, errorValue := scheduleHandler.findSchedule(scheduleID) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } if !found { http.Error(responseWriter, "task schedule not found", http.StatusNotFound) return } if schedule.CreatorPersonID != creatorPersonID { http.Error(responseWriter, "task schedule creator mismatch", http.StatusForbidden) return } result, errorValue := scheduleHandler.ListRepository.CancelSchedules(task.ScheduleCancelRequest{ Scope: task.ScheduleCancelScopeScheduleIDs, RequesterPersonID: creatorPersonID, ScheduleIDs: []string{scheduleID}, CancelledAt: time.Now().UTC(), }) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } writeJSON(responseWriter, http.StatusOK, result) } func (scheduleHandler ScheduleHandler) HandleDelete(responseWriter http.ResponseWriter, request *http.Request) { if scheduleHandler.ListRepository == nil { http.Error(responseWriter, "task schedule repository is not configured", http.StatusServiceUnavailable) return } var deleteRequest scheduleDeleteRequest if errorValue := json.NewDecoder(request.Body).Decode(&deleteRequest); errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest) return } scheduleID := strings.TrimSpace(deleteRequest.ScheduleID) creatorPersonID := strings.TrimSpace(deleteRequest.CreatorPersonID) if scheduleID == "" || creatorPersonID == "" { http.Error(responseWriter, "taskScheduleID and creatorPersonID are required", http.StatusBadRequest) return } schedule, found, errorValue := scheduleHandler.findSchedule(scheduleID) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } if !found { http.Error(responseWriter, "task schedule not found", http.StatusNotFound) return } if schedule.CreatorPersonID != creatorPersonID { http.Error(responseWriter, "task schedule creator mismatch", http.StatusForbidden) return } result, errorValue := scheduleHandler.ListRepository.DeleteSchedule(task.ScheduleDeleteRequest{ ScheduleID: scheduleID, RequesterPersonID: creatorPersonID, }) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } if !result.IsFound { http.Error(responseWriter, "task schedule not found", http.StatusNotFound) return } writeJSON(responseWriter, http.StatusOK, result) } func (scheduleHandler ScheduleHandler) HandleUpdate(responseWriter http.ResponseWriter, request *http.Request) { if scheduleHandler.ListRepository == nil { http.Error(responseWriter, "task schedule repository is not configured", http.StatusServiceUnavailable) return } var updateRequest scheduleUpdateRequest if errorValue := json.NewDecoder(request.Body).Decode(&updateRequest); errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest) return } scheduleID := strings.TrimSpace(updateRequest.ScheduleID) creatorPersonID := strings.TrimSpace(updateRequest.CreatorPersonID) if scheduleID == "" || creatorPersonID == "" { http.Error(responseWriter, "taskScheduleID and creatorPersonID are required", http.StatusBadRequest) return } schedule, found, errorValue := scheduleHandler.findSchedule(scheduleID) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } if !found { http.Error(responseWriter, "task schedule not found", http.StatusNotFound) return } if schedule.CreatorPersonID != creatorPersonID { http.Error(responseWriter, "task schedule creator mismatch", http.StatusForbidden) return } result, errorValue := scheduleHandler.ListRepository.UpdateSchedule(task.ScheduleUpdateRequest{ ScheduleID: scheduleID, RequesterPersonID: creatorPersonID, UpdateSchedule: func(existingSchedule task.Schedule) (task.Schedule, error) { return applyScheduleUpdateRequest(existingSchedule, updateRequest, scheduleHandler.companyTimeZone()) }, }) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest) return } if !result.IsFound { http.Error(responseWriter, "task schedule not found", http.StatusNotFound) return } writeJSON(responseWriter, http.StatusOK, result) } func (scheduleHandler ScheduleHandler) HandleRepairCreator(responseWriter http.ResponseWriter, request *http.Request) { if scheduleHandler.RepairRepository == nil { http.Error(responseWriter, "task schedule repair repository is not configured", http.StatusServiceUnavailable) return } var repairRequest scheduleCreatorRepairRequest if errorValue := json.NewDecoder(request.Body).Decode(&repairRequest); errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusBadRequest) return } fromCreatorPersonID := strings.TrimSpace(repairRequest.FromCreatorPersonID) toCreatorPersonID := strings.TrimSpace(repairRequest.ToCreatorPersonID) if fromCreatorPersonID == "" || toCreatorPersonID == "" { http.Error(responseWriter, "fromCreatorPersonID and toCreatorPersonID are required", http.StatusBadRequest) return } if fromCreatorPersonID == toCreatorPersonID { writeJSON(responseWriter, http.StatusOK, task.ScheduleCreatorRepairResult{}) return } result, errorValue := scheduleHandler.RepairRepository.RepairScheduleCreatorPersonID(task.ScheduleCreatorRepairRequest{ FromCreatorPersonID: fromCreatorPersonID, ToCreatorPersonID: toCreatorPersonID, }) if errorValue != nil { http.Error(responseWriter, errorValue.Error(), http.StatusInternalServerError) return } writeJSON(responseWriter, http.StatusOK, result) } type scheduleListItem struct { ScheduleID string `json:"taskScheduleID"` CreatorPersonID string `json:"creatorPersonID"` Name string `json:"name,omitempty"` ExecutionMode string `json:"executionMode"` Kind string `json:"kind"` IntervalSecond int `json:"intervalSecond,omitempty"` CronExpression string `json:"cronExpression,omitempty"` MaxRunCount int `json:"maxRunCount,omitempty"` CompletedRunCount int `json:"completedRunCount"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` NextRunAt *time.Time `json:"nextRunAt,omitempty"` LastRunAt *time.Time `json:"lastRunAt,omitempty"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` LastTaskRunID string `json:"lastTaskRunID,omitempty"` FailureCount int `json:"failureCount"` DeliveryChannelID string `json:"deliveryChannelID"` ReplyTargetID string `json:"replyTargetID,omitempty"` PromptPreview string `json:"promptPreview"` ApprovedCall *task.ScheduleApprovedCall `json:"approvedCall,omitempty"` } func scheduleListRequestFromHTTP(request *http.Request) task.ScheduleListRequest { queryValues := request.URL.Query() return task.ScheduleListRequest{ ConversationID: strings.TrimSpace(queryValues.Get("deliveryConversationID")), CreatorPersonID: strings.TrimSpace(queryValues.Get("creatorPersonID")), UnboundedOnly: parseBoolQuery(queryValues.Get("unboundedOnly")), IncludeExpired: parseBoolQuery(queryValues.Get("includeExpired")), Page: parsePositiveQuery(queryValues.Get("page"), 1), PageSize: parsePageSizeQuery(queryValues.Get("pageSize")), ReferenceTime: time.Now().UTC(), } } func (scheduleHandler ScheduleHandler) findSchedule(scheduleID string) (task.Schedule, bool, error) { page := 1 for { result, errorValue := scheduleHandler.ListRepository.ListSchedules(task.ScheduleListRequest{ IncludeExpired: true, Page: page, PageSize: 200, ReferenceTime: time.Now().UTC(), }) if errorValue != nil { return task.Schedule{}, false, errorValue } for _, schedule := range result.Schedules { if schedule.ScheduleID == scheduleID { return schedule, true, nil } } if len(result.Schedules) == 0 || page*result.PageSize >= result.TotalCount { return task.Schedule{}, false, nil } page++ } } func applyScheduleUpdateRequest(schedule task.Schedule, request scheduleUpdateRequest, companyTimeZone string) (task.Schedule, error) { return task.ApplyScheduleUpdate(schedule, task.ScheduleUpdateInput{ Description: request.Name, Kind: request.Kind, RunAt: request.RunAt, ExpiresAt: request.ExpiresAt, IntervalSecond: request.IntervalSecond, CronExpression: request.CronExpression, TimeZone: request.TimeZone, MaxRunCount: request.MaxRunCount, RepeatPolicy: request.RepeatPolicy, }, companyTimeZone, time.Now().UTC()) } func scheduleListItems(schedules []task.Schedule) []scheduleListItem { items := []scheduleListItem{} for _, schedule := range schedules { items = append(items, scheduleListItem{ ScheduleID: schedule.ScheduleID, CreatorPersonID: schedule.CreatorPersonID, Name: schedule.Name, ExecutionMode: string(schedule.ExecutionMode), Kind: string(schedule.Kind), IntervalSecond: schedule.IntervalSecond, CronExpression: schedule.CronExpression, MaxRunCount: schedule.MaxRunCount, CompletedRunCount: schedule.CompletedRunCount, CreatedAt: schedule.CreatedAt, UpdatedAt: schedule.UpdatedAt, NextRunAt: schedule.NextRunAt, LastRunAt: schedule.LastRunAt, ExpiresAt: schedule.ExpiresAt, LastTaskRunID: schedule.LastTaskRunID, FailureCount: schedule.FailureCount, DeliveryChannelID: schedule.ConversationID, ReplyTargetID: schedule.ReplyTargetID, PromptPreview: compactPromptPreview(schedule.Prompt, 160), ApprovedCall: schedule.ApprovedCall, }) } return items } func parseBoolQuery(value string) bool { switch strings.TrimSpace(strings.ToLower(value)) { case "1", "true", "yes": return true default: return false } } func parsePositiveQuery(value string, fallback int) int { number, errorValue := strconv.Atoi(strings.TrimSpace(value)) if errorValue != nil { return fallback } if number < 1 { return fallback } return number } func parsePageSizeQuery(value string) int { pageSize := parsePositiveQuery(value, 50) if pageSize > 200 { return 200 } return pageSize } func compactPromptPreview(value string, limit int) string { words := strings.Fields(value) preview := strings.Join(words, " ") if limit <= 0 || len([]rune(preview)) <= limit { return preview } return string([]rune(preview)[:limit]) + "..." }