package buildsvc import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "sync" "time" "qctextbuilder/internal/domain" "qctextbuilder/internal/mapping" "qctextbuilder/internal/qcclient" "qctextbuilder/internal/store" "qctextbuilder/internal/validation" ) type StartBuildRequest struct { TemplateID int64 `json:"templateId"` RequestName string `json:"requestName"` GlobalData map[string]any `json:"globalData"` FieldValues map[string]string `json:"fieldValues"` } type BuildResult struct { BuildID string `json:"buildId"` QCJobID int64 `json:"qcJobId"` Status string `json:"status"` } type Service interface { StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error) PollOnce(ctx context.Context, buildID string) error FetchEditorURL(ctx context.Context, buildID string) error GetBuild(ctx context.Context, buildID string) (*domain.SiteBuild, error) } type BuildService struct { qc qcclient.Client templateStore store.TemplateStore manifestStore store.ManifestStore buildStore store.BuildStore mapping mapping.Service pollTimeout time.Duration mu sync.Mutex inFlightPolls map[string]struct{} } func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore, buildStore store.BuildStore, mappingSvc mapping.Service, pollTimeout time.Duration) *BuildService { if pollTimeout <= 0 { pollTimeout = 5 * time.Minute } return &BuildService{ qc: qc, templateStore: templateStore, manifestStore: manifestStore, buildStore: buildStore, mapping: mappingSvc, pollTimeout: pollTimeout, inFlightPolls: make(map[string]struct{}), } } func (s *BuildService) StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error) { template, err := s.templateStore.GetTemplateByID(ctx, req.TemplateID) if err != nil { return nil, fmt.Errorf("get template: %w", err) } if !template.IsAITemplate { return nil, errors.New("only ai templates are allowed") } manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, req.TemplateID) if err != nil { return nil, fmt.Errorf("get active manifest: %w", err) } if !isBuildAllowed(template.ManifestStatus) { return nil, fmt.Errorf("template manifest status must be reviewed or validated, got %q", template.ManifestStatus) } filteredGlobalData := FilterGlobalData(req.GlobalData) if err := validation.ValidateBuildGlobalData(filteredGlobalData); err != nil { return nil, err } fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID) if err != nil { return nil, fmt.Errorf("list manifest fields: %w", err) } aiData, err := s.mapping.AssembleAIData(fields, req.FieldValues) if err != nil { return nil, fmt.Errorf("assemble aiData: %w", err) } if len(aiData) == 0 { return nil, errors.New("at least one enabled text field value is required") } qcReq := qcclient.CreateSiteRequest{ TemplateID: req.TemplateID, GlobalData: filteredGlobalData, Content: qcclient.CreateSiteContent{ AIData: aiData, }, } requestName := strings.TrimSpace(req.RequestName) if requestName == "" { requestName = "build-" + strconv.FormatInt(time.Now().Unix(), 10) } buildID := strconv.FormatInt(time.Now().UnixNano(), 10) globalJSON, _ := json.Marshal(filteredGlobalData) aiDataJSON, _ := json.Marshal(aiData) finalPayload, _ := json.Marshal(qcReq) now := time.Now().UTC() build := domain.SiteBuild{ ID: buildID, TemplateID: req.TemplateID, ManifestID: manifest.ID, RequestName: requestName, GlobalDataJSON: globalJSON, AIDataJSON: aiDataJSON, FinalSitesPayload: finalPayload, QCStatus: "draft", } if err := s.buildStore.CreateBuild(ctx, build); err != nil { return nil, fmt.Errorf("create build: %w", err) } siteResp, siteRaw, err := s.qc.CreateSite(ctx, qcReq) if err != nil { _ = s.buildStore.UpdateBuildFromJob(ctx, buildID, "failed", nil, "", nil, wrapErrorRaw(err), &now) return nil, fmt.Errorf("post /sites: %w", err) } if err := s.buildStore.MarkBuildSubmitted(ctx, buildID, siteResp.JobID, normalizeQCStatus(siteResp.Status), siteRaw, now); err != nil { return nil, fmt.Errorf("save site submission: %w", err) } return &BuildResult{ BuildID: buildID, QCJobID: siteResp.JobID, Status: normalizeQCStatus(siteResp.Status), }, nil } func (s *BuildService) PollOnce(ctx context.Context, buildID string) error { if !s.acquirePollLease(buildID) { return nil } defer s.releasePollLease(buildID) build, err := s.buildStore.GetBuildByID(ctx, buildID) if err != nil { return fmt.Errorf("get build: %w", err) } if isTerminalStatus(build.QCStatus) { return nil } if build.QCJobID == nil { return errors.New("build has no qcJobId") } now := time.Now().UTC() if hasExceededTimeout(build, now, s.pollTimeout) { return s.buildStore.UpdateBuildFromJob( ctx, buildID, "timeout", build.QCSiteID, preservePreviewURL(build.QCPreviewURL, ""), nil, wrapErrorMessage("poll timeout exceeded"), &now, ) } job, jobRaw, err := s.qc.GetJob(ctx, *build.QCJobID) now = time.Now().UTC() if err != nil { return s.buildStore.UpdateBuildFromJob(ctx, buildID, "failed", nil, "", nil, wrapErrorRaw(err), &now) } status := normalizeQCStatus(job.Status) if isTerminalStatus(build.QCStatus) { status = build.QCStatus } var finishedAt *time.Time var siteID *int64 previewURL := strings.TrimSpace(job.Result.PreviewURL) if previewURL == "" { previewURL = build.QCPreviewURL } if job.Result.SiteID > 0 { id := job.Result.SiteID siteID = &id } else if build.QCSiteID != nil { siteID = build.QCSiteID } if isTerminalStatus(status) { finishedAt = &now } if err := s.buildStore.UpdateBuildFromJob(ctx, buildID, status, siteID, previewURL, jobRaw, nil, finishedAt); err != nil { return err } if status == "done" && siteID != nil && strings.TrimSpace(build.QCEditorURL) == "" { _ = s.fetchAndStoreEditorURL(ctx, buildID, *siteID) } return nil } func (s *BuildService) FetchEditorURL(ctx context.Context, buildID string) error { build, err := s.buildStore.GetBuildByID(ctx, buildID) if err != nil { return fmt.Errorf("get build: %w", err) } if !isTerminalStatus(build.QCStatus) { return fmt.Errorf("editor url can only be fetched for terminal build status, got %q", build.QCStatus) } if build.QCSiteID == nil { return errors.New("build has no qcSiteId") } if strings.TrimSpace(build.QCEditorURL) != "" { return nil } return s.fetchAndStoreEditorURL(ctx, buildID, *build.QCSiteID) } func (s *BuildService) GetBuild(ctx context.Context, buildID string) (*domain.SiteBuild, error) { return s.buildStore.GetBuildByID(ctx, buildID) } func isBuildAllowed(status string) bool { switch strings.ToLower(strings.TrimSpace(status)) { case "reviewed", "validated": return true default: return false } } func normalizeQCStatus(status string) string { s := strings.ToLower(strings.TrimSpace(status)) switch s { case "", "unknown": return "queued" case "in_progress", "running": return "processing" case "success", "succeeded", "completed": return "done" case "error": return "failed" case "queued", "processing", "done", "failed", "timeout": return s default: return "processing" } } func wrapErrorRaw(err error) json.RawMessage { return wrapErrorMessage(err.Error()) } func wrapErrorMessage(msg string) json.RawMessage { payload, marshalErr := json.Marshal(map[string]any{"error": msg}) if marshalErr != nil { return nil } return payload } func preservePreviewURL(existing, next string) string { if strings.TrimSpace(next) != "" { return next } return existing } func isTerminalStatus(status string) bool { switch strings.ToLower(strings.TrimSpace(status)) { case "done", "failed", "timeout": return true default: return false } } func hasExceededTimeout(build *domain.SiteBuild, now time.Time, pollTimeout time.Duration) bool { if pollTimeout <= 0 || build.StartedAt == nil { return false } if isTerminalStatus(build.QCStatus) { return false } return now.Sub(*build.StartedAt) > pollTimeout } func (s *BuildService) fetchAndStoreEditorURL(ctx context.Context, buildID string, siteID int64) error { editor, raw, err := s.qc.GetEditorURL(ctx, siteID) if err != nil { return fmt.Errorf("get editor url: %w", err) } loginURL := strings.TrimSpace(editor.LoginURL) if loginURL == "" { return errors.New("empty editor login url") } return s.buildStore.UpdateBuildEditorURL(ctx, buildID, loginURL, raw) } func (s *BuildService) acquirePollLease(buildID string) bool { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.inFlightPolls[buildID]; ok { return false } s.inFlightPolls[buildID] = struct{}{} return true } func (s *BuildService) releasePollLease(buildID string) { s.mu.Lock() defer s.mu.Unlock() delete(s.inFlightPolls, buildID) }