package handlers import ( "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "github.com/go-chi/chi/v5" "qctextbuilder/internal/buildsvc" "qctextbuilder/internal/config" "qctextbuilder/internal/domain" "qctextbuilder/internal/draftsvc" "qctextbuilder/internal/onboarding" "qctextbuilder/internal/templatesvc" ) type UI struct { templateSvc *templatesvc.Service onboardSvc *onboarding.Service draftSvc *draftsvc.Service buildSvc buildsvc.Service cfg config.Config render htmlRenderer } type htmlRenderer interface { Render(w http.ResponseWriter, name string, data any) } type pageData struct { Title string Msg string Err string Current string } type homePageData struct { pageData TemplateCount int } type settingsPageData struct { pageData QCBaseURL string PollIntervalSeconds int PollTimeoutSeconds int PollMaxConcurrent int TokenConfigured bool LanguageOutputMode string } type templatesPageData struct { pageData Templates []domain.Template } type templateFieldView struct { Path string FieldKind string IsEnabled bool IsRequiredByUs bool DisplayLabel string DisplayOrder int Notes string SampleValue string } type templateDetailPageData struct { pageData Detail *templatesvc.TemplateDetail Fields []templateFieldView } type buildFieldView struct { Path string DisplayLabel string SampleValue string Value string } type buildNewPageData struct { pageData Templates []domain.Template Drafts []domain.BuildDraft SelectedDraftID string SelectedTemplateID int64 SelectedManifestID string EnabledFields []buildFieldView Form buildFormInput } type buildFormInput struct { DraftID string DraftSource string DraftStatus string DraftNotes string RequestName string CompanyName string BusinessType string Username string Email string Phone string OrgNumber string StartDate string Mission string DescriptionShort string DescriptionLong string SiteLanguage string AddressLine1 string AddressLine2 string AddressCity string AddressRegion string AddressZIP string AddressCountry string } type buildDetailPageData struct { pageData Build *domain.SiteBuild EffectiveGlobal []byte CanPoll bool CanFetchEditorURL bool AutoRefreshSeconds int } func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, cfg config.Config, render htmlRenderer) *UI { return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, draftSvc: draftSvc, buildSvc: buildSvc, cfg: cfg, render: render} } func (u *UI) Home(w http.ResponseWriter, r *http.Request) { templates, err := u.templateSvc.ListTemplates(r.Context()) if err != nil { u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0}) return } u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)}) } func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { u.render.Render(w, "settings", settingsPageData{ pageData: basePageData(r, "Settings", "/settings"), QCBaseURL: u.cfg.QCBaseURL, PollIntervalSeconds: u.cfg.PollIntervalSeconds, PollTimeoutSeconds: u.cfg.PollTimeoutSeconds, PollMaxConcurrent: u.cfg.PollMaxConcurrent, TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "", LanguageOutputMode: "EN", }) } func (u *UI) Templates(w http.ResponseWriter, r *http.Request) { templates, err := u.templateSvc.ListTemplates(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates}) } func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) { if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil { http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther) } func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) { templateID, ok := parseTemplateID(w, r) if !ok { return } detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } fields := make([]templateFieldView, 0, len(detail.Fields)) for _, f := range detail.Fields { fields = append(fields, templateFieldView{ Path: f.Path, FieldKind: f.FieldKind, IsEnabled: f.IsEnabled, IsRequiredByUs: f.IsRequiredByUs, DisplayLabel: f.DisplayLabel, DisplayOrder: f.DisplayOrder, Notes: f.Notes, SampleValue: f.SampleValue, }) } u.render.Render(w, "template_detail", templateDetailPageData{pageData: basePageData(r, "Template Detail", "/templates"), Detail: detail, Fields: fields}) } func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) { templateID, ok := parseTemplateID(w, r) if !ok { return } if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther) } func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { templateID, ok := parseTemplateID(w, r) if !ok { return } if err := r.ParseForm(); err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther) return } count, _ := strconv.Atoi(r.FormValue("field_count")) patches := make([]onboarding.FieldPatch, 0, count) for i := 0; i < count; i++ { path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i))) if path == "" { continue } enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on" required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on" label := r.FormValue(fmt.Sprintf("field_label_%d", i)) notes := r.FormValue(fmt.Sprintf("field_notes_%d", i)) order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i)))) if err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther) return } patches = append(patches, onboarding.FieldPatch{ Path: path, IsEnabled: boolPtr(enabled), IsRequiredByUs: boolPtr(required), DisplayLabel: strPtr(label), DisplayOrder: intPtr(order), Notes: strPtr(notes), }) } manifestID := r.FormValue("manifest_id") if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther) } func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) { selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64) selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id")) form := buildFormInput{ DraftID: selectedDraftID, DraftSource: "ui", DraftStatus: "draft", } fieldValues := map[string]string{} if selectedDraftID != "" { draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID) if err == nil { selectedTemplateID = draft.TemplateID form = buildFormInputFromDraft(draft) fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON) } } data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } u.render.Render(w, "build_new", data) } func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther) return } form := buildFormInputFromRequest(r) fieldValues := parseBuildFieldValues(r) globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{ CompanyName: form.CompanyName, BusinessType: form.BusinessType, Username: form.Username, Email: form.Email, Phone: form.Phone, OrgNumber: form.OrgNumber, StartDate: form.StartDate, Mission: form.Mission, DescriptionShort: form.DescriptionShort, DescriptionLong: form.DescriptionLong, SiteLanguage: form.SiteLanguage, AddressLine1: form.AddressLine1, AddressLine2: form.AddressLine2, AddressCity: form.AddressCity, AddressRegion: form.AddressRegion, AddressZIP: form.AddressZIP, AddressCountry: form.AddressCountry, }) templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) if err != nil || templateID <= 0 { http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther) return } result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{ TemplateID: templateID, RequestName: form.RequestName, GlobalData: globalData, FieldValues: fieldValues, }) if err != nil { data, loadErr := u.loadBuildNewPageData(r, pageData{ Title: "New Build", Err: err.Error(), Current: "/builds/new", }, form.DraftID, templateID, form, fieldValues) if loadErr != nil { http.Error(w, loadErr.Error(), http.StatusBadRequest) return } u.render.Render(w, "build_new", data) return } if form.DraftID != "" { _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{ DraftID: form.DraftID, TemplateID: templateID, ManifestID: strings.TrimSpace(r.FormValue("manifest_id")), Source: form.DraftSource, RequestName: form.RequestName, GlobalData: globalData, FieldValues: fieldValues, Status: "submitted", Notes: form.DraftNotes, }) } http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther) } func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther) return } form := buildFormInputFromRequest(r) fieldValues := parseBuildFieldValues(r) templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) if err != nil || templateID <= 0 { http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther) return } globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{ CompanyName: form.CompanyName, BusinessType: form.BusinessType, Username: form.Username, Email: form.Email, Phone: form.Phone, OrgNumber: form.OrgNumber, StartDate: form.StartDate, Mission: form.Mission, DescriptionShort: form.DescriptionShort, DescriptionLong: form.DescriptionLong, SiteLanguage: form.SiteLanguage, AddressLine1: form.AddressLine1, AddressLine2: form.AddressLine2, AddressCity: form.AddressCity, AddressRegion: form.AddressRegion, AddressZIP: form.AddressZIP, AddressCountry: form.AddressCountry, }) draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{ DraftID: form.DraftID, TemplateID: templateID, ManifestID: strings.TrimSpace(r.FormValue("manifest_id")), Source: form.DraftSource, RequestName: form.RequestName, GlobalData: globalData, FieldValues: fieldValues, Status: defaultDraftStatus(form.DraftStatus), Notes: form.DraftNotes, }) if err != nil { data, loadErr := u.loadBuildNewPageData(r, pageData{ Title: "New Build", Err: err.Error(), Current: "/builds/new", }, form.DraftID, templateID, form, fieldValues) if loadErr != nil { http.Error(w, loadErr.Error(), http.StatusBadRequest) return } u.render.Render(w, "build_new", data) return } http.Redirect(w, r, fmt.Sprintf("/builds/new?template_id=%d&draft_id=%s&msg=draft+saved", templateID, urlQuery(draft.ID)), http.StatusSeeOther) } func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) { buildID := strings.TrimSpace(chi.URLParam(r, "id")) build, err := u.buildSvc.GetBuild(r.Context(), buildID) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } status := strings.ToLower(strings.TrimSpace(build.QCStatus)) canPoll := status == "queued" || status == "processing" canFetchEditor := (status == "done" || status == "failed" || status == "timeout") && build.QCSiteID != nil && strings.TrimSpace(build.QCEditorURL) == "" autoRefresh := 0 if canPoll && u.cfg.PollIntervalSeconds > 0 { autoRefresh = u.cfg.PollIntervalSeconds } effectiveGlobal := build.GlobalDataJSON if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 { effectiveGlobal = payloadGlobal } u.render.Render(w, "build_detail", buildDetailPageData{ pageData: basePageData(r, "Build Detail", "/builds"), Build: build, EffectiveGlobal: effectiveGlobal, CanPoll: canPoll, CanFetchEditorURL: canFetchEditor, AutoRefreshSeconds: autoRefresh, }) } func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) { buildID := strings.TrimSpace(chi.URLParam(r, "id")) if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil { http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther) } func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) { buildID := strings.TrimSpace(chi.URLParam(r, "id")) if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil { http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther) } func basePageData(r *http.Request, title, current string) pageData { q := r.URL.Query() return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current} } func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) { rawID := chi.URLParam(r, "id") templateID, err := strconv.ParseInt(rawID, 10, 64) if err != nil { http.Error(w, "invalid template id", http.StatusBadRequest) return 0, false } return templateID, true } func urlQuery(s string) string { return url.QueryEscape(s) } func boolPtr(v bool) *bool { return &v } func intPtr(v int) *int { return &v } func strPtr(v string) *string { return &v } func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string) (buildNewPageData, error) { templates, err := u.templateSvc.ListTemplates(r.Context()) if err != nil { return buildNewPageData{}, err } drafts, err := u.draftSvc.ListDrafts(r.Context(), 50) if err != nil { return buildNewPageData{}, err } data := buildNewPageData{ pageData: page, Templates: templates, Drafts: drafts, SelectedDraftID: selectedDraftID, SelectedTemplateID: selectedTemplateID, Form: form, } if selectedTemplateID <= 0 { return data, nil } detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID) if err != nil || detail.Manifest == nil { return data, nil } data.SelectedManifestID = detail.Manifest.ID for _, f := range detail.Fields { if !f.IsEnabled || f.FieldKind != "text" { continue } data.EnabledFields = append(data.EnabledFields, buildFieldView{ Path: f.Path, DisplayLabel: f.DisplayLabel, SampleValue: f.SampleValue, Value: strings.TrimSpace(fieldValues[f.Path]), }) } return data, nil } func buildFormInputFromRequest(r *http.Request) buildFormInput { return buildFormInput{ DraftID: strings.TrimSpace(r.FormValue("draft_id")), DraftSource: strings.TrimSpace(r.FormValue("draft_source")), DraftStatus: strings.TrimSpace(r.FormValue("draft_status")), DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")), RequestName: strings.TrimSpace(r.FormValue("request_name")), CompanyName: strings.TrimSpace(r.FormValue("company_name")), BusinessType: strings.TrimSpace(r.FormValue("business_type")), Username: strings.TrimSpace(r.FormValue("username")), Email: strings.TrimSpace(r.FormValue("email")), Phone: strings.TrimSpace(r.FormValue("phone")), OrgNumber: strings.TrimSpace(r.FormValue("org_number")), StartDate: strings.TrimSpace(r.FormValue("start_date")), Mission: strings.TrimSpace(r.FormValue("mission")), DescriptionShort: strings.TrimSpace(r.FormValue("description_short")), DescriptionLong: strings.TrimSpace(r.FormValue("description_long")), SiteLanguage: strings.TrimSpace(r.FormValue("site_language")), AddressLine1: strings.TrimSpace(r.FormValue("address_line1")), AddressLine2: strings.TrimSpace(r.FormValue("address_line2")), AddressCity: strings.TrimSpace(r.FormValue("address_city")), AddressRegion: strings.TrimSpace(r.FormValue("address_region")), AddressZIP: strings.TrimSpace(r.FormValue("address_zip")), AddressCountry: strings.TrimSpace(r.FormValue("address_country")), } } func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput { form := buildFormInput{ DraftID: draft.ID, DraftSource: draft.Source, DraftStatus: draft.Status, DraftNotes: draft.Notes, RequestName: draft.RequestName, } mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON) return form } func parseBuildFieldValues(r *http.Request) map[string]string { fieldValues := map[string]string{} count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count"))) for i := 0; i < count; i++ { path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i))) value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i))) if path != "" { fieldValues[path] = value } } return fieldValues } func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) { if len(raw) == 0 { return nil, nil } var payload struct { GlobalData map[string]any `json:"globalData"` } if err := json.Unmarshal(raw, &payload); err != nil { return nil, err } if len(payload.GlobalData) == 0 { return nil, nil } data, err := json.Marshal(payload.GlobalData) if err != nil { return nil, err } return data, nil } func parseFieldValuesJSON(raw []byte) map[string]string { out := map[string]string{} if len(raw) == 0 { return out } _ = json.Unmarshal(raw, &out) return out } func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) { if form == nil || len(raw) == 0 { return } var global map[string]any if err := json.Unmarshal(raw, &global); err != nil { return } form.CompanyName = getString(global["companyName"]) form.BusinessType = getString(global["businessType"]) form.Username = getString(global["username"]) form.Email = getString(global["email"]) form.Phone = getString(global["phone"]) form.OrgNumber = getString(global["orgNumber"]) form.StartDate = getString(global["startDate"]) form.Mission = getString(global["mission"]) form.DescriptionShort = getString(global["descriptionShort"]) form.DescriptionLong = getString(global["descriptionLong"]) form.SiteLanguage = getString(global["siteLanguage"]) address, _ := global["address"].(map[string]any) form.AddressLine1 = getString(address["line1"]) form.AddressLine2 = getString(address["line2"]) form.AddressCity = getString(address["city"]) form.AddressRegion = getString(address["region"]) form.AddressZIP = getString(address["zip"]) form.AddressCountry = getString(address["country"]) } func getString(v any) string { s, _ := v.(string) return strings.TrimSpace(s) } func defaultDraftStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "reviewed", "submitted": return strings.ToLower(strings.TrimSpace(status)) default: return "draft" } }