---
phase: 17-telegram-scheduler
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - pkg/scheduler/scheduler.go
  - pkg/scheduler/jobs.go
  - pkg/scheduler/scheduler_test.go
  - pkg/storage/schema.sql
  - pkg/storage/subscribers.go
  - pkg/storage/scheduled_jobs.go
  - go.mod
  - go.sum
autonomous: true
requirements: [SCHED-01]

must_haves:
  truths:
    - "Scheduler loads enabled jobs from SQLite on startup and registers them with gocron"
    - "Scheduled jobs persist across restarts (stored in scheduled_jobs table)"
    - "Subscriber chat IDs persist in subscribers table"
    - "Scheduler executes scan at cron intervals"
  artifacts:
    - path: "pkg/scheduler/scheduler.go"
      provides: "Scheduler struct wrapping gocron with start/stop lifecycle"
      exports: ["Scheduler", "New", "Start", "Stop"]
    - path: "pkg/scheduler/jobs.go"
      provides: "Job struct and CRUD operations"
      exports: ["Job"]
    - path: "pkg/storage/scheduled_jobs.go"
      provides: "SQLite CRUD for scheduled_jobs table"
      exports: ["ScheduledJob", "SaveScheduledJob", "ListScheduledJobs", "DeleteScheduledJob", "UpdateJobLastRun"]
    - path: "pkg/storage/subscribers.go"
      provides: "SQLite CRUD for subscribers table"
      exports: ["Subscriber", "AddSubscriber", "RemoveSubscriber", "ListSubscribers"]
    - path: "pkg/storage/schema.sql"
      provides: "subscribers and scheduled_jobs CREATE TABLE statements"
      contains: "CREATE TABLE IF NOT EXISTS subscribers"
  key_links:
    - from: "pkg/scheduler/scheduler.go"
      to: "github.com/go-co-op/gocron/v2"
      via: "gocron.NewScheduler + AddJob"
      pattern: "gocron\\.NewScheduler"
    - from: "pkg/scheduler/scheduler.go"
      to: "pkg/storage"
      via: "DB.ListScheduledJobs for startup load"
      pattern: "db\\.ListScheduledJobs"
---

<objective>
Create the pkg/scheduler/ package and the SQLite storage tables (subscribers, scheduled_jobs) that both the bot and scheduler depend on.

Purpose: Establishes cron-based recurring scan infrastructure and the persistence layer for subscriptions and jobs. Independent of pkg/bot/ (Wave 1 parallel).
Output: pkg/scheduler/, pkg/storage/subscribers.go, pkg/storage/scheduled_jobs.go, updated schema.sql.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/17-telegram-scheduler/17-CONTEXT.md
@pkg/storage/db.go
@pkg/storage/schema.sql
@pkg/engine/engine.go
</context>

<interfaces>
<!-- Key types the executor needs from existing codebase -->

From pkg/storage/db.go:
```go
type DB struct { sql *sql.DB }
func Open(path string) (*DB, error)
func (db *DB) Close() error
func (db *DB) SQL() *sql.DB
```

From pkg/engine/engine.go:
```go
type ScanConfig struct { Workers int; Verify bool; Unmask bool }
func (e *Engine) Scan(ctx context.Context, src sources.Source, cfg ScanConfig) (<-chan Finding, error)
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Add gocron dependency, create storage tables, and subscriber/job CRUD</name>
  <files>go.mod, go.sum, pkg/storage/schema.sql, pkg/storage/subscribers.go, pkg/storage/scheduled_jobs.go</files>
  <action>
1. Run `go get github.com/go-co-op/gocron/v2@v2.19.1` to add gocron as a direct dependency.

2. Append to pkg/storage/schema.sql (after existing custom_dorks table):

```sql
-- Phase 17: Telegram bot subscribers for auto-notifications.
CREATE TABLE IF NOT EXISTS subscribers (
    chat_id INTEGER PRIMARY KEY,
    username TEXT,
    subscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- Phase 17: Cron-based scheduled scan jobs.
CREATE TABLE IF NOT EXISTS scheduled_jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT UNIQUE NOT NULL,
    cron_expr TEXT NOT NULL,
    scan_command TEXT NOT NULL,
    notify_telegram BOOLEAN DEFAULT FALSE,
    enabled BOOLEAN DEFAULT TRUE,
    last_run DATETIME,
    next_run DATETIME,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```

3. Create pkg/storage/subscribers.go:
- `Subscriber` struct: `ChatID int64`, `Username string`, `SubscribedAt time.Time`
- `(db *DB) AddSubscriber(chatID int64, username string) error` — INSERT OR REPLACE
- `(db *DB) RemoveSubscriber(chatID int64) (int64, error)` — DELETE, return rows affected
- `(db *DB) ListSubscribers() ([]Subscriber, error)` — SELECT all
- `(db *DB) IsSubscribed(chatID int64) (bool, error)` — SELECT count

4. Create pkg/storage/scheduled_jobs.go:
- `ScheduledJob` struct: `ID int64`, `Name string`, `CronExpr string`, `ScanCommand string`, `NotifyTelegram bool`, `Enabled bool`, `LastRun *time.Time`, `NextRun *time.Time`, `CreatedAt time.Time`
- `(db *DB) SaveScheduledJob(j ScheduledJob) (int64, error)` — INSERT
- `(db *DB) ListScheduledJobs() ([]ScheduledJob, error)` — SELECT all
- `(db *DB) GetScheduledJob(name string) (*ScheduledJob, error)` — SELECT by name
- `(db *DB) DeleteScheduledJob(name string) (int64, error)` — DELETE by name, return rows affected
- `(db *DB) UpdateJobLastRun(name string, lastRun time.Time, nextRun *time.Time) error` — UPDATE last_run and next_run
- `(db *DB) SetJobEnabled(name string, enabled bool) error` — UPDATE enabled flag
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go build ./pkg/storage/...</automated>
  </verify>
  <done>schema.sql has subscribers and scheduled_jobs tables. Storage CRUD methods compile.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Scheduler package with gocron wrapper and startup job loading</name>
  <files>pkg/scheduler/scheduler.go, pkg/scheduler/jobs.go, pkg/scheduler/scheduler_test.go</files>
  <behavior>
    - Test 1: SaveScheduledJob + ListScheduledJobs round-trips correctly in :memory: DB
    - Test 2: AddSubscriber + ListSubscribers round-trips correctly
    - Test 3: Scheduler.Start loads jobs from DB and registers with gocron
    - Test 4: Scheduler.AddJob persists to DB and registers cron job
    - Test 5: Scheduler.RemoveJob removes from DB and gocron
  </behavior>
  <action>
1. Create pkg/scheduler/jobs.go:
- `Job` struct mirroring storage.ScheduledJob but with a `RunFunc func(context.Context) (int, error)` field (the scan function to call; returns finding count + error)
- `JobResult` struct: `JobName string`, `FindingCount int`, `Duration time.Duration`, `Error error`

2. Create pkg/scheduler/scheduler.go:
- `Config` struct:
  - `DB *storage.DB`
  - `ScanFunc func(ctx context.Context, scanCommand string) (int, error)` — abstracted scan executor (avoids tight coupling to engine)
  - `OnComplete func(result JobResult)` — callback for notification bridge (Plan 17-04 wires this)

- `Scheduler` struct:
  - `cfg Config`
  - `sched gocron.Scheduler` (gocron scheduler instance)
  - `jobs map[string]gocron.Job` (gocron job handles keyed by name)
  - `mu sync.Mutex`

- `New(cfg Config) (*Scheduler, error)`:
  - Create gocron scheduler via `gocron.NewScheduler()`
  - Return Scheduler

- `Start(ctx context.Context) error`:
  - Load all enabled jobs from DB via `cfg.DB.ListScheduledJobs()`
  - For each, call internal `registerJob(job)` which creates a gocron.CronJob and stores handle
  - Call `sched.Start()` to begin scheduling

- `Stop() error`:
  - Call `sched.Shutdown()` to stop all jobs

- `AddJob(name, cronExpr, scanCommand string, notifyTelegram bool) error`:
  - Save to DB via `cfg.DB.SaveScheduledJob`
  - Register with gocron via `registerJob`

- `RemoveJob(name string) error`:
  - Remove gocron job handle from `jobs` map and call `sched.RemoveJob`
  - Delete from DB via `cfg.DB.DeleteScheduledJob`

- `ListJobs() ([]storage.ScheduledJob, error)`:
  - Delegate to `cfg.DB.ListScheduledJobs()`

- `RunJob(ctx context.Context, name string) (JobResult, error)`:
  - Manual trigger — look up job in DB, call ScanFunc directly, call OnComplete callback

- Internal `registerJob(sj storage.ScheduledJob)`:
  - Create gocron job: `sched.NewJob(gocron.CronJob(sj.CronExpr, false), gocron.NewTask(func() { ... }))`
  - The task function: call `cfg.ScanFunc(ctx, sj.ScanCommand)`, update last_run/next_run via DB, call `cfg.OnComplete` if sj.NotifyTelegram

3. Create pkg/scheduler/scheduler_test.go:
- Use storage.Open(":memory:") for all tests
- TestStorageRoundTrip: Save job, list, verify fields match
- TestSubscriberRoundTrip: Add subscriber, list, verify; remove, verify empty
- TestSchedulerStartLoadsJobs: Save 2 enabled jobs to DB, create Scheduler with mock ScanFunc, call Start, verify gocron has 2 jobs registered (check len(s.jobs)==2)
- TestSchedulerAddRemoveJob: Add via Scheduler.AddJob, verify in DB; Remove, verify gone from DB
- TestSchedulerRunJob: Manual trigger via RunJob, verify ScanFunc called with correct scanCommand, verify OnComplete called with result
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go test ./pkg/scheduler/... ./pkg/storage/... -v -count=1 -run "TestStorage|TestSubscriber|TestScheduler"</automated>
  </verify>
  <done>Scheduler starts, loads jobs from DB, registers with gocron. AddJob/RemoveJob/RunJob work end-to-end. All tests pass.</done>
</task>

</tasks>

<verification>
- `go build ./pkg/scheduler/...` compiles without errors
- `go test ./pkg/scheduler/... -v` passes all tests
- `go test ./pkg/storage/... -v -run Subscriber` passes subscriber CRUD tests
- `go test ./pkg/storage/... -v -run ScheduledJob` passes job CRUD tests
- `grep gocron go.mod` shows direct dependency at v2.19.1
</verification>

<success_criteria>
- pkg/scheduler/ exists with Scheduler struct, gocron wrapper, job loading from DB
- pkg/storage/subscribers.go and pkg/storage/scheduled_jobs.go exist with full CRUD
- schema.sql has both new tables
- gocron v2.19.1 is a direct dependency in go.mod
- All tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/17-telegram-scheduler/17-02-SUMMARY.md`
</output>
