package app import ( "context" "errors" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "go.uber.org/zap" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) type PublishJobService struct { pool *pgxpool.Pool messaging *rabbitmq.Client logger *zap.Logger } func NewPublishJobService(pool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger) *PublishJobService { return &PublishJobService{ pool: pool, messaging: messaging, logger: logger, } } type CreatePublishJobAccountRequest struct { AccountID string `json:"account_id" binding:"required"` Mode string `json:"mode" binding:"required,oneof=auto manual"` } type CreatePublishJobRequest struct { Title string `json:"title" binding:"required"` ContentRef map[string]any `json:"content_ref" binding:"required"` Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"` ScheduledAt *time.Time `json:"scheduled_at"` } type CreatePublishJobResponse struct { JobID string `json:"job_id"` TaskIDs []string `json:"task_ids"` } func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req CreatePublishJobRequest) (*CreatePublishJobResponse, error) { if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 { return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") } if s.pool == nil { return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable") } contentRefJSON, err := marshalOptionalJSON(req.ContentRef) if err != nil { return nil, response.ErrBadRequest(40091, "invalid_content_ref", "content_ref must be serializable") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50096, "desktop_publish_job_begin_failed", "failed to start desktop publish job transaction") } defer tx.Rollback(ctx) accountRepo := repository.NewDesktopAccountRepository(tx) taskRepo := repository.NewDesktopTaskRepository(tx) jobID := uuid.New() job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{ DesktopID: jobID, TenantID: actor.TenantID, WorkspaceID: actor.PrimaryWorkspaceID, CreatedByUserID: actor.UserID, Title: req.Title, ContentRef: contentRefJSON, ScheduledAt: req.ScheduledAt, }) if err != nil { return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job") } taskIDs := make([]string, 0, len(req.Accounts)) createdTasks := make([]*repository.DesktopTask, 0, len(req.Accounts)) for _, accountReq := range req.Accounts { accountID, parseErr := uuid.Parse(strings.TrimSpace(accountReq.AccountID)) if parseErr != nil { return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "account_id must be a uuid") } account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID) if lookupErr != nil { if errors.Is(lookupErr, pgx.ErrNoRows) { return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts do not exist") } return nil, response.ErrInternal(50098, "desktop_account_lookup_failed", "failed to load selected desktop account") } if account.ClientID == nil { return nil, response.ErrConflict(40992, "desktop_account_client_missing", "one or more selected desktop accounts are not currently bound to a desktop client") } payloadJSON, payloadErr := marshalOptionalJSON(map[string]any{ "title": req.Title, "content_ref": req.ContentRef, "mode": accountReq.Mode, "account_id": account.DesktopID.String(), "platform": account.Platform, }) if payloadErr != nil { return nil, response.ErrBadRequest(40094, "invalid_desktop_task_payload", "publish job payload must be serializable") } task, createErr := taskRepo.CreateTask(ctx, repository.CreateDesktopTaskParams{ DesktopID: uuid.New(), JobID: job.DesktopID, TenantID: actor.TenantID, WorkspaceID: actor.PrimaryWorkspaceID, TargetAccountID: account.DesktopID, TargetClientID: *account.ClientID, Platform: account.Platform, Kind: "publish", Payload: payloadJSON, Status: "queued", }) if createErr != nil { return nil, response.ErrInternal(50099, "desktop_task_create_failed", "failed to create desktop publish task") } createdTasks = append(createdTasks, task) taskIDs = append(taskIDs, task.DesktopID.String()) } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job") } for _, task := range createdTasks { if publishErr := publishDesktopTaskEvent(context.Background(), s.messaging, DesktopTaskEvent{ Type: "task_available", TaskID: task.DesktopID.String(), JobID: task.JobID.String(), WorkspaceID: task.WorkspaceID, TargetClientID: task.TargetClientID.String(), Status: task.Status, Kind: task.Kind, UpdatedAt: task.UpdatedAt, }); publishErr != nil { s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String())) } } return &CreatePublishJobResponse{ JobID: job.DesktopID.String(), TaskIDs: taskIDs, }, nil } func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) { if s.logger == nil || err == nil { return } fields = append(fields, zap.Error(err)) s.logger.Warn(message, fields...) }