Ctrl K
ComunidadGuías…

internal/adminapi/schedule_handler.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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]) + "..."
}