You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

737 line
22KB

  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, key_name, path, field_kind,
  174. sample_value, is_enabled, is_required_by_us, display_label, display_order, notes
  175. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  176. f.ID, f.TemplateID, f.ManifestID, f.Section, f.KeyName, f.Path, f.FieldKind,
  177. f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
  178. )
  179. if err != nil {
  180. return err
  181. }
  182. }
  183. return tx.Commit()
  184. }
  185. func (s *Store) GetActiveManifestByTemplateID(ctx context.Context, templateID int64) (*domain.TemplateManifest, error) {
  186. row := s.db.QueryRowContext(ctx, `
  187. SELECT id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json,
  188. discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at
  189. FROM qc_template_manifests
  190. WHERE template_id = ? AND is_active = 1
  191. ORDER BY created_at DESC
  192. LIMIT 1`, templateID)
  193. manifest, err := scanManifest(row.Scan)
  194. if err != nil {
  195. return nil, err
  196. }
  197. return manifest, nil
  198. }
  199. func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) {
  200. rows, err := s.db.QueryContext(ctx, `
  201. SELECT id, template_id, manifest_id, section, key_name, path, field_kind, sample_value,
  202. is_enabled, is_required_by_us, display_label, display_order, notes
  203. FROM qc_template_fields
  204. WHERE manifest_id = ?
  205. ORDER BY display_order ASC, id ASC`, manifestID)
  206. if err != nil {
  207. return nil, err
  208. }
  209. defer rows.Close()
  210. fields := make([]domain.TemplateField, 0)
  211. for rows.Next() {
  212. var f domain.TemplateField
  213. var isEnabled, isRequired int
  214. if err := rows.Scan(
  215. &f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue,
  216. &isEnabled, &isRequired, &f.DisplayLabel, &f.DisplayOrder, &f.Notes,
  217. ); err != nil {
  218. return nil, err
  219. }
  220. f.IsEnabled = isEnabled == 1
  221. f.IsRequiredByUs = isRequired == 1
  222. fields = append(fields, f)
  223. }
  224. if err := rows.Err(); err != nil {
  225. return nil, err
  226. }
  227. if len(fields) == 0 {
  228. return nil, store.ErrNotFound
  229. }
  230. return fields, nil
  231. }
  232. func (s *Store) UpdateFields(ctx context.Context, manifestID string, fields []domain.TemplateField) error {
  233. tx, err := s.db.BeginTx(ctx, nil)
  234. if err != nil {
  235. return err
  236. }
  237. defer rollback(tx)
  238. var exists int
  239. if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM qc_template_manifests WHERE id = ?`, manifestID).Scan(&exists); err != nil {
  240. return err
  241. }
  242. if exists == 0 {
  243. return store.ErrNotFound
  244. }
  245. if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifestID); err != nil {
  246. return err
  247. }
  248. for _, f := range fields {
  249. _, err := tx.ExecContext(ctx, `
  250. INSERT INTO qc_template_fields (
  251. id, template_id, manifest_id, section, key_name, path, field_kind,
  252. sample_value, is_enabled, is_required_by_us, display_label, display_order, notes
  253. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  254. f.ID, f.TemplateID, f.ManifestID, f.Section, f.KeyName, f.Path, f.FieldKind,
  255. f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes,
  256. )
  257. if err != nil {
  258. return err
  259. }
  260. }
  261. return tx.Commit()
  262. }
  263. func (s *Store) CreateBuild(ctx context.Context, build domain.SiteBuild) error {
  264. _, err := s.db.ExecContext(ctx, `
  265. INSERT INTO site_builds (
  266. id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
  267. qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
  268. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  269. build.ID, build.TemplateID, build.ManifestID, build.RequestName, asRaw(build.GlobalDataJSON), asRaw(build.AIDataJSON), asRaw(build.FinalSitesPayload),
  270. build.QCJobID, build.QCSiteID, build.QCStatus, build.QCPreviewURL, build.QCEditorURL, asRaw(build.QCResultJSON), asRaw(build.QCErrorJSON),
  271. asRFC3339Ptr(build.StartedAt), asRFC3339Ptr(build.FinishedAt),
  272. )
  273. return err
  274. }
  275. func (s *Store) GetBuildByID(ctx context.Context, id string) (*domain.SiteBuild, error) {
  276. row := s.db.QueryRowContext(ctx, `
  277. SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
  278. qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
  279. FROM site_builds
  280. WHERE id = ?`, id)
  281. build, err := scanBuild(row.Scan)
  282. if err != nil {
  283. return nil, err
  284. }
  285. return build, nil
  286. }
  287. func (s *Store) ListBuildsByStatuses(ctx context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) {
  288. base := `
  289. SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json,
  290. qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at
  291. FROM site_builds`
  292. args := make([]any, 0)
  293. parts := make([]string, 0)
  294. if len(statuses) > 0 {
  295. placeholders := make([]string, 0, len(statuses))
  296. for _, status := range statuses {
  297. placeholders = append(placeholders, "?")
  298. args = append(args, status)
  299. }
  300. parts = append(parts, "qc_status IN ("+strings.Join(placeholders, ", ")+")")
  301. }
  302. query := base
  303. if len(parts) > 0 {
  304. query += " WHERE " + strings.Join(parts, " AND ")
  305. }
  306. query += " ORDER BY started_at ASC, id ASC"
  307. if limit > 0 {
  308. query += " LIMIT ?"
  309. args = append(args, limit)
  310. }
  311. rows, err := s.db.QueryContext(ctx, query, args...)
  312. if err != nil {
  313. return nil, err
  314. }
  315. defer rows.Close()
  316. builds := make([]domain.SiteBuild, 0)
  317. for rows.Next() {
  318. build, err := scanBuild(rows.Scan)
  319. if err != nil {
  320. return nil, err
  321. }
  322. builds = append(builds, *build)
  323. }
  324. return builds, rows.Err()
  325. }
  326. func (s *Store) MarkBuildSubmitted(ctx context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error {
  327. res, err := s.db.ExecContext(ctx, `
  328. UPDATE site_builds
  329. SET qc_job_id = ?, qc_status = ?, qc_result_json = ?, started_at = ?
  330. WHERE id = ?`,
  331. jobID, status, asRaw(qcResult), startedAt.UTC().Format(time.RFC3339Nano), buildID,
  332. )
  333. if err != nil {
  334. return err
  335. }
  336. n, _ := res.RowsAffected()
  337. if n == 0 {
  338. return store.ErrNotFound
  339. }
  340. return nil
  341. }
  342. 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 {
  343. res, err := s.db.ExecContext(ctx, `
  344. UPDATE site_builds
  345. SET qc_status = ?, qc_site_id = COALESCE(?, qc_site_id), qc_preview_url = ?, qc_result_json = ?, qc_error_json = ?, finished_at = ?
  346. WHERE id = ?`,
  347. status, siteID, previewURL, asRaw(qcResult), asRaw(qcError), asRFC3339Ptr(finishedAt), buildID,
  348. )
  349. if err != nil {
  350. return err
  351. }
  352. n, _ := res.RowsAffected()
  353. if n == 0 {
  354. return store.ErrNotFound
  355. }
  356. return nil
  357. }
  358. func (s *Store) UpdateBuildEditorURL(ctx context.Context, buildID string, editorURL string, qcResult json.RawMessage) error {
  359. res, err := s.db.ExecContext(ctx, `
  360. UPDATE site_builds
  361. SET qc_editor_url = ?, qc_result_json = ?
  362. WHERE id = ?`,
  363. editorURL, asRaw(qcResult), buildID,
  364. )
  365. if err != nil {
  366. return err
  367. }
  368. n, _ := res.RowsAffected()
  369. if n == 0 {
  370. return store.ErrNotFound
  371. }
  372. return nil
  373. }
  374. func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) error {
  375. _, err := s.db.ExecContext(ctx, `
  376. INSERT INTO app_settings (
  377. id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, updated_at
  378. ) VALUES (1, ?, ?, ?, ?, ?, ?)
  379. ON CONFLICT(id) DO UPDATE SET
  380. qc_base_url = excluded.qc_base_url,
  381. qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted,
  382. language_output_mode = excluded.language_output_mode,
  383. job_poll_interval_seconds = excluded.job_poll_interval_seconds,
  384. job_poll_timeout_seconds = excluded.job_poll_timeout_seconds,
  385. updated_at = excluded.updated_at`,
  386. settings.QCBaseURL,
  387. settings.QCBearerTokenEncrypted,
  388. defaultString(settings.LanguageOutputMode, "EN"),
  389. settings.JobPollIntervalSeconds,
  390. settings.JobPollTimeoutSeconds,
  391. time.Now().UTC().Format(time.RFC3339Nano),
  392. )
  393. return err
  394. }
  395. func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) {
  396. row := s.db.QueryRowContext(ctx, `
  397. SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds
  398. FROM app_settings
  399. WHERE id = 1`)
  400. var settings domain.AppSettings
  401. if err := row.Scan(
  402. &settings.QCBaseURL,
  403. &settings.QCBearerTokenEncrypted,
  404. &settings.LanguageOutputMode,
  405. &settings.JobPollIntervalSeconds,
  406. &settings.JobPollTimeoutSeconds,
  407. ); err != nil {
  408. if err == sql.ErrNoRows {
  409. return nil, store.ErrNotFound
  410. }
  411. return nil, err
  412. }
  413. return &settings, nil
  414. }
  415. func (s *Store) CreateDraft(ctx context.Context, draft domain.BuildDraft) error {
  416. _, err := s.db.ExecContext(ctx, `
  417. INSERT INTO build_drafts (
  418. id, template_id, manifest_id, source, request_name, global_data_json,
  419. field_values_json, status, notes, created_at, updated_at
  420. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
  421. draft.ID, draft.TemplateID, draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON),
  422. asRaw(draft.FieldValuesJSON), draft.Status, draft.Notes, draft.CreatedAt.UTC().Format(time.RFC3339Nano), draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
  423. )
  424. return err
  425. }
  426. func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error {
  427. res, err := s.db.ExecContext(ctx, `
  428. UPDATE build_drafts
  429. SET template_id = ?, manifest_id = ?, source = ?, request_name = ?, global_data_json = ?, field_values_json = ?,
  430. status = ?, notes = ?, updated_at = ?
  431. WHERE id = ?`,
  432. draft.TemplateID, draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON),
  433. draft.Status, draft.Notes, draft.UpdatedAt.UTC().Format(time.RFC3339Nano), draft.ID,
  434. )
  435. if err != nil {
  436. return err
  437. }
  438. n, _ := res.RowsAffected()
  439. if n == 0 {
  440. return store.ErrNotFound
  441. }
  442. return nil
  443. }
  444. func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft, error) {
  445. row := s.db.QueryRowContext(ctx, `
  446. SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, status, notes, created_at, updated_at
  447. FROM build_drafts
  448. WHERE id = ?`, id)
  449. return scanDraft(row.Scan)
  450. }
  451. func (s *Store) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) {
  452. query := `
  453. SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, status, notes, created_at, updated_at
  454. FROM build_drafts
  455. ORDER BY updated_at DESC`
  456. args := make([]any, 0, 1)
  457. if limit > 0 {
  458. query += " LIMIT ?"
  459. args = append(args, limit)
  460. }
  461. rows, err := s.db.QueryContext(ctx, query, args...)
  462. if err != nil {
  463. return nil, err
  464. }
  465. defer rows.Close()
  466. out := make([]domain.BuildDraft, 0)
  467. for rows.Next() {
  468. draft, err := scanDraft(rows.Scan)
  469. if err != nil {
  470. return nil, err
  471. }
  472. out = append(out, *draft)
  473. }
  474. return out, rows.Err()
  475. }
  476. func runMigrations(db *sql.DB) error {
  477. if _, err := db.Exec(`
  478. CREATE TABLE IF NOT EXISTS schema_migrations (
  479. version TEXT PRIMARY KEY,
  480. applied_at TEXT NOT NULL
  481. )`); err != nil {
  482. return err
  483. }
  484. entries, err := fs.ReadDir(migrationFS, "migrations")
  485. if err != nil {
  486. return err
  487. }
  488. files := make([]string, 0, len(entries))
  489. for _, e := range entries {
  490. if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
  491. continue
  492. }
  493. files = append(files, e.Name())
  494. }
  495. sort.Strings(files)
  496. for _, name := range files {
  497. var exists int
  498. if err := db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil {
  499. return err
  500. }
  501. if exists > 0 {
  502. continue
  503. }
  504. raw, err := migrationFS.ReadFile("migrations/" + name)
  505. if err != nil {
  506. return err
  507. }
  508. tx, err := db.Begin()
  509. if err != nil {
  510. return err
  511. }
  512. if _, err := tx.Exec(string(raw)); err != nil {
  513. _ = tx.Rollback()
  514. return fmt.Errorf("apply %s: %w", name, err)
  515. }
  516. if _, err := tx.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
  517. _ = tx.Rollback()
  518. return err
  519. }
  520. if err := tx.Commit(); err != nil {
  521. return err
  522. }
  523. }
  524. return nil
  525. }
  526. func scanTemplate(scan func(dest ...any) error) (*domain.Template, error) {
  527. var t domain.Template
  528. var paletteReady int
  529. var isAITemplate int
  530. var isOnboarded int
  531. var lastDiscovered sql.NullString
  532. var raw []byte
  533. if err := scan(
  534. &t.ID, &t.Name, &t.Description, &t.Locale, &t.ThumbnailURL, &t.TemplatePreviewURL, &t.Type,
  535. &paletteReady, &raw, &isAITemplate, &isOnboarded, &t.ManifestStatus, &lastDiscovered,
  536. ); err != nil {
  537. if err == sql.ErrNoRows {
  538. return nil, store.ErrNotFound
  539. }
  540. return nil, err
  541. }
  542. t.PaletteReady = paletteReady == 1
  543. t.IsAITemplate = isAITemplate == 1
  544. t.IsOnboarded = isOnboarded == 1
  545. t.RawJSON = cloneBytes(raw)
  546. if lastDiscovered.Valid {
  547. if ts, err := time.Parse(time.RFC3339Nano, lastDiscovered.String); err == nil {
  548. t.LastDiscoveredAt = &ts
  549. }
  550. }
  551. return &t, nil
  552. }
  553. func scanManifest(scan func(dest ...any) error) (*domain.TemplateManifest, error) {
  554. var m domain.TemplateManifest
  555. var isActive int
  556. var payloadRaw []byte
  557. var responseRaw []byte
  558. var flattenedRaw []byte
  559. var createdAtRaw string
  560. var updatedAtRaw string
  561. if err := scan(
  562. &m.ID, &m.TemplateID, &m.Version, &m.Source, &m.LanguageUsedDiscovery, &payloadRaw,
  563. &responseRaw, &flattenedRaw, &isActive, &createdAtRaw, &updatedAtRaw,
  564. ); err != nil {
  565. if err == sql.ErrNoRows {
  566. return nil, store.ErrNotFound
  567. }
  568. return nil, err
  569. }
  570. m.IsActive = isActive == 1
  571. m.DiscoveryPayloadJSON = cloneBytes(payloadRaw)
  572. m.DiscoveryResponseJSON = cloneBytes(responseRaw)
  573. m.FlattenedManifestJSON = cloneBytes(flattenedRaw)
  574. m.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
  575. m.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
  576. return &m, nil
  577. }
  578. func scanBuild(scan func(dest ...any) error) (*domain.SiteBuild, error) {
  579. var b domain.SiteBuild
  580. var globalRaw []byte
  581. var aiDataRaw []byte
  582. var payloadRaw []byte
  583. var resultRaw []byte
  584. var errorRaw []byte
  585. var startedAtRaw sql.NullString
  586. var finishedAtRaw sql.NullString
  587. var jobID sql.NullInt64
  588. var siteID sql.NullInt64
  589. if err := scan(
  590. &b.ID, &b.TemplateID, &b.ManifestID, &b.RequestName, &globalRaw, &aiDataRaw, &payloadRaw,
  591. &jobID, &siteID, &b.QCStatus, &b.QCPreviewURL, &b.QCEditorURL, &resultRaw, &errorRaw, &startedAtRaw, &finishedAtRaw,
  592. ); err != nil {
  593. if err == sql.ErrNoRows {
  594. return nil, store.ErrNotFound
  595. }
  596. return nil, err
  597. }
  598. b.GlobalDataJSON = cloneBytes(globalRaw)
  599. b.AIDataJSON = cloneBytes(aiDataRaw)
  600. b.FinalSitesPayload = cloneBytes(payloadRaw)
  601. b.QCResultJSON = cloneBytes(resultRaw)
  602. b.QCErrorJSON = cloneBytes(errorRaw)
  603. if jobID.Valid {
  604. id := jobID.Int64
  605. b.QCJobID = &id
  606. }
  607. if siteID.Valid {
  608. id := siteID.Int64
  609. b.QCSiteID = &id
  610. }
  611. b.StartedAt = parseTimePtr(startedAtRaw)
  612. b.FinishedAt = parseTimePtr(finishedAtRaw)
  613. return &b, nil
  614. }
  615. func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) {
  616. var d domain.BuildDraft
  617. var globalRaw []byte
  618. var fieldsRaw []byte
  619. var createdAtRaw string
  620. var updatedAtRaw string
  621. if err := scan(
  622. &d.ID, &d.TemplateID, &d.ManifestID, &d.Source, &d.RequestName, &globalRaw, &fieldsRaw, &d.Status, &d.Notes, &createdAtRaw, &updatedAtRaw,
  623. ); err != nil {
  624. if err == sql.ErrNoRows {
  625. return nil, store.ErrNotFound
  626. }
  627. return nil, err
  628. }
  629. d.GlobalDataJSON = cloneBytes(globalRaw)
  630. d.FieldValuesJSON = cloneBytes(fieldsRaw)
  631. d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
  632. d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
  633. return &d, nil
  634. }
  635. func rollback(tx *sql.Tx) {
  636. _ = tx.Rollback()
  637. }
  638. func boolToInt(v bool) int {
  639. if v {
  640. return 1
  641. }
  642. return 0
  643. }
  644. func defaultString(value, fallback string) string {
  645. if strings.TrimSpace(value) == "" {
  646. return fallback
  647. }
  648. return strings.TrimSpace(value)
  649. }
  650. func asRaw(raw json.RawMessage) []byte {
  651. if len(raw) == 0 {
  652. return nil
  653. }
  654. out := make([]byte, len(raw))
  655. copy(out, raw)
  656. return out
  657. }
  658. func cloneBytes(raw []byte) []byte {
  659. if len(raw) == 0 {
  660. return nil
  661. }
  662. out := make([]byte, len(raw))
  663. copy(out, raw)
  664. return out
  665. }
  666. func asRFC3339Ptr(t *time.Time) *string {
  667. if t == nil {
  668. return nil
  669. }
  670. v := t.UTC().Format(time.RFC3339Nano)
  671. return &v
  672. }
  673. func parseTimePtr(value sql.NullString) *time.Time {
  674. if !value.Valid {
  675. return nil
  676. }
  677. ts, err := time.Parse(time.RFC3339Nano, value.String)
  678. if err != nil {
  679. return nil
  680. }
  681. return &ts
  682. }