package sqlite import ( "context" "database/sql" "embed" "encoding/json" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "time" _ "modernc.org/sqlite" "qctextbuilder/internal/domain" "qctextbuilder/internal/store" ) //go:embed migrations/*.sql var migrationFS embed.FS type Store struct { db *sql.DB } func New(dbPath string) (*Store, error) { path := strings.TrimSpace(dbPath) if path == "" { path = "data/qctextbuilder.db" } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, fmt.Errorf("create db directory: %w", err) } db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } db.SetMaxOpenConns(1) if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil { _ = db.Close() return nil, fmt.Errorf("enable foreign keys: %w", err) } if err := runMigrations(db); err != nil { _ = db.Close() return nil, fmt.Errorf("run migrations: %w", err) } return &Store{db: db}, nil } func (s *Store) Close() error { if s == nil || s.db == nil { return nil } return s.db.Close() } func (s *Store) UpsertTemplates(ctx context.Context, templates []domain.Template) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer rollback(tx) stmt := ` INSERT INTO qc_templates ( id, name, description, locale, thumbnail_url, template_preview_url, type, palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT is_onboarded FROM qc_templates WHERE id = ?), ?), COALESCE((SELECT manifest_status FROM qc_templates WHERE id = ?), ?), COALESCE((SELECT last_discovered_at FROM qc_templates WHERE id = ?), ?), ? ) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, locale = excluded.locale, thumbnail_url = excluded.thumbnail_url, template_preview_url = excluded.template_preview_url, type = excluded.type, palette_ready = excluded.palette_ready, raw_template_json = excluded.raw_template_json, is_ai_template = excluded.is_ai_template, updated_at = excluded.updated_at; ` now := time.Now().UTC() for _, t := range templates { _, err := tx.ExecContext(ctx, stmt, t.ID, t.Name, t.Description, t.Locale, t.ThumbnailURL, t.TemplatePreviewURL, t.Type, boolToInt(t.PaletteReady), asRaw(t.RawJSON), boolToInt(t.IsAITemplate), t.ID, boolToInt(t.IsOnboarded), t.ID, defaultString(t.ManifestStatus, "missing"), t.ID, asRFC3339Ptr(t.LastDiscoveredAt), now.Format(time.RFC3339Nano), ) if err != nil { return fmt.Errorf("upsert template %d: %w", t.ID, err) } } return tx.Commit() } func (s *Store) GetTemplateByID(ctx context.Context, id int64) (*domain.Template, error) { row := s.db.QueryRowContext(ctx, ` SELECT id, name, description, locale, thumbnail_url, template_preview_url, type, palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at FROM qc_templates WHERE id = ?`, id) t, err := scanTemplate(row.Scan) if err != nil { return nil, err } return t, nil } func (s *Store) ListTemplates(ctx context.Context) ([]domain.Template, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, name, description, locale, thumbnail_url, template_preview_url, type, palette_ready, raw_template_json, is_ai_template, is_onboarded, manifest_status, last_discovered_at FROM qc_templates`) if err != nil { return nil, err } defer rows.Close() out := make([]domain.Template, 0) for rows.Next() { t, err := scanTemplate(rows.Scan) if err != nil { return nil, err } out = append(out, *t) } return out, rows.Err() } func (s *Store) SetTemplateManifestStatus(ctx context.Context, templateID int64, status string, onboarded bool) error { res, err := s.db.ExecContext(ctx, ` UPDATE qc_templates SET manifest_status = ?, is_onboarded = ?, last_discovered_at = ?, updated_at = ? WHERE id = ?`, defaultString(status, "missing"), boolToInt(onboarded), time.Now().UTC().Format(time.RFC3339Nano), time.Now().UTC().Format(time.RFC3339Nano), templateID, ) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return store.ErrNotFound } return nil } func (s *Store) CreateManifest(ctx context.Context, manifest domain.TemplateManifest, fields []domain.TemplateField) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer rollback(tx) if _, err := tx.ExecContext(ctx, `UPDATE qc_template_manifests SET is_active = 0 WHERE template_id = ?`, manifest.TemplateID); err != nil { return err } _, err = tx.ExecContext(ctx, ` INSERT INTO qc_template_manifests ( id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json, discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, manifest.ID, manifest.TemplateID, manifest.Version, manifest.Source, manifest.LanguageUsedDiscovery, asRaw(manifest.DiscoveryPayloadJSON), asRaw(manifest.DiscoveryResponseJSON), asRaw(manifest.FlattenedManifestJSON), boolToInt(manifest.IsActive), manifest.CreatedAt.UTC().Format(time.RFC3339Nano), manifest.UpdatedAt.UTC().Format(time.RFC3339Nano), ) if err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifest.ID); err != nil { return err } for _, f := range fields { _, err := tx.ExecContext(ctx, ` INSERT INTO qc_template_fields ( id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value, is_enabled, is_required_by_us, display_label, display_order, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind, f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes, ) if err != nil { return err } } return tx.Commit() } func (s *Store) GetActiveManifestByTemplateID(ctx context.Context, templateID int64) (*domain.TemplateManifest, error) { row := s.db.QueryRowContext(ctx, ` SELECT id, template_id, manifest_version, source, language_used_discovery, discovery_payload_json, discovery_response_json, flattened_manifest_json, is_active, created_at, updated_at FROM qc_template_manifests WHERE template_id = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1`, templateID) manifest, err := scanManifest(row.Scan) if err != nil { return nil, err } return manifest, nil } func (s *Store) ListFieldsByManifestID(ctx context.Context, manifestID string) ([]domain.TemplateField, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value, is_enabled, is_required_by_us, display_label, display_order, notes FROM qc_template_fields WHERE manifest_id = ? ORDER BY display_order ASC, id ASC`, manifestID) if err != nil { return nil, err } defer rows.Close() fields := make([]domain.TemplateField, 0) for rows.Next() { var f domain.TemplateField var isEnabled, isRequired int if err := rows.Scan( &f.ID, &f.TemplateID, &f.ManifestID, &f.Section, &f.WebsiteSection, &f.KeyName, &f.Path, &f.FieldKind, &f.SampleValue, &isEnabled, &isRequired, &f.DisplayLabel, &f.DisplayOrder, &f.Notes, ); err != nil { return nil, err } f.IsEnabled = isEnabled == 1 f.IsRequiredByUs = isRequired == 1 f.WebsiteSection = domain.NormalizeWebsiteSection(f.WebsiteSection) fields = append(fields, f) } if err := rows.Err(); err != nil { return nil, err } if len(fields) == 0 { return nil, store.ErrNotFound } return fields, nil } func (s *Store) UpdateFields(ctx context.Context, manifestID string, fields []domain.TemplateField) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer rollback(tx) var exists int if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM qc_template_manifests WHERE id = ?`, manifestID).Scan(&exists); err != nil { return err } if exists == 0 { return store.ErrNotFound } if _, err := tx.ExecContext(ctx, `DELETE FROM qc_template_fields WHERE manifest_id = ?`, manifestID); err != nil { return err } for _, f := range fields { _, err := tx.ExecContext(ctx, ` INSERT INTO qc_template_fields ( id, template_id, manifest_id, section, website_section, key_name, path, field_kind, sample_value, is_enabled, is_required_by_us, display_label, display_order, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, f.ID, f.TemplateID, f.ManifestID, f.Section, domain.NormalizeWebsiteSection(f.WebsiteSection), f.KeyName, f.Path, f.FieldKind, f.SampleValue, boolToInt(f.IsEnabled), boolToInt(f.IsRequiredByUs), f.DisplayLabel, f.DisplayOrder, f.Notes, ) if err != nil { return err } } return tx.Commit() } func (s *Store) CreateBuild(ctx context.Context, build domain.SiteBuild) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO site_builds ( id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json, qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, build.ID, build.TemplateID, build.ManifestID, build.RequestName, asRaw(build.GlobalDataJSON), asRaw(build.AIDataJSON), asRaw(build.FinalSitesPayload), build.QCJobID, build.QCSiteID, build.QCStatus, build.QCPreviewURL, build.QCEditorURL, asRaw(build.QCResultJSON), asRaw(build.QCErrorJSON), asRFC3339Ptr(build.StartedAt), asRFC3339Ptr(build.FinishedAt), ) return err } func (s *Store) GetBuildByID(ctx context.Context, id string) (*domain.SiteBuild, error) { row := s.db.QueryRowContext(ctx, ` SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json, qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at FROM site_builds WHERE id = ?`, id) build, err := scanBuild(row.Scan) if err != nil { return nil, err } return build, nil } func (s *Store) ListBuildsByStatuses(ctx context.Context, statuses []string, limit int) ([]domain.SiteBuild, error) { base := ` SELECT id, template_id, manifest_id, request_name, global_data_json, ai_data_json, final_sites_payload_json, qc_job_id, qc_site_id, qc_status, qc_preview_url, qc_editor_url, qc_result_json, qc_error_json, started_at, finished_at FROM site_builds` args := make([]any, 0) parts := make([]string, 0) if len(statuses) > 0 { placeholders := make([]string, 0, len(statuses)) for _, status := range statuses { placeholders = append(placeholders, "?") args = append(args, status) } parts = append(parts, "qc_status IN ("+strings.Join(placeholders, ", ")+")") } query := base if len(parts) > 0 { query += " WHERE " + strings.Join(parts, " AND ") } query += " ORDER BY started_at ASC, id ASC" if limit > 0 { query += " LIMIT ?" args = append(args, limit) } rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() builds := make([]domain.SiteBuild, 0) for rows.Next() { build, err := scanBuild(rows.Scan) if err != nil { return nil, err } builds = append(builds, *build) } return builds, rows.Err() } func (s *Store) MarkBuildSubmitted(ctx context.Context, buildID string, jobID int64, status string, qcResult json.RawMessage, startedAt time.Time) error { res, err := s.db.ExecContext(ctx, ` UPDATE site_builds SET qc_job_id = ?, qc_status = ?, qc_result_json = ?, started_at = ? WHERE id = ?`, jobID, status, asRaw(qcResult), startedAt.UTC().Format(time.RFC3339Nano), buildID, ) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return store.ErrNotFound } return nil } 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 { res, err := s.db.ExecContext(ctx, ` UPDATE site_builds SET qc_status = ?, qc_site_id = COALESCE(?, qc_site_id), qc_preview_url = ?, qc_result_json = ?, qc_error_json = ?, finished_at = ? WHERE id = ?`, status, siteID, previewURL, asRaw(qcResult), asRaw(qcError), asRFC3339Ptr(finishedAt), buildID, ) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return store.ErrNotFound } return nil } func (s *Store) UpdateBuildEditorURL(ctx context.Context, buildID string, editorURL string, qcResult json.RawMessage) error { res, err := s.db.ExecContext(ctx, ` UPDATE site_builds SET qc_editor_url = ?, qc_result_json = ? WHERE id = ?`, editorURL, asRaw(qcResult), buildID, ) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return store.ErrNotFound } return nil } func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) error { promptBlocksRaw, err := json.Marshal(domain.NormalizePromptBlocks(settings.PromptBlocks)) if err != nil { return fmt.Errorf("marshal prompt blocks: %w", err) } provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider) model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel) _, err = s.db.ExecContext(ctx, ` INSERT INTO app_settings ( id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, llm_active_provider, llm_active_model, llm_base_url, openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted, master_prompt, prompt_blocks_json, updated_at ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET qc_base_url = excluded.qc_base_url, qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted, language_output_mode = excluded.language_output_mode, job_poll_interval_seconds = excluded.job_poll_interval_seconds, job_poll_timeout_seconds = excluded.job_poll_timeout_seconds, llm_active_provider = excluded.llm_active_provider, llm_active_model = excluded.llm_active_model, llm_base_url = excluded.llm_base_url, openai_api_key_encrypted = excluded.openai_api_key_encrypted, anthropic_api_key_encrypted = excluded.anthropic_api_key_encrypted, google_api_key_encrypted = excluded.google_api_key_encrypted, xai_api_key_encrypted = excluded.xai_api_key_encrypted, ollama_api_key_encrypted = excluded.ollama_api_key_encrypted, master_prompt = excluded.master_prompt, prompt_blocks_json = excluded.prompt_blocks_json, updated_at = excluded.updated_at`, settings.QCBaseURL, settings.QCBearerTokenEncrypted, defaultString(settings.LanguageOutputMode, "EN"), settings.JobPollIntervalSeconds, settings.JobPollTimeoutSeconds, provider, model, strings.TrimSpace(settings.LLMBaseURL), strings.TrimSpace(settings.OpenAIAPIKeyEncrypted), strings.TrimSpace(settings.AnthropicAPIKeyEncrypted), strings.TrimSpace(settings.GoogleAPIKeyEncrypted), strings.TrimSpace(settings.XAIAPIKeyEncrypted), strings.TrimSpace(settings.OllamaAPIKeyEncrypted), domain.NormalizeMasterPrompt(settings.MasterPrompt), promptBlocksRaw, time.Now().UTC().Format(time.RFC3339Nano), ) return err } func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { row := s.db.QueryRowContext(ctx, ` SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, llm_active_provider, llm_active_model, llm_base_url, openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted, master_prompt, prompt_blocks_json FROM app_settings WHERE id = 1`) var settings domain.AppSettings var promptBlocksRaw []byte if err := row.Scan( &settings.QCBaseURL, &settings.QCBearerTokenEncrypted, &settings.LanguageOutputMode, &settings.JobPollIntervalSeconds, &settings.JobPollTimeoutSeconds, &settings.LLMActiveProvider, &settings.LLMActiveModel, &settings.LLMBaseURL, &settings.OpenAIAPIKeyEncrypted, &settings.AnthropicAPIKeyEncrypted, &settings.GoogleAPIKeyEncrypted, &settings.XAIAPIKeyEncrypted, &settings.OllamaAPIKeyEncrypted, &settings.MasterPrompt, &promptBlocksRaw, ); err != nil { if err == sql.ErrNoRows { return nil, store.ErrNotFound } return nil, err } settings.MasterPrompt = domain.NormalizeMasterPrompt(settings.MasterPrompt) if len(promptBlocksRaw) > 0 { _ = json.Unmarshal(promptBlocksRaw, &settings.PromptBlocks) } settings.LLMActiveProvider = domain.NormalizeLLMProvider(settings.LLMActiveProvider) settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, settings.LLMActiveModel) settings.LLMBaseURL = strings.TrimSpace(settings.LLMBaseURL) settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) settings.GoogleAPIKeyEncrypted = strings.TrimSpace(settings.GoogleAPIKeyEncrypted) settings.XAIAPIKeyEncrypted = strings.TrimSpace(settings.XAIAPIKeyEncrypted) settings.OllamaAPIKeyEncrypted = strings.TrimSpace(settings.OllamaAPIKeyEncrypted) settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks) return &settings, nil } func (s *Store) CreateDraft(ctx context.Context, draft domain.BuildDraft) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO build_drafts ( 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 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, draft.ID, nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), 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), ) return err } func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error { res, err := s.db.ExecContext(ctx, ` UPDATE build_drafts SET template_id = ?, manifest_id = ?, source = ?, request_name = ?, global_data_json = ?, field_values_json = ?, draft_context_json = ?, suggestion_state_json = ?, status = ?, notes = ?, updated_at = ? WHERE id = ?`, nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), asRaw(draft.SuggestionStateJSON), draft.Status, draft.Notes, draft.UpdatedAt.UTC().Format(time.RFC3339Nano), draft.ID, ) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return store.ErrNotFound } return nil } func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft, error) { row := s.db.QueryRowContext(ctx, ` 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 FROM build_drafts WHERE id = ?`, id) return scanDraft(row.Scan) } func (s *Store) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) { query := ` 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 FROM build_drafts ORDER BY updated_at DESC` args := make([]any, 0, 1) if limit > 0 { query += " LIMIT ?" args = append(args, limit) } rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := make([]domain.BuildDraft, 0) for rows.Next() { draft, err := scanDraft(rows.Scan) if err != nil { return nil, err } out = append(out, *draft) } return out, rows.Err() } func runMigrations(db *sql.DB) error { if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TEXT NOT NULL )`); err != nil { return err } entries, err := fs.ReadDir(migrationFS, "migrations") if err != nil { return err } files := make([]string, 0, len(entries)) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { continue } files = append(files, e.Name()) } sort.Strings(files) for _, name := range files { var exists int if err := db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil { return err } if exists > 0 { continue } raw, err := migrationFS.ReadFile("migrations/" + name) if err != nil { return err } tx, err := db.Begin() if err != nil { return err } if _, err := tx.Exec(string(raw)); err != nil { _ = tx.Rollback() return fmt.Errorf("apply %s: %w", name, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { _ = tx.Rollback() return err } if err := tx.Commit(); err != nil { return err } } return nil } func scanTemplate(scan func(dest ...any) error) (*domain.Template, error) { var t domain.Template var paletteReady int var isAITemplate int var isOnboarded int var lastDiscovered sql.NullString var raw []byte if err := scan( &t.ID, &t.Name, &t.Description, &t.Locale, &t.ThumbnailURL, &t.TemplatePreviewURL, &t.Type, &paletteReady, &raw, &isAITemplate, &isOnboarded, &t.ManifestStatus, &lastDiscovered, ); err != nil { if err == sql.ErrNoRows { return nil, store.ErrNotFound } return nil, err } t.PaletteReady = paletteReady == 1 t.IsAITemplate = isAITemplate == 1 t.IsOnboarded = isOnboarded == 1 t.RawJSON = cloneBytes(raw) if lastDiscovered.Valid { if ts, err := time.Parse(time.RFC3339Nano, lastDiscovered.String); err == nil { t.LastDiscoveredAt = &ts } } return &t, nil } func scanManifest(scan func(dest ...any) error) (*domain.TemplateManifest, error) { var m domain.TemplateManifest var isActive int var payloadRaw []byte var responseRaw []byte var flattenedRaw []byte var createdAtRaw string var updatedAtRaw string if err := scan( &m.ID, &m.TemplateID, &m.Version, &m.Source, &m.LanguageUsedDiscovery, &payloadRaw, &responseRaw, &flattenedRaw, &isActive, &createdAtRaw, &updatedAtRaw, ); err != nil { if err == sql.ErrNoRows { return nil, store.ErrNotFound } return nil, err } m.IsActive = isActive == 1 m.DiscoveryPayloadJSON = cloneBytes(payloadRaw) m.DiscoveryResponseJSON = cloneBytes(responseRaw) m.FlattenedManifestJSON = cloneBytes(flattenedRaw) m.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw) m.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw) return &m, nil } func scanBuild(scan func(dest ...any) error) (*domain.SiteBuild, error) { var b domain.SiteBuild var globalRaw []byte var aiDataRaw []byte var payloadRaw []byte var resultRaw []byte var errorRaw []byte var startedAtRaw sql.NullString var finishedAtRaw sql.NullString var jobID sql.NullInt64 var siteID sql.NullInt64 if err := scan( &b.ID, &b.TemplateID, &b.ManifestID, &b.RequestName, &globalRaw, &aiDataRaw, &payloadRaw, &jobID, &siteID, &b.QCStatus, &b.QCPreviewURL, &b.QCEditorURL, &resultRaw, &errorRaw, &startedAtRaw, &finishedAtRaw, ); err != nil { if err == sql.ErrNoRows { return nil, store.ErrNotFound } return nil, err } b.GlobalDataJSON = cloneBytes(globalRaw) b.AIDataJSON = cloneBytes(aiDataRaw) b.FinalSitesPayload = cloneBytes(payloadRaw) b.QCResultJSON = cloneBytes(resultRaw) b.QCErrorJSON = cloneBytes(errorRaw) if jobID.Valid { id := jobID.Int64 b.QCJobID = &id } if siteID.Valid { id := siteID.Int64 b.QCSiteID = &id } b.StartedAt = parseTimePtr(startedAtRaw) b.FinishedAt = parseTimePtr(finishedAtRaw) return &b, nil } func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) { var d domain.BuildDraft var templateID sql.NullInt64 var globalRaw []byte var fieldsRaw []byte var draftContextRaw []byte var suggestionStateRaw []byte var createdAtRaw string var updatedAtRaw string if err := scan( &d.ID, &templateID, &d.ManifestID, &d.Source, &d.RequestName, &globalRaw, &fieldsRaw, &draftContextRaw, &suggestionStateRaw, &d.Status, &d.Notes, &createdAtRaw, &updatedAtRaw, ); err != nil { if err == sql.ErrNoRows { return nil, store.ErrNotFound } return nil, err } if templateID.Valid { d.TemplateID = templateID.Int64 } d.GlobalDataJSON = cloneBytes(globalRaw) d.FieldValuesJSON = cloneBytes(fieldsRaw) d.DraftContextJSON = cloneBytes(draftContextRaw) d.SuggestionStateJSON = cloneBytes(suggestionStateRaw) d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw) d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw) return &d, nil } func rollback(tx *sql.Tx) { _ = tx.Rollback() } func boolToInt(v bool) int { if v { return 1 } return 0 } func defaultString(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return strings.TrimSpace(value) } func asRaw(raw json.RawMessage) []byte { if len(raw) == 0 { return nil } out := make([]byte, len(raw)) copy(out, raw) return out } func cloneBytes(raw []byte) []byte { if len(raw) == 0 { return nil } out := make([]byte, len(raw)) copy(out, raw) return out } func asRFC3339Ptr(t *time.Time) *string { if t == nil { return nil } v := t.UTC().Format(time.RFC3339Nano) return &v } func parseTimePtr(value sql.NullString) *time.Time { if !value.Valid { return nil } ts, err := time.Parse(time.RFC3339Nano, value.String) if err != nil { return nil } return &ts } func nullableInt64(value int64) any { if value <= 0 { return nil } return value }