|
- 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/onboarding"
- "qctextbuilder/internal/templatesvc"
- )
-
- type UI struct {
- templateSvc *templatesvc.Service
- onboardSvc *onboarding.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
- SelectedTemplateID int64
- SelectedManifestID string
- EnabledFields []buildFieldView
- Form buildFormInput
- }
-
- type buildFormInput struct {
- 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, buildSvc buildsvc.Service, cfg config.Config, render htmlRenderer) *UI {
- return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, 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)
- data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedTemplateID, buildFormInput{}, nil)
- 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)
-
- 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: 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,
- }),
- FieldValues: fieldValues,
- })
- if err != nil {
- data, loadErr := u.loadBuildNewPageData(r, pageData{
- Title: "New Build",
- Err: err.Error(),
- Current: "/builds/new",
- }, 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/%s?msg=build+started", result.BuildID), 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, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string) (buildNewPageData, error) {
- templates, err := u.templateSvc.ListTemplates(r.Context())
- if err != nil {
- return buildNewPageData{}, err
- }
-
- data := buildNewPageData{
- pageData: page,
- Templates: templates,
- 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{
- 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 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
- }
|