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