← All stories

BRANCH · ef-086-generated-reports

Generated reports

EF-086 Persona: Organizer Stage: Closeout Roots in: admin-shell-access EF reference: EventFarm doc

The post-event closeout flow. Organizer wants a guest list export, a check-in summary, an attendance report. The reports are async — generated in the background, delivered as signed download URLs, expiring after 30 days. The interesting failures are around the lifecycle: a job stuck queued for 20 minutes, an expired download link, a sweeper that ran early and deleted an artifact someone was about to download, two organizers requesting the same report at the same time.

Happy path

  1. Organizer navigates to the report center.

    From the admin shell, Reports section. Lists the organizer's recent jobs with status pills (queued / running / completed / failed / expired) and timestamps.

  2. Click "Generate new report" and pick a report type from the catalog.

    Catalog shows: Guest list summary (EF-087), Activity log (EF-088), Email deliverability (EF-089), Graphical check-in (EF-090), Specialized ticketing reports (EF-091), and the in-scope-for-this-branch generic reports (attendance, registration timeline, sponsor lead export, ticket-block utilization).

  3. Configure the report.

    Modal asks for parameters specific to the report type: event, date range, format (CSV / PDF), recipients for email notification. Submit.

  4. Job is queued.

    UI immediately shows the new job in the list with status "queued." A subtle progress indicator. The organizer can leave the page; the job continues in the background.

  5. Job completes (async, typically 5-60 seconds).

    When the organizer returns to the page, status pill is "completed" with a download button. If they configured email notification, an email arrives via Cloudflare Email Sending (EF-051) with a signed download link.

  6. Click download.

    Signed URL fetches the artifact from R2. Browser starts the download. The job's "last downloaded" timestamp updates server-side for audit.

Failure modes

Job stuck queued — never starts running

Trigger: queue worker is degraded; job sits in "queued" for 5+ minutes.

UI surfaces a "Job is taking longer than usual. We're investigating." note next to the queued status pill after 5 minutes. Does NOT silently let the job linger. After 15 minutes, the job is auto-marked "failed" with a stuck-queue diagnostic AND an automatic retry is queued. Organizer is not surprised by a job that runs hours later.

Recovery: Auto-retry. Organizer can also manually re-trigger.

Job runs but fails mid-execution

Trigger: query times out (large dataset), or R2 write fails, or the report-runner crashes mid-generation.

Status pill changes to "failed" with a failure-reason field visible. Common reasons surfaced as user-readable: "Dataset too large — try a narrower date range." or "Storage temporarily unavailable — please retry." with a "Retry" button. Internal stack trace is logged but NOT shown to the organizer. Failed jobs do NOT count against any quota.

Recovery: Retry button (re-queues with same params), or adjust params and create new.

Signed download URL expired

Trigger: organizer clicks a download link from an email sent 31 days ago. The URL was signed with a 7-day TTL; the token is invalid.

Click resolves to a Voyage page (NOT a raw R2 403): "This download link expired on <date>. Generate a new download link." with a button that re-signs a fresh URL if the artifact is still in R2 (within the 30-day retention) OR re-runs the report if the artifact has been swept.

Recovery: Generate fresh link; re-run if sweeper deleted.

Artifact swept while organizer was preparing to download

Trigger: report job completed 30 days ago. The 30-day sweeper ran 5 minutes ago and marked the artifact expired + deleted from R2. Organizer tries to download.

Status pill changes from "completed" to "expired" — visible in the report-job list, so the organizer knows this state exists. Click resolves to: "This report's artifacts were removed after 30 days. Generate a new one." Same content as the expired-URL flow, but the trigger is different. Both lead to the same recovery path: re-run the report.

Recovery: Re-run with the same parameters.

Two organizers request the same report simultaneously

Trigger: organizer A and B both request "guest list summary for Event X" within the same minute.

Server creates two distinct job rows, each with its own job_id, owner, parameters. They run independently and produce independent artifacts. Both organizers see "their" job in the list. The system does NOT dedup these — two organizers might be working on different goals (A wants the CSV for finance, B wants the PDF for the board); silently merging them would surprise B.

Recovery: Both succeed independently.

Large dataset that exceeds artifact size limits

Trigger: report query yields 500,000 rows; CSV would be 200MB.

Server-side limit (configurable, default 50MB per artifact) — when exceeded, the job marks itself as "failed" with reason "Dataset exceeded 50MB. Try a narrower date range or generate per-month reports." UI surfaces the reason with a deep-link to a parameter-narrowed re-run. The job is NOT silently truncated — that would lie to the organizer about completeness.

Recovery: Narrow the parameters; re-run.

Email notification bounced

Trigger: organizer configured email notification for the report. The recipient email bounced (per EF-089).

Job's UI status pill remains "completed" — the report itself succeeded. A secondary indicator on the job row shows "email notification bounced" with a "resend" affordance. The organizer can fix the email address (typo) or pick a different recipient and resend.

Recovery: Fix recipient + resend.

Notification email sent before artifact is uploaded to R2

Trigger: ordering bug — the notification email is queued before R2 upload completes. Organizer clicks the link in the email; signed URL exists but R2 returns 404.

Server-side ordering: notification is queued ONLY after R2 upload confirms. If the link 404s anyway (race window), the user-visible page shows: "Your report is still being prepared — please check back in a moment." with auto-refresh. Does NOT show "expired" or "not found" — those are different states.

Recovery: Auto-refresh resolves within seconds.

Permission revoked between job creation and completion

Trigger: organizer requested a report. Their workspace role is downgraded before completion. Job runs, completes, but the user no longer has report-read permission.

Job's artifact is NOT deleted (other admins might still need it). Click on the download link: "You don't have permission to access this report. Contact your workspace admin." Distinct UX from "expired" or "failed" — the report itself is fine; the user's access is the issue.

Recovery: Out-of-band — request access reinstated.

Organizer cancels a running job

Trigger: organizer realized they configured wrong parameters; clicks "Cancel" on a running job.

Cancel sends a stop signal to the runner. Status pill transitions to "cancelled" within ~5 seconds. Any partial R2 artifact is cleaned up. If the job is too far along (e.g., final upload step), the cancel may not interrupt it; UI states "Cancellation requested — may complete anyway." Organizer is not lied to about cancellability.

Recovery: Cancellation succeeds or completes — both outcomes are explicit.

Catalog of report types is filtered by organizer's permissions

Trigger: organizer has read-only access; they should see report types they can READ but not the admin-only ones (e.g., audit log).

Catalog is server-side filtered. Organizer doesn't see admin-only types in the picker. Direct deep-link to an admin-only type (e.g., /admin/reports/new?type=audit-log) shows a permission-required page with a Request access affordance. Does NOT just hide the option silently — surfaces the existence with a clear gate.

Recovery: Request access to the missing report type.

Edge cases

Re-run a previous report with same parameters

Each job row has a "Re-run with same parameters" affordance. Creates a new job (new job_id, new artifact). Doesn't overwrite the existing row.

Download the same report multiple times

Same signed URL works for repeated downloads within its TTL. Server tracks download_count for audit but doesn't restrict.

Job survives organizer logout / login

Jobs are persisted server-side. Organizer logs out, comes back tomorrow, the job is in their list with the appropriate status (running, completed, etc.).

Large list view with hundreds of jobs

Report center jobs list is paginated (page/limit/total/hasMore per the platform's pagination rule). Default 25 per page, sortable by created date / status / type.

Page evaluation

SurfaceDiscoverabilityError UXLayoutOrientation
Report center · jobs list "Generate new report" CTA visible without scrolling. Recent jobs as the primary content. Per-job error states inline. Failed jobs render with the reason text and Retry button. Table-like list. Status pill, type, params, created, updated, download, retry/cancel actions per row. Page H1 "Reports". Pagination at bottom.
New report modal Catalog of report types as cards. Clicking a card opens parameter form for that type. Permission-gated types are absent from the catalog (server-filtered). ui-modal with two-step UX: type picker → parameter form. Modal title "Generate report" → "<Type name> report — parameters" on type select.
Job row · running state Progress indicator visible. Cancel button visible. If progress stalls past 5 min, "taking longer than usual" note appears. Row layout reflows: progress bar replaces download button while running. Status pill is "running" with a subtle animation.
Job row · failed state Failure reason inline. Retry button visible. Reason is user-readable, not a stack trace. Row layout shows the reason in place of the download. Status pill is "failed" with a non-alarming color (warm orange, not stop-light red).
Expired-link landing Re-generate CTA is the primary action. Tone: explanatory, not blame-shifting. Centered card max-width 640px. Page H1 "Download link expired" with the original report title visible.

Acceptance signals

  • POST /v1/admin/events/:eventId/reports/jobs returns 201 with a job_id.
  • D1 row exists in event_report_jobs with the submitted type + params + organizer's id as requested_by.
  • Job list endpoint is paginated (page/limit/total/hasMore).
  • Completed job has at least one row in event_report_job_artifacts with a non-null r2_key.
  • Signed download URL is keyed off REPORT_DOWNLOAD_SIGNING_SECRET and includes a TTL parameter.
  • Notification email queued via notification_outbox (per EF-051) when notify_emails was provided.
  • Sweeper marks 30+ day artifacts as expired AND deletes the R2 object.
  • document.title for the report center matches "Reports · <workspace>".
  • No console errors at severity ≥ warn.

Stable test attributes

Branch-specific only; trunk and component attributes inherited.

Visibility teeth. Each attribute must be present AND effectively visible when the relevant state is active.

data-testWherePurpose
report-centerPage /admin/events/:id/reportsRoot container of the report center
report-center-h1Inside report-centerPage H1 "Reports"
generate-report-ctaTop of report-center"Generate new report" button
report-jobs-listInside report-centerList of job rows
report-job-rowInside report-jobs-listEach job (multiple instances)
report-job-status-pillInside report-job-rowqueued / running / completed / failed / expired / cancelled
report-job-typeInside report-job-rowType name + params summary
report-job-created-atInside report-job-rowTimestamp
report-job-download-ctaInside report-job-row · completed stateDownload artifact (signed URL)
report-job-retry-ctaInside report-job-row · failed stateRe-queue with same params
report-job-rerun-ctaInside report-job-row · any terminal stateRe-run with same params (creates new job)
report-job-cancel-ctaInside report-job-row · queued/running stateCancel job
report-job-failure-reasonInside report-job-row · failed stateUser-readable reason text
report-job-stuck-noteInside report-job-row · running state > 5min"Taking longer than usual" note
report-job-bounce-indicatorInside report-job-row · email-bounced stateBounce notice + resend affordance
report-jobs-paginationBottom of report-centerPage/limit/total controls
generate-report-modalModal portal (uses ui-modal)Generate-report modal
report-type-catalogInside generate-report-modalPicker showing available types
report-type-cardInside report-type-catalogEach type (multiple instances)
report-params-formInside generate-report-modal · post-type-selectParameter form (uses ui-form)
report-params-formatInside report-params-formCSV/PDF format chooser (uses ui-checkbox)
report-params-recipientsInside report-params-formEmail recipients for notification
report-submit-ctaInside report-params-formSubmit (uses ui-form-submit)
expired-download-pagePage from clicking an expired link"Download link expired" surface
expired-download-regenerate-ctaInside expired-download-pageRe-generate signed URL or re-run report
permission-required-pagePage after deep-link to admin-only typePermission gate
permission-required-request-access-ctaInside permission-required-pageRequest access

Agent test plan

Probe list
- report-center-renders: navigate to /admin/events/${fixture.eventId}/reports, assert report-center visible AND generate-report-cta interactive
- report-jobs-paginated: assert report-jobs-pagination visible AND ui-form pagination response shape
- generate-report-flow: click generate-report-cta, pick a type, fill params, submit, assert API POST returns 201 AND new report-job-row visible with status pill = "queued"
- job-completes: stub job runner to complete in 2s, assert status pill transitions to "completed" AND report-job-download-cta visible
- download-with-signed-url: click report-job-download-cta, assert signed URL fetched (path matches /v1/admin/.../reports/jobs/[a-z0-9]+/download)
- job-stuck-note: stub job to remain queued, advance clock 6 minutes, assert report-job-stuck-note visible
- job-failed-with-reason: stub job to fail with reason "Dataset too large", assert report-job-status-pill = "failed" AND report-job-failure-reason visible AND report-job-retry-cta visible
- job-retry-creates-new-job: click retry, assert API POST creates new job with same params
- expired-link-page: navigate to a deliberately-expired signed URL, assert expired-download-page visible AND expired-download-regenerate-cta visible
- expired-link-regenerate: click regenerate, assert new signed URL produced AND download initiates
- swept-artifact-distinct-state: stub artifact deletion, click download on a 31-day-old job, assert expired (not "not found") UI
- two-organizers-distinct-jobs: simulate two parallel POSTs from different users, assert two distinct job rows
- large-dataset-failure: stub job to fail with "exceeded 50MB" reason, assert report-job-failure-reason text matches "narrower date range"
- email-bounce-indicator: stub email-event for the notification to bounce, assert report-job-bounce-indicator visible on the job row
- cancel-running-job: click cancel on a running job, assert status transitions to "cancelled" within 5s
- cancel-too-late: cancel a job that completes during cancellation, assert UI states "Cancellation requested — may complete anyway" OR final status reflects completion
- permission-gated-catalog: log in as read-only user, assert audit-log type absent from report-type-catalog
- permission-deep-link: navigate directly to /admin/reports/new?type=audit-log as read-only user, assert permission-required-page visible
- list-survives-logout: create job, log out, log back in, assert job still in list with current status