package app import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/sync/singleflight" "github.com/geo-platform/tenant-api/internal/shared/auditlog" "github.com/geo-platform/tenant-api/internal/shared/auth" sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" "github.com/geo-platform/tenant-api/internal/shared/middleware" "github.com/geo-platform/tenant-api/internal/shared/response" sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule" ) type ScheduleTaskService struct { pool *pgxpool.Pool auditLogs *auditlog.AsyncWriter cache sharedcache.Cache cacheGroup singleflight.Group } func NewScheduleTaskService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *ScheduleTaskService { return &ScheduleTaskService{pool: pool, auditLogs: auditLogs} } func (s *ScheduleTaskService) WithCache(c sharedcache.Cache) *ScheduleTaskService { s.cache = c return s } type ScheduleTaskRequest struct { PromptRuleID int64 `json:"prompt_rule_id" binding:"required"` BrandID *int64 `json:"brand_id"` Name string `json:"name" binding:"required"` CronExpr string `json:"cron_expr" binding:"required"` AutoPublish *bool `json:"auto_publish"` PublishAccountIDs []string `json:"publish_account_ids"` CoverAssetURL *string `json:"cover_asset_url"` CoverImageAssetID *int64 `json:"cover_image_asset_id"` EnableWebSearch *bool `json:"enable_web_search"` GenerateCount *int `json:"generate_count"` StartAt *string `json:"start_at"` EndAt *string `json:"end_at"` } type ScheduleTaskResponse struct { ID int64 `json:"id"` WorkspaceID *int64 `json:"workspace_id"` PromptRuleID int64 `json:"prompt_rule_id"` PromptRuleName *string `json:"prompt_rule_name"` BrandID *int64 `json:"brand_id"` BrandName *string `json:"brand_name"` Name string `json:"name"` CronExpr string `json:"cron_expr"` AutoPublish bool `json:"auto_publish"` PublishAccountIDs []string `json:"publish_account_ids"` AutoPublishPlatforms []string `json:"auto_publish_platforms"` CoverAssetURL *string `json:"cover_asset_url"` CoverImageAssetID *int64 `json:"cover_image_asset_id"` EnableWebSearch bool `json:"enable_web_search"` GenerateCount int `json:"generate_count"` StartAt *string `json:"start_at"` EndAt *string `json:"end_at"` NextRunAt *string `json:"next_run_at"` Status string `json:"status"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type ScheduleTaskListParams struct { PromptRuleID *int64 `json:"prompt_rule_id"` Status *string `json:"status"` Keyword *string `json:"keyword"` CreatedFrom *time.Time CreatedTo *time.Time Page int `json:"page"` PageSize int `json:"page_size"` } type ScheduleTaskListResponse struct { Items []ScheduleTaskResponse `json:"items"` Total int64 `json:"total"` } type ScheduleTaskStatusRequest struct { Status string `json:"status" binding:"required,oneof=enabled disabled"` } func (s *ScheduleTaskService) List(ctx context.Context, params ScheduleTaskListParams) (*ScheduleTaskListResponse, error) { actor := auth.MustActor(ctx) return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, scheduleTaskListCacheKey(actor.TenantID, params), defaultCacheTTL(), func(loadCtx context.Context) (*ScheduleTaskListResponse, error) { return s.loadScheduleTasks(loadCtx, actor.TenantID, params) }) } func (s *ScheduleTaskService) Detail(ctx context.Context, id int64) (*ScheduleTaskResponse, error) { actor := auth.MustActor(ctx) record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, scheduleTaskDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ScheduleTaskResponse, bool, error) { return s.loadScheduleTaskDetail(loadCtx, actor.TenantID, id) }) if err != nil { return nil, err } if !found || record == nil { return nil, response.ErrNotFound(40432, "schedule_not_found", "schedule task not found") } return record, nil } func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskRequest) (*ScheduleTaskResponse, error) { actor := auth.MustActor(ctx) if err := validateScheduleCronExpr(req.CronExpr); err != nil { return nil, err } if err := s.ensurePromptRuleExists(ctx, actor.TenantID, req.PromptRuleID); err != nil { return nil, err } generateCount, err := normalizeScheduleGenerateCount(req.GenerateCount) if err != nil { return nil, err } enableWebSearch := boolValue(req.EnableWebSearch) publishConfig, err := s.normalizeAutoPublishConfig(ctx, actor, req) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to begin schedule task transaction") } defer tx.Rollback(ctx) var id int64 var createdAt time.Time var startAt pgtype.Timestamptz var endAt pgtype.Timestamptz var status string err = tx.QueryRow(ctx, ` INSERT INTO schedule_tasks ( tenant_id, workspace_id, operator_id, prompt_rule_id, brand_id, name, cron_expr, enable_web_search, generate_count, auto_publish, publish_account_ids, cover_asset_url, cover_image_asset_id, start_at, end_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14::timestamptz, $15::timestamptz) RETURNING id, created_at, start_at, end_at, status `, actor.TenantID, actor.PrimaryWorkspaceID, actor.UserID, req.PromptRuleID, req.BrandID, req.Name, req.CronExpr, enableWebSearch, generateCount, publishConfig.AutoPublish, publishConfig.AccountIDsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt). Scan(&id, &createdAt, &startAt, &endAt, &status) if err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to create schedule task") } nextRunAt, err := resolveScheduleNextRunForStatus(status, req.CronExpr, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now()) if err != nil { return nil, response.ErrBadRequest(40015, "invalid_cron_expr", err.Error()) } if _, err := tx.Exec(ctx, ` UPDATE schedule_tasks SET next_run_at = $1, updated_at = NOW() WHERE id = $2 AND tenant_id = $3 `, nextRunAt, id, actor.TenantID); err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to initialize next run time") } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to commit schedule task") } afterJSON, _ := json.Marshal(map[string]interface{}{ "id": id, "name": req.Name, "cron_expr": req.CronExpr, "enable_web_search": enableWebSearch, "generate_count": generateCount, "auto_publish": publishConfig.AutoPublish, }) result := "success" resourceType := "schedule_task" requestID := middleware.RequestIDFromContext(ctx) s.auditLogs.Log(auditlog.Entry{ OperatorID: actor.UserID, TenantID: &actor.TenantID, Module: "schedule_task", Action: "create", ResourceType: &resourceType, ResourceID: &id, RequestID: nilIfEmptyString(requestID), AfterJSON: afterJSON, Result: &result, }) invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id) workspaceID := actor.PrimaryWorkspaceID return &ScheduleTaskResponse{ ID: id, WorkspaceID: &workspaceID, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID, Name: req.Name, CronExpr: req.CronExpr, AutoPublish: publishConfig.AutoPublish, PublishAccountIDs: publishConfig.AccountIDs, AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL, CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch, GenerateCount: generateCount, Status: status, CreatedAt: fmt.Sprintf("%v", createdAt), NextRunAt: timeStringPtr(nextRunAt), }, nil } func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req ScheduleTaskRequest) error { actor := auth.MustActor(ctx) if err := validateScheduleCronExpr(req.CronExpr); err != nil { return err } if err := s.ensurePromptRuleExists(ctx, actor.TenantID, req.PromptRuleID); err != nil { return err } generateCount, err := normalizeScheduleGenerateCount(req.GenerateCount) if err != nil { return err } enableWebSearch := boolValue(req.EnableWebSearch) publishConfig, err := s.normalizeAutoPublishConfig(ctx, actor, req) if err != nil { return err } tx, err := s.pool.Begin(ctx) if err != nil { return response.ErrInternal(50010, "update_failed", "failed to begin schedule task transaction") } defer tx.Rollback(ctx) var status string var startAt pgtype.Timestamptz var endAt pgtype.Timestamptz err = tx.QueryRow(ctx, ` UPDATE schedule_tasks SET prompt_rule_id = $1, brand_id = $2, name = $3, cron_expr = $4, enable_web_search = $5, generate_count = $6, workspace_id = $7, auto_publish = $8, publish_account_ids = $9::jsonb, cover_asset_url = $10, cover_image_asset_id = $11, start_at = $12::timestamptz, end_at = $13::timestamptz, operator_id = $14, updated_at = NOW() WHERE id = $15 AND tenant_id = $16 AND deleted_at IS NULL RETURNING status, start_at, end_at `, req.PromptRuleID, req.BrandID, req.Name, req.CronExpr, enableWebSearch, generateCount, actor.PrimaryWorkspaceID, publishConfig.AutoPublish, publishConfig.AccountIDsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID). Scan(&status, &startAt, &endAt) if err != nil { return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found") } nextRunAt, err := resolveScheduleNextRunForStatus(status, req.CronExpr, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now()) if err != nil { return response.ErrBadRequest(40015, "invalid_cron_expr", err.Error()) } if _, err := tx.Exec(ctx, ` UPDATE schedule_tasks SET next_run_at = $1, updated_at = NOW() WHERE id = $2 AND tenant_id = $3 `, nextRunAt, id, actor.TenantID); err != nil { return response.ErrInternal(50010, "update_failed", "failed to refresh next run time") } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50010, "update_failed", "failed to commit schedule task") } invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id) return nil } func (s *ScheduleTaskService) Delete(ctx context.Context, id int64) error { actor := auth.MustActor(ctx) tag, err := s.pool.Exec(ctx, ` UPDATE schedule_tasks SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, id, actor.TenantID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found") } afterJSON, _ := json.Marshal(map[string]interface{}{"id": id}) result := "success" resourceType := "schedule_task" requestID := middleware.RequestIDFromContext(ctx) s.auditLogs.Log(auditlog.Entry{ OperatorID: actor.UserID, TenantID: &actor.TenantID, Module: "schedule_task", Action: "delete", ResourceType: &resourceType, ResourceID: &id, RequestID: nilIfEmptyString(requestID), AfterJSON: afterJSON, Result: &result, }) invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id) return nil } func (s *ScheduleTaskService) ToggleStatus(ctx context.Context, id int64, req ScheduleTaskStatusRequest) error { actor := auth.MustActor(ctx) tx, err := s.pool.Begin(ctx) if err != nil { return response.ErrInternal(50010, "update_failed", "failed to begin schedule task transaction") } defer tx.Rollback(ctx) var cronExpr string var status string var startAt pgtype.Timestamptz var endAt pgtype.Timestamptz err = tx.QueryRow(ctx, ` UPDATE schedule_tasks SET status = $1, operator_id = $2, updated_at = NOW() WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL RETURNING cron_expr, status, start_at, end_at `, req.Status, actor.UserID, id, actor.TenantID). Scan(&cronExpr, &status, &startAt, &endAt) if err != nil { return response.ErrNotFound(40432, "schedule_not_found", "schedule task not found") } nextRunAt, err := resolveScheduleNextRunForStatus(status, cronExpr, timePtrFromTimestamp(startAt), timePtrFromTimestamp(endAt), time.Now()) if err != nil { return response.ErrBadRequest(40015, "invalid_cron_expr", err.Error()) } if _, err := tx.Exec(ctx, ` UPDATE schedule_tasks SET next_run_at = $1, updated_at = NOW() WHERE id = $2 AND tenant_id = $3 `, nextRunAt, id, actor.TenantID); err != nil { return response.ErrInternal(50010, "update_failed", "failed to refresh next run time") } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50010, "update_failed", "failed to commit schedule task") } invalidateScheduleTaskCaches(ctx, s.cache, actor.TenantID, &id) return nil } func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID int64, params ScheduleTaskListParams) (*ScheduleTaskListResponse, error) { if params.Page < 1 { params.Page = 1 } if params.PageSize < 1 || params.PageSize > 100 { params.PageSize = 20 } offset := (params.Page - 1) * params.PageSize var total int64 if err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM schedule_tasks WHERE tenant_id = $1 AND deleted_at IS NULL AND ($2::bigint IS NULL OR prompt_rule_id = $2) AND ($3::text IS NULL OR status = $3) AND ($4::text IS NULL OR name ILIKE '%' || $4 || '%') AND ($5::timestamptz IS NULL OR created_at >= $5) AND ($6::timestamptz IS NULL OR created_at <= $6) `, tenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo).Scan(&total); err != nil { return nil, response.ErrInternal(50010, "count_failed", "failed to count schedule tasks") } rows, err := s.pool.Query(ctx, ` SELECT st.id, st.workspace_id, st.prompt_rule_id, st.brand_id, st.name, st.cron_expr, st.auto_publish, st.publish_account_ids, st.cover_asset_url, st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at, st.next_run_at, st.status, st.created_at, st.updated_at, pr.name AS prompt_rule_name, b.name AS brand_name FROM schedule_tasks st LEFT JOIN prompt_rules pr ON pr.id = st.prompt_rule_id LEFT JOIN brands b ON b.id = st.brand_id WHERE st.tenant_id = $1 AND st.deleted_at IS NULL AND ($2::bigint IS NULL OR st.prompt_rule_id = $2) AND ($3::text IS NULL OR st.status = $3) AND ($4::text IS NULL OR st.name ILIKE '%' || $4 || '%') AND ($5::timestamptz IS NULL OR st.created_at >= $5) AND ($6::timestamptz IS NULL OR st.created_at <= $6) ORDER BY st.created_at DESC LIMIT $7 OFFSET $8 `, tenantID, params.PromptRuleID, params.Status, params.Keyword, params.CreatedFrom, params.CreatedTo, params.PageSize, offset) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list schedule tasks") } defer rows.Close() items := make([]ScheduleTaskResponse, 0) for rows.Next() { item, err := scanScheduleTaskResponse(rows) if err != nil { return nil, err } if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil { return nil, err } items = append(items, item) } return &ScheduleTaskListResponse{Items: items, Total: total}, nil } func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenantID, taskID int64) (*ScheduleTaskResponse, bool, error) { row := s.pool.QueryRow(ctx, ` SELECT st.id, st.workspace_id, st.prompt_rule_id, st.brand_id, st.name, st.cron_expr, st.auto_publish, st.publish_account_ids, st.cover_asset_url, st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at, st.next_run_at, st.status, st.created_at, st.updated_at, pr.name AS prompt_rule_name, b.name AS brand_name FROM schedule_tasks st LEFT JOIN prompt_rules pr ON pr.id = st.prompt_rule_id LEFT JOIN brands b ON b.id = st.brand_id WHERE st.id = $1 AND st.tenant_id = $2 AND st.deleted_at IS NULL `, taskID, tenantID) item, err := scanScheduleTaskResponse(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil } return nil, false, err } if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil { return nil, false, err } return &item, true, nil } func (s *ScheduleTaskService) populateScheduleAutoPublishPlatforms(ctx context.Context, tenantID int64, item *ScheduleTaskResponse) error { if item == nil || !item.AutoPublish { return nil } platformIDs, err := loadPublishAccountPlatformsByIDs(ctx, s.pool, tenantID, item.PublishAccountIDs) if err != nil { return response.ErrInternal(50010, "query_failed", "failed to resolve schedule auto publish platforms") } item.AutoPublishPlatforms = platformIDs return nil } func scanScheduleTaskResponse(scanner interface { Scan(dest ...interface{}) error }) (ScheduleTaskResponse, error) { var item ScheduleTaskResponse var createdAt interface{} var updatedAt interface{} var startAt interface{} var endAt interface{} var nextRunAt interface{} var publishAccountIDsJSON []byte if err := scanner.Scan(&item.ID, &item.WorkspaceID, &item.PromptRuleID, &item.BrandID, &item.Name, &item.CronExpr, &item.AutoPublish, &publishAccountIDsJSON, &item.CoverAssetURL, &item.CoverImageAssetID, &item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt, &nextRunAt, &item.Status, &createdAt, &updatedAt, &item.PromptRuleName, &item.BrandName); err != nil { if errors.Is(err, pgx.ErrNoRows) { return ScheduleTaskResponse{}, pgx.ErrNoRows } return ScheduleTaskResponse{}, response.ErrInternal(50010, "scan_failed", err.Error()) } item.PublishAccountIDs = decodeSchedulePublishAccountIDs(publishAccountIDsJSON) item.CreatedAt = fmt.Sprintf("%v", createdAt) item.UpdatedAt = fmt.Sprintf("%v", updatedAt) if startAt != nil { value := fmt.Sprintf("%v", startAt) item.StartAt = &value } if endAt != nil { value := fmt.Sprintf("%v", endAt) item.EndAt = &value } if nextRunAt != nil { value := fmt.Sprintf("%v", nextRunAt) item.NextRunAt = &value } return item, nil } func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenantID, promptRuleID int64) error { var exists bool _ = s.pool.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM prompt_rules WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL) `, promptRuleID, tenantID).Scan(&exists) if !exists { return response.ErrBadRequest(40001, "invalid_rule", "prompt rule not found or not active") } return nil } type scheduleAutoPublishConfig struct { AutoPublish bool AccountIDs []string PlatformIDs []string AccountIDsJSON []byte CoverAssetURL *string CoverImageAssetID *int64 } func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, actor auth.Actor, req ScheduleTaskRequest) (scheduleAutoPublishConfig, error) { autoPublish := boolValue(req.AutoPublish) accountIDs := normalizeSchedulePublishAccountIDs(req.PublishAccountIDs) config := scheduleAutoPublishConfig{ AutoPublish: autoPublish, AccountIDs: accountIDs, } if !autoPublish { config.AccountIDs = []string{} config.AccountIDsJSON = []byte("[]") return config, nil } if actor.PrimaryWorkspaceID <= 0 { return config, response.ErrBadRequest(40023, "workspace_required", "workspace context is required for automatic publishing") } if len(accountIDs) == 0 { return config, response.ErrBadRequest(40024, "publish_accounts_required", "publish_account_ids is required when auto_publish is enabled") } if err := validateOptionalPositiveInt64(req.CoverImageAssetID, "cover_image_asset_id"); err != nil { return config, err } coverURL := strings.TrimSpace(stringPointerValue(req.CoverAssetURL)) platformIDs, err := s.loadPublishAccountPlatformIDs(ctx, actor.TenantID, actor.PrimaryWorkspaceID, accountIDs) if err != nil { return config, err } config.PlatformIDs = normalizePlatformIDs(platformIDs) if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" { return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url") } accountIDsJSON, err := json.Marshal(accountIDs) if err != nil { return config, response.ErrBadRequest(40026, "invalid_publish_account_ids", "publish_account_ids must be serializable") } config.AccountIDsJSON = accountIDsJSON if coverURL != "" { config.CoverAssetURL = &coverURL config.CoverImageAssetID = req.CoverImageAssetID } return config, nil } func (s *ScheduleTaskService) loadPublishAccountPlatformIDs(ctx context.Context, tenantID, workspaceID int64, accountIDs []string) ([]string, error) { rows, err := s.pool.Query(ctx, ` SELECT platform_id FROM platform_accounts WHERE tenant_id = $1 AND workspace_id = $2 AND desktop_id::text = ANY($3::text[]) AND deleted_at IS NULL `, tenantID, workspaceID, accountIDs) if err != nil { return nil, response.ErrInternal(50010, "publish_account_check_failed", "failed to validate publish accounts") } defer rows.Close() platformIDs := make([]string, 0, len(accountIDs)) for rows.Next() { var platformID string if err := rows.Scan(&platformID); err != nil { return nil, response.ErrInternal(50010, "publish_account_check_failed", "failed to parse publish accounts") } platformIDs = append(platformIDs, strings.TrimSpace(platformID)) } if err := rows.Err(); err != nil { return nil, response.ErrInternal(50010, "publish_account_check_failed", "failed to iterate publish accounts") } if len(platformIDs) != len(accountIDs) { return nil, response.ErrBadRequest(40027, "invalid_publish_accounts", "one or more publish accounts do not exist") } return platformIDs, nil } func schedulePublishPlatformsRequireCover(platformIDs []string) bool { for _, platformID := range platformIDs { if publishPlatformRequiresCover(platformID) { return true } } return false } func resolveScheduleNextRunForStatus(status, cronExpr string, startAt, endAt *time.Time, now time.Time) (*time.Time, error) { if status != "enabled" { return nil, nil } return sharedschedule.ComputeNextRun(cronExpr, startAt, endAt, now) } func validateScheduleCronExpr(cronExpr string) error { if _, err := sharedschedule.Parse(cronExpr); err != nil { return response.ErrBadRequest(40015, "invalid_cron_expr", err.Error()) } return nil } func normalizeScheduleGenerateCount(value *int) (int, error) { if value == nil { return 1, nil } if *value < 1 || *value > 20 { return 0, response.ErrBadRequest(40022, "invalid_generate_count", "generate_count must be between 1 and 20") } return *value, nil } func boolValue(value *bool) bool { return value != nil && *value } func normalizeSchedulePublishAccountIDs(values []string) []string { seen := make(map[string]struct{}, len(values)) normalized := make([]string, 0, len(values)) for _, value := range values { raw := strings.TrimSpace(value) if raw == "" { continue } id, err := uuid.Parse(raw) if err != nil { continue } key := id.String() if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} normalized = append(normalized, key) } return normalized } func decodeSchedulePublishAccountIDs(raw []byte) []string { var values []string if len(raw) == 0 { return []string{} } if err := json.Unmarshal(raw, &values); err != nil { return []string{} } return normalizeSchedulePublishAccountIDs(values) } func validateOptionalPositiveInt64(value *int64, field string) error { if value != nil && *value <= 0 { return response.ErrBadRequest(40028, "invalid_"+field, field+" must be a positive integer or null") } return nil } func timePtrFromTimestamp(value pgtype.Timestamptz) *time.Time { if !value.Valid { return nil } result := value.Time.Round(0) return &result } func timeStringPtr(value *time.Time) *string { if value == nil { return nil } text := value.Format(time.RFC3339) return &text }