feat(dashboards): scheduled dashboard delivery worker (PMS-471) #349

Merged
YousifShkara merged 1 commit from feat/PMS-471-scheduled-dashboards into main 2026-06-24 08:49:46 +02:00
Owner

PMS-453 phase 1 shipped per-user saved dashboards (saved_dashboards table + /api/v1/dashboards CRUD). The widget rendering surface (phase 2a) is in flight separately; this PR adds the phase-2b delivery surface so a user can mark a saved dashboard "deliver this weekly to my email" and a background worker materialises a snapshot at the cron tick.

What landed:

  • Migration 076_scheduled_dashboards.sql adds scheduled_dashboards (id, tenant_id, dashboard_id, user_id, cron_expr, channel='email', recipient_email, is_active, last_run_at, next_run_at, last_error, created_at, updated_at). Both dashboard_id and user_id cascade on delete so removing a dashboard or user removes its schedules. The hot-path index is partial on is_active = true so a tenant that disables every schedule keeps its rows out of the worker scan.

  • DTOs in dashboards/models.rs: ScheduledDashboardResponse, CreateScheduledDashboardRequest, UpdateScheduledDashboardRequest. The cron expression is validated via the existing utils::validation::validate_cron; the recipient override is validated as an email.

  • dashboards/service.rs gains schedule_create/list/get/update/delete plus a module-local compute_next_run helper. The create path runs the existing get visibility check first so a user can only schedule their own dashboards (saved dashboards are private by design); mutation methods are owner-scoped via user_id = $3 in the WHERE.

  • New dashboards/worker.rs: ScheduledDashboardsWorker implements Job and ticks every 60s. Each tick locks up to 10 due rows via SELECT ... FOR UPDATE SKIP LOCKED, fetches the dashboard via the visibility-checking service path, renders a text snapshot of the layout JSONB (extracts widget keys from the SPA-owned blob: handles {widgets: [{key}]}, {widgets: ["..."]}, and nested {rows: [{widgets}]} shapes), and INSERTs an email row into notifications. The DispatcherWorker handles the SMTP send + 1m/5m/30m/2h/6h retry backoff. The cadence advances regardless of outcome - a failing render stamps last_error but does NOT stall the next firing.

  • The "materialise" step is intentionally shallow at v1 - the SPA widget render path doesn't exist yet (phase 2a). The snapshot today carries the dashboard name + widget keys; once 2a lands the worker can swap in a richer renderer without touching the schedule machinery.

  • Schedule routes registered on the dashboards router: GET/POST /api/v1/dashboards/{id}/schedules (list / create under the parent), GET/PATCH/DELETE /api/v1/dashboards/schedules/{id} (per-schedule mutation lives on a flat path so a SPA "pause this schedule" PATCH does not need the parent id in the URL).

  • main.rs registers the worker on the shared Scheduler at 60s intervals, mirroring the dispatcher / rmm / sla / calendar-reminder cadence.

Integration tests at tests/scheduled_dashboards.rs:

  • schedule_create_returns_next_run_at: POSTing a schedule returns a row with is_active=true, a populated next_run_at, and the parent dashboard_id.
  • worker_tick_materialises_due_schedule: a back-dated schedule is picked up on the next tick, one notifications row of channel email lands with the widget keys in the body, and the schedule's last_run_at is populated while next_run_at advances with no last_error.
  • worker_tick_skips_disabled_schedule: a schedule with is_active=false is NOT examined by the tick, produces no notification, and is left untouched.

#PMS-471

PMS-453 phase 1 shipped per-user saved dashboards (`saved_dashboards` table + `/api/v1/dashboards` CRUD). The widget rendering surface (phase 2a) is in flight separately; this PR adds the phase-2b delivery surface so a user can mark a saved dashboard "deliver this weekly to my email" and a background worker materialises a snapshot at the cron tick. What landed: * Migration `076_scheduled_dashboards.sql` adds `scheduled_dashboards (id, tenant_id, dashboard_id, user_id, cron_expr, channel='email', recipient_email, is_active, last_run_at, next_run_at, last_error, created_at, updated_at)`. Both `dashboard_id` and `user_id` cascade on delete so removing a dashboard or user removes its schedules. The hot-path index is partial on `is_active = true` so a tenant that disables every schedule keeps its rows out of the worker scan. * DTOs in `dashboards/models.rs`: `ScheduledDashboardResponse`, `CreateScheduledDashboardRequest`, `UpdateScheduledDashboardRequest`. The cron expression is validated via the existing `utils::validation::validate_cron`; the recipient override is validated as an email. * `dashboards/service.rs` gains `schedule_create/list/get/update/delete` plus a module-local `compute_next_run` helper. The create path runs the existing `get` visibility check first so a user can only schedule their own dashboards (saved dashboards are private by design); mutation methods are owner-scoped via `user_id = $3` in the WHERE. * New `dashboards/worker.rs`: `ScheduledDashboardsWorker` implements `Job` and ticks every 60s. Each tick locks up to 10 due rows via `SELECT ... FOR UPDATE SKIP LOCKED`, fetches the dashboard via the visibility-checking service path, renders a text snapshot of the layout JSONB (extracts widget keys from the SPA-owned blob: handles `{widgets: [{key}]}`, `{widgets: ["..."]}`, and nested `{rows: [{widgets}]}` shapes), and INSERTs an `email` row into `notifications`. The DispatcherWorker handles the SMTP send + 1m/5m/30m/2h/6h retry backoff. The cadence advances regardless of outcome - a failing render stamps `last_error` but does NOT stall the next firing. * The "materialise" step is intentionally shallow at v1 - the SPA widget render path doesn't exist yet (phase 2a). The snapshot today carries the dashboard name + widget keys; once 2a lands the worker can swap in a richer renderer without touching the schedule machinery. * Schedule routes registered on the dashboards router: `GET/POST /api/v1/dashboards/{id}/schedules` (list / create under the parent), `GET/PATCH/DELETE /api/v1/dashboards/schedules/{id}` (per-schedule mutation lives on a flat path so a SPA "pause this schedule" PATCH does not need the parent id in the URL). * `main.rs` registers the worker on the shared `Scheduler` at 60s intervals, mirroring the dispatcher / rmm / sla / calendar-reminder cadence. Integration tests at `tests/scheduled_dashboards.rs`: * `schedule_create_returns_next_run_at`: POSTing a schedule returns a row with `is_active=true`, a populated `next_run_at`, and the parent `dashboard_id`. * `worker_tick_materialises_due_schedule`: a back-dated schedule is picked up on the next tick, one `notifications` row of channel `email` lands with the widget keys in the body, and the schedule's `last_run_at` is populated while `next_run_at` advances with no `last_error`. * `worker_tick_skips_disabled_schedule`: a schedule with `is_active=false` is NOT examined by the tick, produces no notification, and is left untouched. #PMS-471
YousifShkara force-pushed feat/PMS-471-scheduled-dashboards from 965f8b46ad
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 1m3s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m54s
Integration / integration tests (pull_request) Successful in 5m48s
to 371c12a495
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 55s
Check / fmt + clippy + build + tests (pull_request) Successful in 3m12s
Integration / integration tests (pull_request) Successful in 11m28s
Create release / Create release from merged PR (pull_request) Successful in 4s
2026-06-24 08:30:21 +02:00
Compare
YousifShkara deleted branch feat/PMS-471-scheduled-dashboards 2026-06-24 08:49:46 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!349
No description provided.