package draftsvc import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "qctextbuilder/internal/domain" "qctextbuilder/internal/store" ) type UpsertDraftRequest struct { DraftID string `json:"draftId,omitempty"` TemplateID *int64 `json:"templateId,omitempty"` ManifestID string `json:"manifestId,omitempty"` Source string `json:"source,omitempty"` RequestName string `json:"requestName,omitempty"` GlobalData map[string]any `json:"globalData"` FieldValues map[string]string `json:"fieldValues"` DraftContext *domain.DraftContext `json:"draftContext,omitempty"` Status string `json:"status,omitempty"` Notes string `json:"notes,omitempty"` } type Service struct { drafts store.DraftStore templates store.TemplateStore manifests store.ManifestStore } func New(draftStore store.DraftStore, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service { return &Service{ drafts: draftStore, templates: templateStore, manifests: manifestStore, } } func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domain.BuildDraft, error) { templateID := int64(0) if req.TemplateID != nil { templateID = *req.TemplateID } var existing *domain.BuildDraft if strings.TrimSpace(req.DraftID) != "" { var err error existing, err = s.drafts.GetDraftByID(ctx, strings.TrimSpace(req.DraftID)) if err != nil { return nil, fmt.Errorf("get draft: %w", err) } if templateID == 0 { templateID = existing.TemplateID } } if templateID > 0 { template, err := s.templates.GetTemplateByID(ctx, templateID) if err != nil { return nil, fmt.Errorf("get template: %w", err) } if !template.IsAITemplate { return nil, errors.New("only ai templates are allowed") } } manifestID := strings.TrimSpace(req.ManifestID) if templateID <= 0 { manifestID = "" } else if manifestID == "" { manifest, err := s.manifests.GetActiveManifestByTemplateID(ctx, templateID) if err != nil { return nil, fmt.Errorf("get active manifest: %w", err) } manifestID = manifest.ID } globalDataJSON, err := json.Marshal(req.GlobalData) if err != nil { return nil, errors.New("globalData is invalid JSON") } fieldValuesJSON, err := json.Marshal(req.FieldValues) if err != nil { return nil, errors.New("fieldValues is invalid JSON") } draftContextJSON, err := buildDraftContextJSON(req, existing) if err != nil { return nil, err } now := time.Now().UTC() source := defaultString(req.Source, "ui") status := normalizeDraftStatus(req.Status) if status == "" { status = "draft" } draft := domain.BuildDraft{ ID: strings.TrimSpace(req.DraftID), TemplateID: templateID, ManifestID: manifestID, Source: source, RequestName: strings.TrimSpace(req.RequestName), GlobalDataJSON: globalDataJSON, FieldValuesJSON: fieldValuesJSON, DraftContextJSON: draftContextJSON, Status: status, Notes: strings.TrimSpace(req.Notes), UpdatedAt: now, } if draft.ID == "" { draft.ID = strconv.FormatInt(time.Now().UnixNano(), 10) draft.CreatedAt = now if err := s.drafts.CreateDraft(ctx, draft); err != nil { return nil, fmt.Errorf("create draft: %w", err) } } else { existing, err := s.drafts.GetDraftByID(ctx, draft.ID) if err != nil { return nil, fmt.Errorf("get draft: %w", err) } draft.CreatedAt = existing.CreatedAt if err := s.drafts.UpdateDraft(ctx, draft); err != nil { return nil, fmt.Errorf("update draft: %w", err) } } return s.drafts.GetDraftByID(ctx, draft.ID) } func buildDraftContextJSON(req UpsertDraftRequest, existing *domain.BuildDraft) (json.RawMessage, error) { if req.DraftContext == nil { if existing != nil { return existing.DraftContextJSON, nil } return nil, nil } raw, err := json.Marshal(req.DraftContext) if err != nil { return nil, errors.New("draftContext is invalid JSON") } return raw, nil } func (s *Service) GetDraft(ctx context.Context, draftID string) (*domain.BuildDraft, error) { return s.drafts.GetDraftByID(ctx, strings.TrimSpace(draftID)) } func (s *Service) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) { if limit <= 0 { limit = 50 } return s.drafts.ListDrafts(ctx, limit) } func normalizeDraftStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "": return "" case "draft", "reviewed", "submitted": return strings.ToLower(strings.TrimSpace(status)) default: return "draft" } } func defaultString(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return strings.TrimSpace(value) }