# Scheduled audits — cron setup

This app schedules audits via a server endpoint, not via in-process Node
cron. The choice of "who triggers the endpoint" is yours:

- **Production (recommended)**: `pg_cron` + `pg_net` extensions on the same
  Postgres instance — the DB pings our endpoint every N minutes. No extra
  infra, no separate worker process.
- **Fallback**: any host-level cron (systemd timer, GitHub Actions, infra
  cron, server cron) hitting the endpoint with `curl`. Use this if your
  Postgres doesn't have `pg_net` or you prefer to keep the DB hands-off.

The endpoint is `POST /api/cron/run-audits`. It queries `sites` where
`audit_cadence` is `daily` or `weekly` and `next_audit_at` has elapsed,
fires the audits, and advances `next_audit_at`.

---

## Endpoint contract

```
POST /api/cron/run-audits
Authorization: Bearer <CRON_SECRET>
```

- Returns `200` with a JSON body summarizing the run:
  ```json
  {
    "runAt": "2026-05-23T14:00:00.000Z",
    "candidates": 12,
    "triggered": 10,
    "failed": 2,
    "results": [{ "siteId": "...", "ok": true, "testId": "..." }]
  }
  ```
- `401` if the bearer is missing/wrong.
- `503` if `CRON_SECRET` is not configured server-side.

`CRON_SECRET` must be **at least 32 chars**. Generate one with:

```bash
openssl rand -base64 48
```

Put it in eleva-speed's `.env.local` (and in your production env).

---

## Option A — pg_cron + pg_net (recommended)

### 1. Enable extensions on the eleva-speed Postgres database

Run **once, as superuser**:

```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
CREATE EXTENSION IF NOT EXISTS pg_net;
```

Notes:

- `pg_cron` typically requires `shared_preload_libraries = 'pg_cron'` in
  `postgresql.conf` + a restart. If you're on managed Postgres (Supabase,
  Neon, AWS RDS, Crunchy, etc.) check their docs — most expose it as a
  toggle in the dashboard.
- `pg_net` also needs to be enabled. Some providers ship it by default
  alongside `pg_cron`; others require a request.

### 2. Store the endpoint + secret as database GUC settings

These are safer than embedding them in the cron job SQL itself:

```sql
ALTER DATABASE elevaspeed SET app.cron_endpoint = 'https://eleva-speed.app/api/cron/run-audits';
ALTER DATABASE elevaspeed SET app.cron_secret = '<your-CRON_SECRET-value>';
```

Replace `elevaspeed` with your actual database name (matches `POSTGRES_DB`
in `docker-compose.yml` if you're using the local stack, or the database
in your `DATABASE_URL`).

### 3. Schedule the job

```sql
SELECT cron.schedule(
  'eleva-audits',
  '*/5 * * * *',
  $$
    SELECT net.http_post(
      url := current_setting('app.cron_endpoint'),
      headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.cron_secret'))
    );
  $$
);
```

Every 5 minutes, Postgres POSTs to our endpoint. Our handler scans for due
sites and fires audits.

### 4. Inspect the cron jobs

```sql
-- List all jobs
SELECT * FROM cron.job;

-- See the most recent runs
SELECT * FROM cron.job_run_details ORDER BY start_time DESC LIMIT 20;

-- Inspect pg_net HTTP responses
SELECT * FROM net._http_response ORDER BY created DESC LIMIT 20;
```

### 5. Unschedule (if you need to)

```sql
SELECT cron.unschedule('eleva-audits');
```

---

## Option B — host cron (fallback)

If `pg_net` is unavailable, run cron from anywhere that can reach the API:

### systemd timer (Linux server)

`/etc/systemd/system/eleva-audits.service`:

```ini
[Unit]
Description=Eleva Speed scheduled audits tick

[Service]
Type=oneshot
ExecStart=/usr/bin/curl -sf -X POST \
  -H "Authorization: Bearer ${CRON_SECRET}" \
  https://eleva-speed.app/api/cron/run-audits
Environment=CRON_SECRET=...
```

`/etc/systemd/system/eleva-audits.timer`:

```ini
[Unit]
Description=Eleva Speed scheduled audits — every 5 min

[Timer]
OnCalendar=*:0/5
Persistent=true

[Install]
WantedBy=timers.target
```

Then:

```bash
sudo systemctl enable --now eleva-audits.timer
```

### GitHub Actions

`.github/workflows/audits-cron.yml`:

```yaml
name: scheduled-audits
on:
  schedule:
    - cron: "*/5 * * * *"
jobs:
  tick:
    runs-on: ubuntu-latest
    steps:
      - run: |
          curl -sf -X POST \
            -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
            https://eleva-speed.app/api/cron/run-audits
```

### Plain cron

```
*/5 * * * * curl -sf -X POST -H "Authorization: Bearer $CRON_SECRET" https://eleva-speed.app/api/cron/run-audits
```

---

## Cadence semantics

- `none` — no scheduled audits, `next_audit_at` is `null`. Default.
- `daily` — re-audit ~24h after the previous successful audit.
- `weekly` — re-audit ~7 days after.

Time-drift policy: `next_audit_at` is set to `now() + interval` after each
successful run. If the cron tick was late, we don't try to catch up — one
audit fires, then we wait the full interval.

If sitespeed returns an error for a site, `next_audit_at` is **not**
advanced, so the next tick retries.

---

## Tuning

- **Tick frequency**: every 5 minutes is a good default. Daily cadence
  could run hourly and still be cheap. Don't go below 1 minute — sitespeed
  audits take 30-90s and you'll pile up.
- **Concurrency**: the endpoint runs sequentially (one audit start per
  iteration). Sitespeed itself queues incoming tests, so we don't need
  client-side concurrency control. If you need fan-out, switch to a worker
  queue.
- **Per-site rate**: nothing extra today — `next_audit_at` is the only
  guard. If you ever want to cap audits per user (e.g., free tier), add
  it inside the endpoint handler.
