- cmd/serve.go: starts scheduler, optionally starts Telegram bot with --telegram flag - cmd/schedule.go: add/list/remove/run subcommands for scheduled scan job CRUD - pkg/scheduler/: gocron v2 based scheduler with DB-backed jobs and scan execution - pkg/storage/scheduled_jobs.go: scheduled_jobs table CRUD with tests - Remove serve and schedule stubs from cmd/stubs.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
4.3 KiB
Go
165 lines
4.3 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ScheduledJob represents a cron-based recurring scan job.
|
|
type ScheduledJob struct {
|
|
ID int64
|
|
Name string
|
|
CronExpr string
|
|
ScanPath string
|
|
Enabled bool
|
|
Notify bool
|
|
LastRunAt *time.Time
|
|
NextRunAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// SaveScheduledJob inserts a new scheduled job and returns its ID.
|
|
func (db *DB) SaveScheduledJob(job ScheduledJob) (int64, error) {
|
|
enabledInt := 0
|
|
if job.Enabled {
|
|
enabledInt = 1
|
|
}
|
|
notifyInt := 0
|
|
if job.Notify {
|
|
notifyInt = 1
|
|
}
|
|
|
|
res, err := db.sql.Exec(
|
|
`INSERT INTO scheduled_jobs (name, cron_expr, scan_path, enabled, notify)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
job.Name, job.CronExpr, job.ScanPath, enabledInt, notifyInt,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("inserting scheduled job: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// ListScheduledJobs returns all scheduled jobs ordered by creation time.
|
|
func (db *DB) ListScheduledJobs() ([]ScheduledJob, error) {
|
|
rows, err := db.sql.Query(
|
|
`SELECT id, name, cron_expr, scan_path, enabled, notify, last_run_at, next_run_at, created_at
|
|
FROM scheduled_jobs ORDER BY created_at ASC`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying scheduled jobs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var jobs []ScheduledJob
|
|
for rows.Next() {
|
|
j, err := scanJobRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jobs = append(jobs, j)
|
|
}
|
|
return jobs, rows.Err()
|
|
}
|
|
|
|
// GetScheduledJob returns a single job by ID.
|
|
func (db *DB) GetScheduledJob(id int64) (*ScheduledJob, error) {
|
|
row := db.sql.QueryRow(
|
|
`SELECT id, name, cron_expr, scan_path, enabled, notify, last_run_at, next_run_at, created_at
|
|
FROM scheduled_jobs WHERE id = ?`, id,
|
|
)
|
|
var j ScheduledJob
|
|
var enabledInt, notifyInt int
|
|
var lastRun, nextRun, createdAt sql.NullString
|
|
if err := row.Scan(&j.ID, &j.Name, &j.CronExpr, &j.ScanPath,
|
|
&enabledInt, ¬ifyInt, &lastRun, &nextRun, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
j.Enabled = enabledInt != 0
|
|
j.Notify = notifyInt != 0
|
|
j.LastRunAt = parseNullTime(lastRun)
|
|
j.NextRunAt = parseNullTime(nextRun)
|
|
if createdAt.Valid {
|
|
t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String)
|
|
j.CreatedAt = t
|
|
}
|
|
return &j, nil
|
|
}
|
|
|
|
// DeleteScheduledJob removes a job by ID and returns rows affected.
|
|
func (db *DB) DeleteScheduledJob(id int64) (int64, error) {
|
|
res, err := db.sql.Exec(`DELETE FROM scheduled_jobs WHERE id = ?`, id)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting scheduled job %d: %w", id, err)
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
// UpdateJobLastRun updates the last_run_at timestamp for a job.
|
|
func (db *DB) UpdateJobLastRun(id int64, t time.Time) error {
|
|
_, err := db.sql.Exec(
|
|
`UPDATE scheduled_jobs SET last_run_at = ? WHERE id = ?`,
|
|
t.Format("2006-01-02 15:04:05"), id,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// ListEnabledScheduledJobs returns only enabled jobs.
|
|
func (db *DB) ListEnabledScheduledJobs() ([]ScheduledJob, error) {
|
|
rows, err := db.sql.Query(
|
|
`SELECT id, name, cron_expr, scan_path, enabled, notify, last_run_at, next_run_at, created_at
|
|
FROM scheduled_jobs WHERE enabled = 1 ORDER BY created_at ASC`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying enabled scheduled jobs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var jobs []ScheduledJob
|
|
for rows.Next() {
|
|
j, err := scanJobRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jobs = append(jobs, j)
|
|
}
|
|
return jobs, rows.Err()
|
|
}
|
|
|
|
func scanJobRow(rows *sql.Rows) (ScheduledJob, error) {
|
|
var j ScheduledJob
|
|
var enabledInt, notifyInt int
|
|
var lastRun, nextRun, createdAt sql.NullString
|
|
if err := rows.Scan(&j.ID, &j.Name, &j.CronExpr, &j.ScanPath,
|
|
&enabledInt, ¬ifyInt, &lastRun, &nextRun, &createdAt); err != nil {
|
|
return j, fmt.Errorf("scanning scheduled job row: %w", err)
|
|
}
|
|
j.Enabled = enabledInt != 0
|
|
j.Notify = notifyInt != 0
|
|
j.LastRunAt = parseNullTime(lastRun)
|
|
j.NextRunAt = parseNullTime(nextRun)
|
|
if createdAt.Valid {
|
|
t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String)
|
|
j.CreatedAt = t
|
|
}
|
|
return j, nil
|
|
}
|
|
|
|
func parseNullTime(ns sql.NullString) *time.Time {
|
|
if !ns.Valid || ns.String == "" {
|
|
return nil
|
|
}
|
|
// Try multiple formats SQLite may return.
|
|
for _, layout := range []string{
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02T15:04:05Z",
|
|
time.RFC3339,
|
|
} {
|
|
if t, err := time.Parse(layout, ns.String); err == nil {
|
|
return &t
|
|
}
|
|
}
|
|
return nil
|
|
}
|