feat(17-05): implement serve and schedule commands replacing stubs
- 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>
This commit is contained in:
164
pkg/storage/scheduled_jobs.go
Normal file
164
pkg/storage/scheduled_jobs.go
Normal file
@@ -0,0 +1,164 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user