# Scheduled reports

Send dashboard summaries on a schedule, with explicit delivery controls.

Open a dashboard and choose **Schedules → New schedule**. The inline editor keeps
the dashboard and sidebar in place. Choose the whole dashboard or selected panels,
a summary with an optional PDF or CSV attachment, saved notification channels,
and an interval, daily, weekly, or monthly delivery time and time zone.
The sidebar **Schedules** page also manages these same deliveries alongside
[workflow automation](https://bytesmap.com/docs/automate/workflows.md), using the shared editor and scheduler.

There is one delivery path: workspace notification channels. Email is one channel
type; schedules no longer accept raw email recipients. A summary-only channel
cannot be the sole destination for a PDF/CSV schedule. With mixed destinations,
Slack and Teams receive the summary/link and file-capable channels receive the
attachment too. Capabilities come from the shared core catalog.

**Download sample** explicitly runs the selected content for a PDF/CSV export;
nothing is sent. Opening, editing, saving, and refreshing schedules only read or
write metadata. **Send now** explicitly queues a delivery using the saved settings,
including for a paused schedule. Schedules can be edited, paused, resumed, or
deleted. **History** records each channel's outcome, including partial delivery.
An attempt being queued or generated cannot be edited or deleted until it finishes.

## Filters and exported data

New schedules start with the dashboard's current filters. Each schedule stores its
own filter values. Relative values such as `-7d` are resolved again at delivery
time, using one common time across the dashboard. The export includes both saved
filters and the resolved parameters used for each panel. The current dashboard
layout and query definitions are read on every run. Whole-dashboard schedules
include newly added panels. Selected-panel schedules retain stable panel keys and
include only those panels. A removed selection fails before any query runs. If a saved filter has been
removed from the dashboard, the report fails until its settings are reviewed.

- **Summary** contains a bounded excerpt of result values from up to 12 selected
  panels (first three rows and four encoded fields, or table columns), data notes,
  and a dashboard link.
  Provider message limits may shorten it. It never derives new totals from rows.
- **PDF** is a printable data report with panel tables, filters, totals, and result
  notes. It is not a screenshot of dashboard charts. Each panel shows up to 100
  rows and 12 columns; larger results and shortened cells are clearly indicated.
  Fonts are bundled, including Arabic support, so generation needs no browser or
  external font service. Font licenses are in the report module's `assets/OFL.txt`.
- **CSV** is a ZIP attachment containing a CSV for each panel, a separate totals
  CSV where available, and `report.json` with filters and result metadata. Text
  uses UTF-8 and standard CSV quoting. Spreadsheet formula prefixes in text are
  escaped; numeric values remain numeric.

Reports use the normal query permissions and query limits. Shared queries are
executed once per report. Truncation, sampling, estimates, and partial flow
results are disclosed. Live metrics are current measurements and do not use
dashboard query filters. A missing or failed panel prevents delivery of the
whole report. Exports allow up to 100 panels, 8 MB of collected data, and an 8 MB
attachment, with a two-minute generation deadline. Narrow the filters or reduce
the dashboard results when a limit is reached.

## Database setup

API, native host and report-worker startup apply the shared metadata migrations,
including report tables/settings. For an explicit migration, run from the
repository root using the same database configuration as the API:

```sh
bun --env-file=.env run db:migrate
```

This creates the report tables and indexes without replacing existing data.
When using embedded PGlite, stop the API before running a separate migration
process against its data directory. Fresh installations receive the baseline
schema and additive upgrades through the same migration entrypoint.

## Notification channels and email setup

Workspace owners and admins configure **Settings → Notifications**. The inline
channel editor follows OpenShip's provider picker and verified-channel flow,
using shared BytesMap components. Add a channel, save it, then use **Send test**
to deliver a real test notification. Saving or opening settings never sends a
message or runs a dashboard query.

| Channel  | Report delivery                                                           |
| -------- | ------------------------------------------------------------------------- |
| Email    | Summary and optional PDF or CSV ZIP through the workspace mail connection |
| Slack    | Summary and dashboard link                                                |
| Discord  | Summary and PDF or CSV ZIP attachment                                     |
| Teams    | Summary and dashboard link through an incoming webhook or Workflow        |
| Telegram | Summary and file through a bot, with optional forum topic ID              |
| Webhook  | Signed JSON, with the attachment encoded as base64                        |

Channels belong to the workspace. Report authors can select their labels without
access to credentials. Channel URLs, bot tokens, and signing secrets are encrypted
with the host's credential codec. The API uses AES-256-GCM backed by
`BYTESMAP_MASTER_KEY` when configured; legacy values and local installations without
a master key use `BETTER_AUTH_SECRET`. See [credential encryption and rotation](https://bytesmap.com/docs/sources/safety.md#transport-and-credentials).
Webhook HMAC secrets are shown only on creation
or replacement. Receivers verify `X-BytesMap-Signature-256: sha256=<hex>` using
HMAC-SHA256 over the exact request body. The JSON includes `id`, `category`,
`organizationId`, `title`, `body`, `url`, `createdAt`, and optional `attachment`.

Changing a destination resets its verification; renaming or pausing it does not.
A test that finishes after the destination changes cannot verify the new config.
Every selected channel must exist, be enabled, and have passed a test before the
report reads customer data. Delivery resolves current channel settings again.
Pausing a channel prevents reports using it from sending; those attempts appear
as failed in history. Remove a channel from its schedules before deleting it.

Under **Scheduler & channel delivery → Email channel connection**, choose the instance connection or
save a custom SMTP server for this workspace. Host, port, username, password,
sender name, and sender address are editable. Connection security expands to
STARTTLS or TLS. **Test connection** verifies connectivity and login only: it
does not send email or save the draft. An omitted password keeps the saved value
only for the same host, port, security mode, and username. Switching to instance
email preserves the custom connection for later use. Both modes validate TLS.

Instance SMTP remains the fallback for existing installations and still provides
account email independently of workspace report settings. Configure it on the API
and any separate worker if using **Use instance email**:

```dotenv
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=reports@example.com
SMTP_PASS=your-smtp-password
SMTP_FROM=BytesMap <reports@example.com>
```

Each email channel receives its own message, without exposing other channel
addresses. Only email channels require a mail connection; chat and webhook
channels work without SMTP. Reports
can still be saved as paused and downloaded as samples. Attachments and credentials
are never printed as a delivery fallback.

Webhooks use HTTPS, pinned DNS, bounded responses, and a 15-second deadline.
Redirects are not followed. Private destinations are blocked by default. A
self-hosted instance can opt into internal generic webhooks with
`BYTESMAP_NOTIFY_ALLOW_INTERNAL=true`; cloud mode always blocks them. This does
not relax destination checks for Slack, Discord, Teams, or Telegram. Custom SMTP
can reach private relays on self-hosted instances, while cloud requires public hosts.

## Running the scheduler

With `BYTESMAP_WORKER_MODE=in-process`, the API runs the scheduler. The BytesMap
server must stay running; the browser may be closed. **Settings → Notifications →
Scheduler & channel delivery** enables or pauses automatic schedules for the workspace. Expand
**Scheduler options** to set the check interval from 10 seconds to one hour
(default 30 seconds). These changes apply without restarting the API or worker.
Manual **Send now** requests remain available while automatic reports are paused.
The host wakes every five seconds to inspect metadata; a durable per-workspace
checkpoint controls automatic checks at the saved interval. This wake-up does
not run customer queries. Delivery can start after the selected minute by the
configured interval and any time needed for earlier reports.
Daily and weekly schedules follow the selected IANA time zone, including
daylight saving transitions. Monthly schedules use days 1–28. If the server is
offline across several intervals, one catch-up run replaces the missed runs.

For a separate worker, use PostgreSQL for **BytesMap's metadata database**, set
`BYTESMAP_WORKER_MODE=worker` on the API, and start:

```sh
bun run --cwd apps/api reports:worker
```

Do not share an embedded PGlite directory between the API and a separate worker.
Both processes must use the same `DATABASE_URL`, `BETTER_AUTH_SECRET`,
`BYTESMAP_MASTER_KEY` when configured (required in cloud mode), and instance SMTP
settings if using the fallback. Preserve the legacy auth secret while any old
credentials still depend on it. Workspace settings are read from metadata. The
distributable package includes the same worker:

```sh
bun run build:sdk
bun packages/bytesmap/dist/worker.js
```

Run the installed `bytesmap-report-worker` with the same environment supplied by your process
manager. Durable database claims coordinate multiple schedulers and prevent
concurrent delivery of the same run. Manual requests remain queued through
restarts. A running attempt has a ten-minute lease; an expired lease is recorded
as interrupted. Interrupted, failed, or partially accepted deliveries are not
automatically retried, because a provider may already have accepted a delivery.
Check destinations and history before choosing **Send now** again. A successful
status means the provider accepted the message, not that a recipient read it.
An export is collected and rendered once, then sent to each destination.
If only some destinations accept it, history records a partial delivery.

## Access and history

Managing reports requires dashboard write access. Every run checks current
organization membership, dashboard access, and access to all saved queries and
flow sources. Automatic runs use the person who last saved or resumed the
schedule; manual runs use the person who requested delivery. Browser sessions, local mode and
scoped tokens use the same operations. Each run rechecks the originating membership and credential
against current grants; it cannot borrow broader owner access after a token is revoked.

Managing channels and report delivery settings requires an account session with
workspace owner/admin access. API keys and MCP cannot configure outbound
destinations. The shared SDK exposes identical HTTP and trusted native operations.

Each workspace allows up to 50 channels. Each dashboard allows up to 20 schedules,
each with up to 20 selected channels. The
latest 100 attempts per schedule are retained, with up to 25 shown in history.
History records settings, channel IDs, provider labels, per-channel outcomes, and timestamps. Generated data and
attachments are not stored in the metadata database.

## HTTP endpoints

All paths below are under `/api/dashboards/:id`.

| Method | Path                      | Purpose                                                              |
| ------ | ------------------------- | -------------------------------------------------------------------- |
| GET    | `/reports`                | Schedules, available channels, SMTP/scheduler state, and worker mode |
| POST   | `/reports`                | Create a schedule                                                    |
| PATCH  | `/reports/:reportId`      | Replace settings, or pause with `{ "enabled": false }`               |
| DELETE | `/reports/:reportId`      | Delete an idle schedule and its history                              |
| GET    | `/reports/:reportId/runs` | Recent delivery attempts                                             |
| POST   | `/reports/:reportId/send` | Queue a manual delivery; returns HTTP 202                            |
| POST   | `/report-export`          | Download a sample using `{ "format": "pdf", "params": {} }`          |

Create and full update bodies have this shape:

```json
{
  "name": "Weekly operations",
  "channelIds": ["saved-notification-channel-id"],
  "panelIds": ["chart:active-users-chart-id"],
  "format": "pdf",
  "params": { "since": "-7d" },
  "enabled": true,
  "recurrence": {
    "frequency": "weekly",
    "time": "09:00",
    "timezone": "Africa/Cairo",
    "weekday": 1,
    "monthDay": 1
  }
}
```

`weekday` uses Sunday = 0 and is used for weekly schedules. `monthDay` is used
for monthly schedules. Both fields are present regardless of frequency. Use
`"format": "csv"` for a ZIP of CSV files. Only parameters declared by the
dashboard may be supplied.

`channelIds` is required and non-empty. `recipients` is rejected. Omit `panelIds`
for the whole dashboard; otherwise send a non-empty array of the shared
`panelKey()` identities. The sample export endpoint accepts the same optional
`panelIds`. Use `format: "summary"` for a message without an attachment.
The existing `reports` HTTP/SDK operation names remain compatible. The unified `schedules`
operations delegate dashboard deliveries to the same services and durable queue; there is no
separate client execution engine.

Workspace operations are defined once in `@bytesmap/contracts` and implemented
by `@bytesmap/platform`:

| Method         | Path                                    | Purpose                                                |
| -------------- | --------------------------------------- | ------------------------------------------------------ |
| GET / PUT      | `/api/settings/reports`                 | Read/save workspace scheduler and mail configuration   |
| POST           | `/api/settings/reports/test-connection` | Test a draft SMTP connection without saving or sending |
| GET / POST     | `/api/notifications/channels`           | List/create channels                                   |
| PATCH / DELETE | `/api/notifications/channels/:id`       | Edit/pause/delete a channel                            |
| POST           | `/api/notifications/channels/:id/test`  | Send a test and verify the unchanged destination       |

API/worker/native startup applies an additive migration. Legacy direct recipients
become saved email channels, reused by address within the same workspace. New
channels require an explicit test; migration never marks them verified. Affected
schedules pause with **Review needed**, and pending legacy sends are interrupted.
Test the converted channels, then edit and enable the schedule. Historical
recipient labels remain readable, but cannot be used for new delivery.
Existing channel-only schedules keep their cadence. Existing Slack/Teams-only
schedules are normalized to summary delivery, which is what those webhooks
already received. Workspace SMTP settings and credentials are preserved.
