Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

831 řádky
27KB

  1. package sqlite
  2. import (
  3. "context"
  4. "database/sql"
  5. "embed"
  6. "encoding/json"
  7. "fmt"
  8. "io/fs"
  9. "os"
  10. "path/filepath"
  11. "sort"
  12. "strings"
  13. "time"
  14. _ "modernc.org/sqlite"
  15. "qctextbuilder/internal/domain"
  16. "qctextbuilder/internal/store"
  17. )
  18. //go:embed migrations/*.sql
  19. var migrationFS embed.FS
  20. type Store struct {
  21. db *sql.DB
  22. }
  23. func New(dbPath string) (*Store, error) {
  24. path := strings.TrimSpace(dbPath)
  25. if path == "" {
  26. path = "data/qctextbuilder.db"
  27. }
  28. if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
  29. return nil, fmt.Errorf("create db directory: %w", err)
  30. }
  31. db, err := sql.Open("sqlite", path)
  32. if err != nil {
  33. return nil, fmt.Errorf("open sqlite: %w", err)
  34. }
  35. db.SetMaxOpenConns(1)
  36. if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
  37. _ = db.Close()
  38. return nil, fmt.Errorf("enable foreign keys: %w", err)
  39. }
  40. if err := runMigrations(db); err != nil {
  41. _ = db.Close()
  42. return nil, fmt.Errorf("run migrations: %w", err)
  43. }
  44. return &Store{db: db}, nil
  45. }
  46. func (s *Store) Close() error {
  47. if s == nil || s.db == nil {
  48. return nil
  49. }
  50. return s.db.Close()
  51. }
  52. func (s *Store) UpsertTemplates(ctx context.Context, templates []domain.Template) error {
  53. tx, err := s.db.BeginTx(ctx, nil)
  54. if err != nil {
  55. return err
  56. }
  57. defer rollback(tx)
  58. stmt := `
  59. INSERT INTO qc_templates (
  60. id, name, description, locale, thumbnail_url, template_preview_url, type,
  61. palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at, updated_at
  62. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT is_onboarded FROM qc_templates WHERE id = ?), ?),
  63. COALESCE((SELECT manifest_status FROM qc_templates WHERE id = ?), ?),
  64. COALESCE((SELECT last_discovered_at FROM qc_templates WHERE id = ?), ?),
  65. ?
  66. )
  67. ON CONFLICT(id) DO UPDATE SET
  68. name = excluded.name,
  69. description = excluded.description,
  70. locale = excluded.locale,
  71. thumbnail_url = excluded.thumbnail_url,
  72. template_preview_url = excluded.template_preview_url,
  73. type = excluded.type,
  74. palette_ready = excluded.palette_ready,
  75. raw_template_json = excluded.raw_template_json,
  76. is_ai_template = excluded.is_ai_template,
  77. updated_at = excluded.updated_at;
  78. `
  79. now := time.Now().UTC()
  80. for _, t := range templates {
  81. _, err := tx.ExecContext(ctx, stmt,
  82. t.ID, t.Name, t.Description, t.Locale, t.ThumbnailURL, t.TemplatePreviewURL, t.Type,
  83. boolToInt(t.PaletteReady), asRaw(t.RawJSON), boolToInt(t.IsAITemplate),
  84. t.ID, boolToInt(t.IsOnboarded),
  85. t.ID, defaultString(t.ManifestStatus, "missing"),
  86. t.ID, asRFC3339Ptr(t.LastDiscoveredAt),
  87. now.Format(time.RFC3339Nano),
  88. )
  89. if err != nil {
  90. return fmt.Errorf("upsert template %d: %w", t.ID, err)
  91. }
  92. }
  93. return tx.Commit()
  94. }
  95. func (s *Store) GetTemplateByID(ctx context.Context, id int64) (*domain.Template, error) {
  96. row := s.db.QueryRowContext(ctx, `
  97. SELECT id, name, description, locale, thumbnail_url, template_preview_url, type,
  98. palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at
  99. FROM qc_templates
  100. WHERE id = ?`, id)
  101. t, err := scanTemplate(row.Scan)
  102. if err != nil {
  103. return nil, err
  104. }
  105. return t, nil
  106. }
  107. func (s *Store) ListTemplates(ctx context.Context) ([]domain.Template, error) {
  108. rows, err := s.db.QueryContext(ctx, `
  109. SELECT id, name, description, locale, thumbnail_url, template_preview_url, type,
  110. palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at
  111. FROM qc_templates`)
  112. if err != nil {
  113. return nil, err
  114. }
  115. defer rows.Close()
  116. out := make([]domain.Template, 0)
  117. for rows.Next() {
  118. t, err := scanTemplate(rows.Scan)
  119. if err != nil {
  120. return nil, err
  121. }
  122. out = append(out, *t)
  123. }
  124. return out, rows.Err()
  125. }
  126. func (s *Store) SetTemplateManifestStatus(ctx context.Context, templateID int64, status string, onboarded bool) error {
  127. res, err := s.db.ExecContext(ctx, `
  128. UPDATE qc_templates
  129. SET manifest_status = ?, is_onboarded = ?, last_discovered_at = ?, updated_at = ?
  130. WHERE id = ?`,
  131. defaultString(status, "missing"),
  132. boolToInt(onboarded),
  133. time.Now().UTC().Format(time.RFC3339Nano),
  134. time.Now().UTC().Format(time.RFC3339Nano),
  135. templateID,
  136. )
  137. if err != nil {
  138. return err
  139. }
  140. n, _ := res.RowsAffected()
  141. if n == 0 {
  142. return store.ErrNotFound
  143. }
  144. return nil
  145. }
  146. func (s *Store) CreateManifest(ctx context.Context, manifest domain.TemplateManifest, fields []domain.TemplateField) error {
  147. tx, err := s.db.BeginTx(ctx, nil)
  148. if err != nil {
  149. return err
  150. }
  151. defer rollback(tx)
  152. if _, err := tx.ExecContext(ctx, `UPDATE qc_template_manifests SET is_active = 0 WHERE template_id = ?`, manifest.TemplateID); err != nil {
  153. return err
  154. }
  155. _, err = tx.ExecContext(ctx, `
  156. INSERT INTO qc_template_manifests (
  157. id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json,
  158. discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at
  159. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  160. manifest.ID, manifest.TemplateID, manifest.Version, manifest.Source, manifest.LanguageUsedDiscovery,
  161. asRaw(manifest.DiscoveryPayloadJSON), asRaw(manifest.DiscoveryResponseJSON), asRaw(manifest.FlattenedManifestJSON),
  162. boolToInt(manifest.IsActive), manifest.CreatedAt.UTC().Format(time.RFC3339Nano), manifest.UpdatedAt.UTC().Format(time.RFC3339Nano),
  163. )
  164. if err != nil {
  165. return err
  166. }
  167. if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifest.ID); err != nil {
  168. return err
  169. }
  170. for _, f := range fields {
  171. _, err := tx.ExecContext(ctx, `
  172. INSERT INTO qc_template_fields (
  173. id, template_id, manifest_id, section, website_section, key_name, path, field_kind,
  174. sample_value, is_enabled, is_required_by_us, display_label, display_order, notes,
  175. semantic_slot, mapping_source, mapping_confidence
  176. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  177. f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind,
  178. f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
  179. strings.TrimSpace(f.SemanticSlot), strings.TrimSpace(f.MappingSource), f.MappingConfidence,
  180. )
  181. if err != nil {
  182. return err
  183. }
  184. }
  185. return tx.Commit()
  186. }
  187. func (s *Store) GetActiveManifestByTemplateID(ctx context.Context, templateID int64) (*domain.TemplateManifest, error) {
  188. row := s.db.QueryRowContext(ctx, `
  189. SELECT id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json,
  190. discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at
  191. FROM qc_template_manifests
  192. WHERE template_id = ? AND is_active = 1
  193. ORDER BY created_at DESC
  194. LIMIT 1`, templateID)
  195. manifest, err := scanManifest(row.Scan)
  196. if err != nil {
  197. return nil, err
  198. }
  199. return manifest, nil
  200. }
  201. func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) {
  202. rows, err := s.db.QueryContext(ctx, `
  203. SELECT id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value,
  204. is_enabled, is_required_by_us, display_label, display_order, notes,
  205. semantic_slot, mapping_source, mapping_confidence
  206. FROM qc_template_fields
  207. WHERE manifest_id = ?
  208. ORDER BY display_order ASC, id ASC`, manifestID)
  209. if err != nil {
  210. return nil, err
  211. }
  212. defer rows.Close()
  213. fields := make([]domain.TemplateField, 0)
  214. for rows.Next() {
  215. var f domain.TemplateField
  216. var isEnabled, isRequired int
  217. if err := rows.Scan(
  218. &f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.WebsiteSection, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue,
  219. &isEnabled, &isRequired, &f.DisplayLabel, &f.DisplayOrder, &f.Notes,
  220. &f.SemanticSlot, &f.MappingSource, &f.MappingConfidence,
  221. ); err != nil {
  222. return nil, err
  223. }
  224. f.IsEnabled = isEnabled == 1
  225. f.IsRequiredByUs = isRequired == 1
  226. f.WebsiteSection = domain.NormalizeWebsiteSection(f.WebsiteSection)
  227. f.SemanticSlot = strings.TrimSpace(f.SemanticSlot)
  228. f.MappingSource = strings.TrimSpace(f.MappingSource)
  229. fields = append(fields, f)
  230. }
  231. if err := rows.Err(); err != nil {
  232. return nil, err
  233. }
  234. if len(fields) == 0 {
  235. return nil, store.ErrNotFound
  236. }
  237. return fields, nil
  238. }
  239. func (s *Store) UpdateFields(ctx context.Context, manifestID string, fields []domain.TemplateField) error {
  240. tx, err := s.db.BeginTx(ctx, nil)
  241. if err != nil {
  242. return err
  243. }
  244. defer rollback(tx)
  245. var exists int
  246. if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM qc_template_manifests WHERE id = ?`, manifestID).Scan(&exists); err != nil {
  247. return err
  248. }
  249. if exists == 0 {
  250. return store.ErrNotFound
  251. }
  252. if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifestID); err != nil {
  253. return err
  254. }
  255. for _, f := range fields {
  256. _, err := tx.ExecContext(ctx, `
  257. INSERT INTO qc_template_fields (
  258. id, template_id, manifest_id, section, website_section, key_name, path, field_kind,
  259. sample_value, is_enabled, is_required_by_us, display_label, display_order, notes,
  260. semantic_slot, mapping_source, mapping_confidence
  261. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  262. f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind,
  263. f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
  264. strings.TrimSpace(f.SemanticSlot), strings.TrimSpace(f.MappingSource), f.MappingConfidence,
  265. )
  266. if err != nil {
  267. return err
  268. }
  269. }
  270. return tx.Commit()
  271. }
  272. func (s *Store) CreateBuild(ctx context.Context, build domain.SiteBuild) error {
  273. _, err := s.db.ExecContext(ctx, `
  274. INSERT INTO site_builds (
  275. id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
  276. qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
  277. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  278. build.ID, build.TemplateID, build.ManifestID, build.RequestName, asRaw(build.GlobalDataJSON), asRaw(build.AIDataJSON), asRaw(build.FinalSitesPayload),
  279. build.QCJobID, build.QCSiteID, build.QCStatus, build.QCPreviewURL, build.QCEditorURL, asRaw(build.QCResultJSON), asRaw(build.QCErrorJSON),
  280. asRFC3339Ptr(build.StartedAt), asRFC3339Ptr(build.FinishedAt),
  281. )
  282. return err
  283. }
  284. func (s *Store) GetBuildByID(ctx context.Context, id string) (*domain.SiteBuild, error) {
  285. row := s.db.QueryRowContext(ctx, `
  286. SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
  287. qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
  288. FROM site_builds
  289. WHERE id = ?`, id)
  290. build, err := scanBuild(row.Scan)
  291. if err != nil {
  292. return nil, err
  293. }
  294. return build, nil
  295. }
  296. func (s *Store) ListBuildsByStatuses(ctx context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) {
  297. base := `
  298. SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
  299. qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
  300. FROM site_builds`
  301. args := make([]any, 0)
  302. parts := make([]string, 0)
  303. if len(statuses) > 0 {
  304. placeholders := make([]string, 0, len(statuses))
  305. for _, status := range statuses {
  306. placeholders = append(placeholders, "?")
  307. args = append(args, status)
  308. }
  309. parts = append(parts, "qc_status IN ("+strings.Join(placeholders, ", ")+")")
  310. }
  311. query := base
  312. if len(parts) > 0 {
  313. query += " WHERE " + strings.Join(parts, " AND ")
  314. }
  315. query += " ORDER BY started_at ASC, id ASC"
  316. if limit > 0 {
  317. query += " LIMIT ?"
  318. args = append(args, limit)
  319. }
  320. rows, err := s.db.QueryContext(ctx, query, args...)
  321. if err != nil {
  322. return nil, err
  323. }
  324. defer rows.Close()
  325. builds := make([]domain.SiteBuild, 0)
  326. for rows.Next() {
  327. build, err := scanBuild(rows.Scan)
  328. if err != nil {
  329. return nil, err
  330. }
  331. builds = append(builds, *build)
  332. }
  333. return builds, rows.Err()
  334. }
  335. func (s *Store) MarkBuildSubmitted(ctx context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error {
  336. res, err := s.db.ExecContext(ctx, `
  337. UPDATE site_builds
  338. SET qc_job_id = ?, qc_status = ?, qc_result_json = ?, started_at = ?
  339. WHERE id = ?`,
  340. jobID, status, asRaw(qcResult), startedAt.UTC().Format(time.RFC3339Nano), buildID,
  341. )
  342. if err != nil {
  343. return err
  344. }
  345. n, _ := res.RowsAffected()
  346. if n == 0 {
  347. return store.ErrNotFound
  348. }
  349. return nil
  350. }
  351. func (s *Store) UpdateBuildFromJob(ctx context.Context, buildID string, status string, siteID *int64, previewURL string, qcResult json.RawMessage, qcError json.RawMessage, finishedAt *time.Time) error {
  352. res, err := s.db.ExecContext(ctx, `
  353. UPDATE site_builds
  354. SET qc_status = ?, qc_site_id = COALESCE(?, qc_site_id), qc_preview_url = ?, qc_result_json = ?, qc_error_json = ?, finished_at = ?
  355. WHERE id = ?`,
  356. status, siteID, previewURL, asRaw(qcResult), asRaw(qcError), asRFC3339Ptr(finishedAt), buildID,
  357. )
  358. if err != nil {
  359. return err
  360. }
  361. n, _ := res.RowsAffected()
  362. if n == 0 {
  363. return store.ErrNotFound
  364. }
  365. return nil
  366. }
  367. func (s *Store) UpdateBuildEditorURL(ctx context.Context, buildID string, editorURL string, qcResult json.RawMessage) error {
  368. res, err := s.db.ExecContext(ctx, `
  369. UPDATE site_builds
  370. SET qc_editor_url = ?, qc_result_json = ?
  371. WHERE id = ?`,
  372. editorURL, asRaw(qcResult), buildID,
  373. )
  374. if err != nil {
  375. return err
  376. }
  377. n, _ := res.RowsAffected()
  378. if n == 0 {
  379. return store.ErrNotFound
  380. }
  381. return nil
  382. }
  383. func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) error {
  384. promptBlocksRaw, err := json.Marshal(domain.NormalizePromptBlocks(settings.PromptBlocks))
  385. if err != nil {
  386. return fmt.Errorf("marshal prompt blocks: %w", err)
  387. }
  388. provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
  389. model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
  390. _, err = s.db.ExecContext(ctx, `
  391. INSERT INTO app_settings (
  392. id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds,
  393. llm_active_provider, llm_active_model, llm_base_url, llm_temperature, llm_max_tokens,
  394. openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted,
  395. google_places_api_key_encrypted,
  396. master_prompt, prompt_blocks_json, updated_at
  397. ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  398. ON CONFLICT(id) DO UPDATE SET
  399. qc_base_url = excluded.qc_base_url,
  400. qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted,
  401. language_output_mode = excluded.language_output_mode,
  402. job_poll_interval_seconds = excluded.job_poll_interval_seconds,
  403. job_poll_timeout_seconds = excluded.job_poll_timeout_seconds,
  404. llm_active_provider = excluded.llm_active_provider,
  405. llm_active_model = excluded.llm_active_model,
  406. llm_base_url = excluded.llm_base_url,
  407. llm_temperature = excluded.llm_temperature,
  408. llm_max_tokens = excluded.llm_max_tokens,
  409. openai_api_key_encrypted = excluded.openai_api_key_encrypted,
  410. anthropic_api_key_encrypted = excluded.anthropic_api_key_encrypted,
  411. google_api_key_encrypted = excluded.google_api_key_encrypted,
  412. xai_api_key_encrypted = excluded.xai_api_key_encrypted,
  413. ollama_api_key_encrypted = excluded.ollama_api_key_encrypted,
  414. google_places_api_key_encrypted = excluded.google_places_api_key_encrypted,
  415. master_prompt = excluded.master_prompt,
  416. prompt_blocks_json = excluded.prompt_blocks_json,
  417. updated_at = excluded.updated_at`,
  418. settings.QCBaseURL,
  419. settings.QCBearerTokenEncrypted,
  420. defaultString(settings.LanguageOutputMode, "EN"),
  421. settings.JobPollIntervalSeconds,
  422. settings.JobPollTimeoutSeconds,
  423. provider,
  424. model,
  425. strings.TrimSpace(settings.LLMBaseURL),
  426. domain.NormalizeLLMTemperature(settings.LLMTemperature),
  427. domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens),
  428. strings.TrimSpace(settings.OpenAIAPIKeyEncrypted),
  429. strings.TrimSpace(settings.AnthropicAPIKeyEncrypted),
  430. strings.TrimSpace(settings.GoogleAPIKeyEncrypted),
  431. strings.TrimSpace(settings.XAIAPIKeyEncrypted),
  432. strings.TrimSpace(settings.OllamaAPIKeyEncrypted),
  433. strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted),
  434. domain.NormalizeMasterPrompt(settings.MasterPrompt),
  435. promptBlocksRaw,
  436. time.Now().UTC().Format(time.RFC3339Nano),
  437. )
  438. return err
  439. }
  440. func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) {
  441. row := s.db.QueryRowContext(ctx, `
  442. SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds,
  443. llm_active_provider, llm_active_model, llm_base_url, llm_temperature, llm_max_tokens,
  444. openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted,
  445. google_places_api_key_encrypted,
  446. master_prompt, prompt_blocks_json
  447. FROM app_settings
  448. WHERE id = 1`)
  449. var settings domain.AppSettings
  450. var promptBlocksRaw []byte
  451. if err := row.Scan(
  452. &settings.QCBaseURL,
  453. &settings.QCBearerTokenEncrypted,
  454. &settings.LanguageOutputMode,
  455. &settings.JobPollIntervalSeconds,
  456. &settings.JobPollTimeoutSeconds,
  457. &settings.LLMActiveProvider,
  458. &settings.LLMActiveModel,
  459. &settings.LLMBaseURL,
  460. &settings.LLMTemperature,
  461. &settings.LLMMaxTokens,
  462. &settings.OpenAIAPIKeyEncrypted,
  463. &settings.AnthropicAPIKeyEncrypted,
  464. &settings.GoogleAPIKeyEncrypted,
  465. &settings.XAIAPIKeyEncrypted,
  466. &settings.OllamaAPIKeyEncrypted,
  467. &settings.GooglePlacesAPIKeyEncrypted,
  468. &settings.MasterPrompt,
  469. &promptBlocksRaw,
  470. ); err != nil {
  471. if err == sql.ErrNoRows {
  472. return nil, store.ErrNotFound
  473. }
  474. return nil, err
  475. }
  476. settings.MasterPrompt = domain.NormalizeMasterPrompt(settings.MasterPrompt)
  477. if len(promptBlocksRaw) > 0 {
  478. _ = json.Unmarshal(promptBlocksRaw, &settings.PromptBlocks)
  479. }
  480. settings.LLMActiveProvider = domain.NormalizeLLMProvider(settings.LLMActiveProvider)
  481. settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, settings.LLMActiveModel)
  482. settings.LLMBaseURL = strings.TrimSpace(settings.LLMBaseURL)
  483. settings.LLMTemperature = domain.NormalizeLLMTemperature(settings.LLMTemperature)
  484. settings.LLMMaxTokens = domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens)
  485. settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(settings.OpenAIAPIKeyEncrypted)
  486. settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(settings.AnthropicAPIKeyEncrypted)
  487. settings.GoogleAPIKeyEncrypted = strings.TrimSpace(settings.GoogleAPIKeyEncrypted)
  488. settings.XAIAPIKeyEncrypted = strings.TrimSpace(settings.XAIAPIKeyEncrypted)
  489. settings.OllamaAPIKeyEncrypted = strings.TrimSpace(settings.OllamaAPIKeyEncrypted)
  490. settings.GooglePlacesAPIKeyEncrypted = strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted)
  491. settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks)
  492. return &settings, nil
  493. }
  494. func (s *Store) CreateDraft(ctx context.Context, draft domain.BuildDraft) error {
  495. _, err := s.db.ExecContext(ctx, `
  496. INSERT INTO build_drafts (
  497. id, template_id, manifest_id, source, request_name, global_data_json,
  498. field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
  499. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  500. draft.ID, nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON),
  501. asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), asRaw(draft.SuggestionStateJSON), draft.Status, draft.Notes, draft.CreatedAt.UTC().Format(time.RFC3339Nano), draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
  502. )
  503. return err
  504. }
  505. func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error {
  506. res, err := s.db.ExecContext(ctx, `
  507. UPDATE build_drafts
  508. SET template_id = ?, manifest_id = ?, source = ?, request_name = ?, global_data_json = ?, field_values_json = ?, draft_context_json = ?, suggestion_state_json = ?,
  509. status = ?, notes = ?, updated_at = ?
  510. WHERE id = ?`,
  511. nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), asRaw(draft.SuggestionStateJSON),
  512. draft.Status, draft.Notes, draft.UpdatedAt.UTC().Format(time.RFC3339Nano), draft.ID,
  513. )
  514. if err != nil {
  515. return err
  516. }
  517. n, _ := res.RowsAffected()
  518. if n == 0 {
  519. return store.ErrNotFound
  520. }
  521. return nil
  522. }
  523. func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft, error) {
  524. row := s.db.QueryRowContext(ctx, `
  525. SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
  526. FROM build_drafts
  527. WHERE id = ?`, id)
  528. return scanDraft(row.Scan)
  529. }
  530. func (s *Store) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) {
  531. query := `
  532. SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
  533. FROM build_drafts
  534. ORDER BY updated_at DESC`
  535. args := make([]any, 0, 1)
  536. if limit > 0 {
  537. query += " LIMIT ?"
  538. args = append(args, limit)
  539. }
  540. rows, err := s.db.QueryContext(ctx, query, args...)
  541. if err != nil {
  542. return nil, err
  543. }
  544. defer rows.Close()
  545. out := make([]domain.BuildDraft, 0)
  546. for rows.Next() {
  547. draft, err := scanDraft(rows.Scan)
  548. if err != nil {
  549. return nil, err
  550. }
  551. out = append(out, *draft)
  552. }
  553. return out, rows.Err()
  554. }
  555. func runMigrations(db *sql.DB) error {
  556. if _, err := db.Exec(`
  557. CREATE TABLE IF NOT EXISTS schema_migrations (
  558. version TEXT PRIMARY KEY,
  559. applied_at TEXT NOT NULL
  560. )`); err != nil {
  561. return err
  562. }
  563. entries, err := fs.ReadDir(migrationFS, "migrations")
  564. if err != nil {
  565. return err
  566. }
  567. files := make([]string, 0, len(entries))
  568. for _, e := range entries {
  569. if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
  570. continue
  571. }
  572. files = append(files, e.Name())
  573. }
  574. sort.Strings(files)
  575. for _, name := range files {
  576. var exists int
  577. if err := db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil {
  578. return err
  579. }
  580. if exists > 0 {
  581. continue
  582. }
  583. raw, err := migrationFS.ReadFile("migrations/" + name)
  584. if err != nil {
  585. return err
  586. }
  587. tx, err := db.Begin()
  588. if err != nil {
  589. return err
  590. }
  591. if _, err := tx.Exec(string(raw)); err != nil {
  592. _ = tx.Rollback()
  593. return fmt.Errorf("apply %s: %w", name, err)
  594. }
  595. if _, err := tx.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
  596. _ = tx.Rollback()
  597. return err
  598. }
  599. if err := tx.Commit(); err != nil {
  600. return err
  601. }
  602. }
  603. return nil
  604. }
  605. func scanTemplate(scan func(dest ...any) error) (*domain.Template, error) {
  606. var t domain.Template
  607. var paletteReady int
  608. var isAITemplate int
  609. var isOnboarded int
  610. var lastDiscovered sql.NullString
  611. var raw []byte
  612. if err := scan(
  613. &t.ID, &t.Name, &t.Description, &t.Locale, &t.ThumbnailURL, &t.TemplatePreviewURL, &t.Type,
  614. &paletteReady, &raw, &isAITemplate, &isOnboarded, &t.ManifestStatus, &lastDiscovered,
  615. ); err != nil {
  616. if err == sql.ErrNoRows {
  617. return nil, store.ErrNotFound
  618. }
  619. return nil, err
  620. }
  621. t.PaletteReady = paletteReady == 1
  622. t.IsAITemplate = isAITemplate == 1
  623. t.IsOnboarded = isOnboarded == 1
  624. t.RawJSON = cloneBytes(raw)
  625. if lastDiscovered.Valid {
  626. if ts, err := time.Parse(time.RFC3339Nano, lastDiscovered.String); err == nil {
  627. t.LastDiscoveredAt = &ts
  628. }
  629. }
  630. return &t, nil
  631. }
  632. func scanManifest(scan func(dest ...any) error) (*domain.TemplateManifest, error) {
  633. var m domain.TemplateManifest
  634. var isActive int
  635. var payloadRaw []byte
  636. var responseRaw []byte
  637. var flattenedRaw []byte
  638. var createdAtRaw string
  639. var updatedAtRaw string
  640. if err := scan(
  641. &m.ID, &m.TemplateID, &m.Version, &m.Source, &m.LanguageUsedDiscovery, &payloadRaw,
  642. &responseRaw, &flattenedRaw, &isActive, &createdAtRaw, &updatedAtRaw,
  643. ); err != nil {
  644. if err == sql.ErrNoRows {
  645. return nil, store.ErrNotFound
  646. }
  647. return nil, err
  648. }
  649. m.IsActive = isActive == 1
  650. m.DiscoveryPayloadJSON = cloneBytes(payloadRaw)
  651. m.DiscoveryResponseJSON = cloneBytes(responseRaw)
  652. m.FlattenedManifestJSON = cloneBytes(flattenedRaw)
  653. m.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
  654. m.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
  655. return &m, nil
  656. }
  657. func scanBuild(scan func(dest ...any) error) (*domain.SiteBuild, error) {
  658. var b domain.SiteBuild
  659. var globalRaw []byte
  660. var aiDataRaw []byte
  661. var payloadRaw []byte
  662. var resultRaw []byte
  663. var errorRaw []byte
  664. var startedAtRaw sql.NullString
  665. var finishedAtRaw sql.NullString
  666. var jobID sql.NullInt64
  667. var siteID sql.NullInt64
  668. if err := scan(
  669. &b.ID, &b.TemplateID, &b.ManifestID, &b.RequestName, &globalRaw, &aiDataRaw, &payloadRaw,
  670. &jobID, &siteID, &b.QCStatus, &b.QCPreviewURL, &b.QCEditorURL, &resultRaw, &errorRaw, &startedAtRaw, &finishedAtRaw,
  671. ); err != nil {
  672. if err == sql.ErrNoRows {
  673. return nil, store.ErrNotFound
  674. }
  675. return nil, err
  676. }
  677. b.GlobalDataJSON = cloneBytes(globalRaw)
  678. b.AIDataJSON = cloneBytes(aiDataRaw)
  679. b.FinalSitesPayload = cloneBytes(payloadRaw)
  680. b.QCResultJSON = cloneBytes(resultRaw)
  681. b.QCErrorJSON = cloneBytes(errorRaw)
  682. if jobID.Valid {
  683. id := jobID.Int64
  684. b.QCJobID = &id
  685. }
  686. if siteID.Valid {
  687. id := siteID.Int64
  688. b.QCSiteID = &id
  689. }
  690. b.StartedAt = parseTimePtr(startedAtRaw)
  691. b.FinishedAt = parseTimePtr(finishedAtRaw)
  692. return &b, nil
  693. }
  694. func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) {
  695. var d domain.BuildDraft
  696. var templateID sql.NullInt64
  697. var globalRaw []byte
  698. var fieldsRaw []byte
  699. var draftContextRaw []byte
  700. var suggestionStateRaw []byte
  701. var createdAtRaw string
  702. var updatedAtRaw string
  703. if err := scan(
  704. &d.ID, &templateID, &d.ManifestID, &d.Source, &d.RequestName, &globalRaw, &fieldsRaw, &draftContextRaw, &suggestionStateRaw, &d.Status, &d.Notes, &createdAtRaw, &updatedAtRaw,
  705. ); err != nil {
  706. if err == sql.ErrNoRows {
  707. return nil, store.ErrNotFound
  708. }
  709. return nil, err
  710. }
  711. if templateID.Valid {
  712. d.TemplateID = templateID.Int64
  713. }
  714. d.GlobalDataJSON = cloneBytes(globalRaw)
  715. d.FieldValuesJSON = cloneBytes(fieldsRaw)
  716. d.DraftContextJSON = cloneBytes(draftContextRaw)
  717. d.SuggestionStateJSON = cloneBytes(suggestionStateRaw)
  718. d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
  719. d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
  720. return &d, nil
  721. }
  722. func rollback(tx *sql.Tx) {
  723. _ = tx.Rollback()
  724. }
  725. func boolToInt(v bool) int {
  726. if v {
  727. return 1
  728. }
  729. return 0
  730. }
  731. func defaultString(value, fallback string) string {
  732. if strings.TrimSpace(value) == "" {
  733. return fallback
  734. }
  735. return strings.TrimSpace(value)
  736. }
  737. func asRaw(raw json.RawMessage) []byte {
  738. if len(raw) == 0 {
  739. return nil
  740. }
  741. out := make([]byte, len(raw))
  742. copy(out, raw)
  743. return out
  744. }
  745. func cloneBytes(raw []byte) []byte {
  746. if len(raw) == 0 {
  747. return nil
  748. }
  749. out := make([]byte, len(raw))
  750. copy(out, raw)
  751. return out
  752. }
  753. func asRFC3339Ptr(t *time.Time) *string {
  754. if t == nil {
  755. return nil
  756. }
  757. v := t.UTC().Format(time.RFC3339Nano)
  758. return &v
  759. }
  760. func parseTimePtr(value sql.NullString) *time.Time {
  761. if !value.Valid {
  762. return nil
  763. }
  764. ts, err := time.Parse(time.RFC3339Nano, value.String)
  765. if err != nil {
  766. return nil
  767. }
  768. return &ts
  769. }
  770. func nullableInt64(value int64) any {
  771. if value <= 0 {
  772. return nil
  773. }
  774. return value
  775. }