package store import ( "database/sql" "encoding/json" "fmt" "github.com/intodns/backend/internal/checker" _ "modernc.org/sqlite" ) // HistoryEntry represents a saved DNS check result (without full report). type HistoryEntry struct { ID int64 `json:"id"` Domain string `json:"domain"` Timestamp string `json:"timestamp"` DurationMs int64 `json:"duration_ms"` Summary struct { Pass int `json:"pass"` Warn int `json:"warn"` Fail int `json:"fail"` Info int `json:"info"` } `json:"summary"` ClientIP string `json:"client_ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` } // FullHistoryEntry includes the parsed report. type FullHistoryEntry struct { HistoryEntry Report *checker.Report `json:"report"` } // Store wraps SQLite for persisting DNS check results. type Store struct { db *sql.DB } // NewStore opens the SQLite database at dbPath and creates the schema. func NewStore(dbPath string) (*Store, error) { db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } // Enable WAL mode for better concurrency. if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { db.Close() return nil, fmt.Errorf("set WAL mode: %w", err) } schema := ` CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL, timestamp TEXT NOT NULL, duration_ms INTEGER NOT NULL, summary_pass INTEGER NOT NULL DEFAULT 0, summary_warn INTEGER NOT NULL DEFAULT 0, summary_fail INTEGER NOT NULL DEFAULT 0, summary_info INTEGER NOT NULL DEFAULT 0, client_ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', report_json TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_checks_domain ON checks(domain); CREATE INDEX IF NOT EXISTS idx_checks_timestamp ON checks(timestamp); ` if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("create schema: %w", err) } // Migrate: add columns if they don't exist (safe for existing DBs). for _, col := range []string{ "ALTER TABLE checks ADD COLUMN client_ip TEXT NOT NULL DEFAULT ''", "ALTER TABLE checks ADD COLUMN user_agent TEXT NOT NULL DEFAULT ''", } { db.Exec(col) // ignore errors — column may already exist } return &Store{db: db}, nil } // SaveReport serializes the report to JSON and inserts a row. func (s *Store) SaveReport(report *checker.Report, clientIP, userAgent string) error { reportJSON, err := json.Marshal(report) if err != nil { return fmt.Errorf("marshal report: %w", err) } _, err = s.db.Exec( `INSERT INTO checks (domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent, report_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, report.Domain, report.Timestamp, report.DurationMs, report.Summary.Pass, report.Summary.Warn, report.Summary.Fail, report.Summary.Info, clientIP, userAgent, string(reportJSON), ) if err != nil { return fmt.Errorf("insert check: %w", err) } return nil } // GetHistory returns the latest N check entries for a specific domain. func (s *Store) GetHistory(domain string, limit int) ([]HistoryEntry, error) { rows, err := s.db.Query( `SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent FROM checks WHERE domain = ? ORDER BY id DESC LIMIT ?`, domain, limit, ) if err != nil { return nil, fmt.Errorf("query history: %w", err) } defer rows.Close() return scanEntries(rows) } // GetRecent returns the latest N check entries across all domains. func (s *Store) GetRecent(limit int) ([]HistoryEntry, error) { rows, err := s.db.Query( `SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent FROM checks ORDER BY id DESC LIMIT ?`, limit, ) if err != nil { return nil, fmt.Errorf("query recent: %w", err) } defer rows.Close() return scanEntries(rows) } // GetReport returns a single history entry with the full parsed report. func (s *Store) GetReport(id int64) (*FullHistoryEntry, error) { row := s.db.QueryRow( `SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent, report_json FROM checks WHERE id = ?`, id, ) var e FullHistoryEntry var reportJSON string err := row.Scan( &e.ID, &e.Domain, &e.Timestamp, &e.DurationMs, &e.Summary.Pass, &e.Summary.Warn, &e.Summary.Fail, &e.Summary.Info, &e.ClientIP, &e.UserAgent, &reportJSON, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("scan report: %w", err) } var report checker.Report if err := json.Unmarshal([]byte(reportJSON), &report); err != nil { return nil, fmt.Errorf("unmarshal report: %w", err) } e.Report = &report return &e, nil } // Close closes the underlying database connection. func (s *Store) Close() error { return s.db.Close() } func scanEntries(rows *sql.Rows) ([]HistoryEntry, error) { var entries []HistoryEntry for rows.Next() { var e HistoryEntry if err := rows.Scan( &e.ID, &e.Domain, &e.Timestamp, &e.DurationMs, &e.Summary.Pass, &e.Summary.Warn, &e.Summary.Fail, &e.Summary.Info, &e.ClientIP, &e.UserAgent, ); err != nil { return nil, fmt.Errorf("scan entry: %w", err) } entries = append(entries, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("rows iteration: %w", err) } return entries, nil }