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"` 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"` 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 := req.TemplateID if strings.TrimSpace(req.DraftID) != "" { 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 { return nil, errors.New("templateId is required") } 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 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") } 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, 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 (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) }