Files
geo/server/internal/tenant/app/publish_job_service.go
T
root b16e9f0bd1 feat(desktop): implement workspace foundation + desktop-client skeleton
Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend
JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant,
thread workspace_id through tenant monitoring quota.

Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/
preload/renderer, shared Vue component package (packages/ui-shared), and server
surface — desktop client registration + token rotation + heartbeat, SSE task
event stream, desktop accounts/tasks/content handlers, publish job endpoint,
and supporting repositories, services, sqlc queries, and migrations.

Hard cutover per plan: remove browser-extension monitoring callback endpoints,
stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
2026-04-19 14:18:20 +08:00

170 lines
5.9 KiB
Go

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...)
}