Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

447 lines
14KB

  1. package handlers
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "strconv"
  6. "strings"
  7. "github.com/go-chi/chi/v5"
  8. "qctextbuilder/internal/buildsvc"
  9. "qctextbuilder/internal/domain"
  10. "qctextbuilder/internal/draftsvc"
  11. "qctextbuilder/internal/logging"
  12. "qctextbuilder/internal/onboarding"
  13. "qctextbuilder/internal/templatesvc"
  14. )
  15. type API struct {
  16. templateSvc *templatesvc.Service
  17. onboardSvc *onboarding.Service
  18. draftSvc *draftsvc.Service
  19. buildSvc buildsvc.Service
  20. recentLogs *logging.RecentStore
  21. }
  22. func NewAPI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, recentLogs *logging.RecentStore) *API {
  23. return &API{
  24. templateSvc: templateSvc,
  25. onboardSvc: onboardSvc,
  26. draftSvc: draftSvc,
  27. buildSvc: buildSvc,
  28. recentLogs: recentLogs,
  29. }
  30. }
  31. func (a *API) Health(w http.ResponseWriter, _ *http.Request) {
  32. writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
  33. }
  34. func (a *API) SyncTemplates(w http.ResponseWriter, r *http.Request) {
  35. templates, err := a.templateSvc.SyncAITemplates(r.Context())
  36. if err != nil {
  37. writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
  38. return
  39. }
  40. writeJSON(w, http.StatusOK, map[string]any{"count": len(templates), "templates": templates})
  41. }
  42. func (a *API) ListTemplates(w http.ResponseWriter, r *http.Request) {
  43. templates, err := a.templateSvc.ListTemplates(r.Context())
  44. if err != nil {
  45. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  46. return
  47. }
  48. writeJSON(w, http.StatusOK, map[string]any{"count": len(templates), "templates": templates})
  49. }
  50. func (a *API) GetTemplateDetail(w http.ResponseWriter, r *http.Request) {
  51. rawID := chi.URLParam(r, "id")
  52. templateID, err := strconv.ParseInt(rawID, 10, 64)
  53. if err != nil {
  54. writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"})
  55. return
  56. }
  57. detail, err := a.templateSvc.GetTemplateDetail(r.Context(), templateID)
  58. if err != nil {
  59. writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
  60. return
  61. }
  62. writeJSON(w, http.StatusOK, detail)
  63. }
  64. func (a *API) OnboardTemplate(w http.ResponseWriter, r *http.Request) {
  65. rawID := chi.URLParam(r, "id")
  66. templateID, err := strconv.ParseInt(rawID, 10, 64)
  67. if err != nil {
  68. writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"})
  69. return
  70. }
  71. manifest, fields, err := a.onboardSvc.OnboardTemplate(r.Context(), templateID)
  72. if err != nil {
  73. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  74. return
  75. }
  76. writeJSON(w, http.StatusOK, map[string]any{
  77. "manifestId": manifest.ID,
  78. "fieldCount": len(fields),
  79. "status": "reviewed",
  80. })
  81. }
  82. type updateTemplateFieldsRequest struct {
  83. ManifestID string `json:"manifestId"`
  84. Fields []updateTemplateFieldItem `json:"fields"`
  85. }
  86. type updateTemplateFieldItem struct {
  87. Path string `json:"path"`
  88. IsEnabled *bool `json:"isEnabled,omitempty"`
  89. IsRequiredByUs *bool `json:"isRequiredByUs,omitempty"`
  90. DisplayLabel *string `json:"displayLabel,omitempty"`
  91. DisplayOrder *int `json:"displayOrder,omitempty"`
  92. WebsiteSection *string `json:"websiteSection,omitempty"`
  93. Notes *string `json:"notes,omitempty"`
  94. }
  95. func (a *API) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
  96. rawID := chi.URLParam(r, "id")
  97. templateID, err := strconv.ParseInt(rawID, 10, 64)
  98. if err != nil {
  99. writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid template id"})
  100. return
  101. }
  102. var req updateTemplateFieldsRequest
  103. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  104. writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
  105. return
  106. }
  107. if len(req.Fields) == 0 {
  108. writeJSON(w, http.StatusBadRequest, map[string]any{"error": "fields is required"})
  109. return
  110. }
  111. patches := make([]onboarding.FieldPatch, 0, len(req.Fields))
  112. for _, f := range req.Fields {
  113. patches = append(patches, onboarding.FieldPatch{
  114. Path: f.Path,
  115. IsEnabled: f.IsEnabled,
  116. IsRequiredByUs: f.IsRequiredByUs,
  117. DisplayLabel: f.DisplayLabel,
  118. DisplayOrder: f.DisplayOrder,
  119. WebsiteSection: f.WebsiteSection,
  120. Notes: f.Notes,
  121. })
  122. }
  123. manifest, fields, err := a.onboardSvc.UpdateTemplateFields(r.Context(), templateID, req.ManifestID, patches)
  124. if err != nil {
  125. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  126. return
  127. }
  128. writeJSON(w, http.StatusOK, map[string]any{
  129. "templateId": templateID,
  130. "manifestId": manifest.ID,
  131. "fieldCount": len(fields),
  132. "fields": fields,
  133. })
  134. }
  135. func (a *API) StartBuild(w http.ResponseWriter, r *http.Request) {
  136. var req buildsvc.StartBuildRequest
  137. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  138. writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
  139. return
  140. }
  141. result, err := a.buildSvc.StartBuild(r.Context(), req)
  142. if err != nil {
  143. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  144. return
  145. }
  146. writeJSON(w, http.StatusAccepted, result)
  147. }
  148. type upsertDraftRequest struct {
  149. TemplateID *int64 `json:"templateId,omitempty"`
  150. ManifestID string `json:"manifestId"`
  151. Source string `json:"source"`
  152. RequestName string `json:"requestName"`
  153. GlobalData map[string]any `json:"globalData"`
  154. FieldValues map[string]string `json:"fieldValues"`
  155. DraftContext *domain.DraftContext `json:"draftContext,omitempty"`
  156. SuggestionState *domain.DraftSuggestionState `json:"suggestionState,omitempty"`
  157. Status string `json:"status"`
  158. Notes string `json:"notes"`
  159. }
  160. type intakeDraftRequest struct {
  161. DraftID string `json:"draftId,omitempty"`
  162. Source string `json:"source"`
  163. RequestName string `json:"requestName"`
  164. TemplateID *int64 `json:"templateId,omitempty"`
  165. GlobalData map[string]any `json:"globalData"`
  166. Notes string `json:"notes"`
  167. WebsiteURL string `json:"websiteUrl,omitempty"`
  168. WebsiteSummary string `json:"websiteSummary,omitempty"`
  169. BusinessType string `json:"businessType,omitempty"`
  170. LocaleStyle string `json:"localeStyle,omitempty"`
  171. MarketStyle string `json:"marketStyle,omitempty"`
  172. AddressMode string `json:"addressMode,omitempty"`
  173. ContentTone string `json:"contentTone,omitempty"`
  174. PromptInstructions string `json:"promptInstructions,omitempty"`
  175. StyleProfile *domain.DraftStyleProfile `json:"styleProfile,omitempty"`
  176. }
  177. func (a *API) IntakeDraft(w http.ResponseWriter, r *http.Request) {
  178. var req intakeDraftRequest
  179. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  180. writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
  181. return
  182. }
  183. globalData := req.GlobalData
  184. if globalData == nil {
  185. globalData = map[string]any{}
  186. }
  187. if strings.TrimSpace(req.BusinessType) != "" && strings.TrimSpace(getMapString(globalData, "businessType")) == "" {
  188. globalData["businessType"] = strings.TrimSpace(req.BusinessType)
  189. }
  190. normalizeFlatAddress(globalData)
  191. styleProfile := domain.DraftStyleProfile{
  192. LocaleStyle: strings.TrimSpace(req.LocaleStyle),
  193. MarketStyle: strings.TrimSpace(req.MarketStyle),
  194. AddressMode: strings.TrimSpace(req.AddressMode),
  195. ContentTone: strings.TrimSpace(req.ContentTone),
  196. PromptInstructions: strings.TrimSpace(req.PromptInstructions),
  197. }
  198. if req.StyleProfile != nil {
  199. styleProfile = *req.StyleProfile
  200. if styleProfile.LocaleStyle == "" {
  201. styleProfile.LocaleStyle = strings.TrimSpace(req.LocaleStyle)
  202. }
  203. if styleProfile.MarketStyle == "" {
  204. styleProfile.MarketStyle = strings.TrimSpace(req.MarketStyle)
  205. }
  206. if styleProfile.AddressMode == "" {
  207. styleProfile.AddressMode = strings.TrimSpace(req.AddressMode)
  208. }
  209. if styleProfile.ContentTone == "" {
  210. styleProfile.ContentTone = strings.TrimSpace(req.ContentTone)
  211. }
  212. if styleProfile.PromptInstructions == "" {
  213. styleProfile.PromptInstructions = strings.TrimSpace(req.PromptInstructions)
  214. }
  215. }
  216. businessType := strings.TrimSpace(req.BusinessType)
  217. if businessType == "" {
  218. businessType = strings.TrimSpace(getMapString(globalData, "businessType"))
  219. }
  220. draftContext := &domain.DraftContext{
  221. IntakeSource: strings.TrimSpace(req.Source),
  222. LLM: domain.DraftLLMContext{
  223. BusinessType: businessType,
  224. WebsiteURL: strings.TrimSpace(req.WebsiteURL),
  225. WebsiteSummary: strings.TrimSpace(req.WebsiteSummary),
  226. StyleProfile: styleProfile,
  227. },
  228. }
  229. draft, err := a.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  230. DraftID: strings.TrimSpace(req.DraftID),
  231. TemplateID: req.TemplateID,
  232. Source: defaultStr(req.Source, "intake-api"),
  233. RequestName: req.RequestName,
  234. GlobalData: globalData,
  235. FieldValues: map[string]string{},
  236. DraftContext: draftContext,
  237. Status: "draft",
  238. Notes: req.Notes,
  239. })
  240. if err != nil {
  241. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  242. return
  243. }
  244. if strings.TrimSpace(req.DraftID) == "" {
  245. writeJSON(w, http.StatusCreated, draft)
  246. return
  247. }
  248. writeJSON(w, http.StatusOK, draft)
  249. }
  250. func (a *API) ListDrafts(w http.ResponseWriter, r *http.Request) {
  251. limit, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit")))
  252. drafts, err := a.draftSvc.ListDrafts(r.Context(), limit)
  253. if err != nil {
  254. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  255. return
  256. }
  257. writeJSON(w, http.StatusOK, map[string]any{"count": len(drafts), "drafts": drafts})
  258. }
  259. func (a *API) GetDraft(w http.ResponseWriter, r *http.Request) {
  260. draftID := strings.TrimSpace(chi.URLParam(r, "id"))
  261. draft, err := a.draftSvc.GetDraft(r.Context(), draftID)
  262. if err != nil {
  263. writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
  264. return
  265. }
  266. writeJSON(w, http.StatusOK, draft)
  267. }
  268. func (a *API) UpdateDraft(w http.ResponseWriter, r *http.Request) {
  269. draftID := strings.TrimSpace(chi.URLParam(r, "id"))
  270. var req upsertDraftRequest
  271. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  272. writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
  273. return
  274. }
  275. draft, err := a.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  276. DraftID: draftID,
  277. TemplateID: req.TemplateID,
  278. ManifestID: req.ManifestID,
  279. Source: req.Source,
  280. RequestName: req.RequestName,
  281. GlobalData: req.GlobalData,
  282. FieldValues: req.FieldValues,
  283. DraftContext: req.DraftContext,
  284. SuggestionState: req.SuggestionState,
  285. Status: req.Status,
  286. Notes: req.Notes,
  287. })
  288. if err != nil {
  289. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  290. return
  291. }
  292. writeJSON(w, http.StatusOK, draft)
  293. }
  294. func (a *API) GetBuild(w http.ResponseWriter, r *http.Request) {
  295. buildID := chi.URLParam(r, "id")
  296. build, err := a.buildSvc.GetBuild(r.Context(), buildID)
  297. if err != nil {
  298. writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
  299. return
  300. }
  301. writeJSON(w, http.StatusOK, build)
  302. }
  303. func (a *API) PollBuildOnce(w http.ResponseWriter, r *http.Request) {
  304. buildID := chi.URLParam(r, "id")
  305. if err := a.buildSvc.PollOnce(r.Context(), buildID); err != nil {
  306. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  307. return
  308. }
  309. build, err := a.buildSvc.GetBuild(r.Context(), buildID)
  310. if err != nil {
  311. writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
  312. return
  313. }
  314. writeJSON(w, http.StatusOK, build)
  315. }
  316. func (a *API) FetchBuildEditorURL(w http.ResponseWriter, r *http.Request) {
  317. buildID := chi.URLParam(r, "id")
  318. if err := a.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil {
  319. writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
  320. return
  321. }
  322. build, err := a.buildSvc.GetBuild(r.Context(), buildID)
  323. if err != nil {
  324. writeJSON(w, http.StatusNotFound, map[string]any{"error": err.Error()})
  325. return
  326. }
  327. writeJSON(w, http.StatusOK, build)
  328. }
  329. func (a *API) ListLogs(w http.ResponseWriter, r *http.Request) {
  330. limit, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit")))
  331. if limit <= 0 {
  332. limit = 100
  333. }
  334. if limit > 500 {
  335. limit = 500
  336. }
  337. logs := a.recentLogs.List(limit)
  338. writeJSON(w, http.StatusOK, map[string]any{
  339. "count": len(logs),
  340. "logs": logs,
  341. })
  342. }
  343. func writeJSON(w http.ResponseWriter, status int, v any) {
  344. w.Header().Set("Content-Type", "application/json")
  345. w.WriteHeader(status)
  346. _ = json.NewEncoder(w).Encode(v)
  347. }
  348. func defaultStr(v, fallback string) string {
  349. if strings.TrimSpace(v) == "" {
  350. return fallback
  351. }
  352. return strings.TrimSpace(v)
  353. }
  354. func getMapString(values map[string]any, key string) string {
  355. if values == nil {
  356. return ""
  357. }
  358. raw, _ := values[key].(string)
  359. return raw
  360. }
  361. // normalizeFlatAddress folds flat addressXxx keys (as sent by Leadharvester)
  362. // into the nested globalData["address"] map the rest of the codebase expects.
  363. // Nested values win over flat ones if both are present.
  364. func normalizeFlatAddress(globalData map[string]any) {
  365. if globalData == nil {
  366. return
  367. }
  368. mapping := map[string]string{
  369. "addressLine1": "line1",
  370. "addressLine2": "line2",
  371. "addressCity": "city",
  372. "addressRegion": "region",
  373. "addressZIP": "zip",
  374. "addressCountry": "country",
  375. }
  376. address, _ := globalData["address"].(map[string]any)
  377. if address == nil {
  378. address = map[string]any{}
  379. }
  380. changed := false
  381. for flat, nested := range mapping {
  382. raw, ok := globalData[flat]
  383. if !ok {
  384. continue
  385. }
  386. delete(globalData, flat)
  387. value, ok := raw.(string)
  388. if !ok {
  389. continue
  390. }
  391. value = strings.TrimSpace(value)
  392. if value == "" {
  393. continue
  394. }
  395. if existing, _ := address[nested].(string); strings.TrimSpace(existing) != "" {
  396. continue
  397. }
  398. address[nested] = value
  399. changed = true
  400. }
  401. if changed || len(address) > 0 {
  402. if len(address) > 0 {
  403. globalData["address"] = address
  404. }
  405. }
  406. }