Skip to content

hir-94: per-recipient queue table on campaign detail#21

Open
jaredzwick wants to merge 2 commits into
pypesdev:mainfrom
jaredzwick:hir-94/campaign-queue-detail
Open

hir-94: per-recipient queue table on campaign detail#21
jaredzwick wants to merge 2 commits into
pypesdev:mainfrom
jaredzwick:hir-94/campaign-queue-detail

Conversation

@jaredzwick

Copy link
Copy Markdown
Collaborator

The campaign detail page surfaced aggregate counts but a user could
not see which specific recipients were pending, sent, failed, or
bounced. The most common post-create question — "did my emails
actually go out?" — required a database query to answer.

  • GET /api/campaigns/:id/queue: paginated per-recipient queue rows.
    Owner-checked (loads the campaign first to verify userId), then
    delegates to the existing getQueueEntriesByCampaign helper. Limit
    defaults 100, max 500; offset honored. Returns the columns a UI
    table needs (status / attempts / scheduled / sent / errorMessage)
    and omits raw send_log internals.
  • /dashboard/campaigns/:id detail page: new "Recipients" section
    renders the queue rows in a table with status badge, attempts
    N/M, last-activity relative time, and a one-line error summary.
  • src/lib/queueRowSummary.ts: pure helpers (queue-status badge
    label/tone, summarizeError that trims to a single line + ellipsis,
    formatRelativeTime that takes a fixed now for testability).

29 vitest specs in tests/int/queueRowSummary.int.spec.ts cover every
known + unknown queue status, error truncation (empty / multi-line /
CRLF / over-max with rounded ellipsis / trailing-whitespace strip /
custom max), and relative-time formatting (just-now band, past +
future across m/h/d/w with rounding, ISO string input, NaN/null
handling).

Stacked on hir-94/campaign-list-detail (PR-pending). No schema, no
migrations, no send-pipeline changes. tsc clean. test:int 139
passed (only pre-existing PAYLOAD_SECRET api.int.spec.ts fails).

Co-Authored-By: Paperclip noreply@paperclip.ing

Note: built on top of #19 (campaign-list-detail). Will rebase / become diffable on its own once #19 lands.

🤖 Generated with Claude Code

jaredzwick and others added 2 commits May 3, 2026 02:14
A user could create a campaign but had no way to see it afterward —
no listing, no detail view, no status, no sent/open/click counts. The
GET /api/campaigns and GET /api/campaigns/:id endpoints already
existed; only the UI was missing.

- /dashboard/campaigns: lists campaigns with status badge, sent
  progress, reply count, created date. Empty-state CTA links to
  /dashboard/campaigns/new.
- /dashboard/campaigns/[id]: shows status, send progress, opens /
  clicks / replies / bounces / unsubscribes / total recipients, plus
  optional queue stats (pending / sent / failed / bounced) when the
  API returns them. Includes a delete action with confirm prompt.
- /dashboard/campaigns/new: on successful POST, router.push to the
  new campaign's detail page so the user lands somewhere meaningful.
- src/lib/campaignStatus.ts: pure helpers (status badge label/tone,
  sent progress formatter with clamp + percent rounding) so render
  logic is unit-testable without mounting React.

15 vitest specs in tests/int/campaignStatus.int.spec.ts cover all
known + unknown statuses, null/undefined/empty inputs, and the
formatter's clamp / NaN / zero-total / rounding paths.

No API route, schema, migration, or send-pipeline changes. tsc clean
(error count went from 10 pre-existing on main to 0 — earlier merged
PRs cleared them). Full test:int matches main baseline (only
api.int.spec.ts still fails on missing PAYLOAD_SECRET, pre-existing).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
The campaign detail page surfaced aggregate counts but a user could
not see which specific recipients were pending, sent, failed, or
bounced. The most common post-create question — "did my emails
actually go out?" — required a database query to answer.

- GET /api/campaigns/:id/queue: paginated per-recipient queue rows.
  Owner-checked (loads the campaign first to verify userId), then
  delegates to the existing getQueueEntriesByCampaign helper. Limit
  defaults 100, max 500; offset honored. Returns the columns a UI
  table needs (status / attempts / scheduled / sent / errorMessage)
  and omits raw send_log internals.
- /dashboard/campaigns/:id detail page: new "Recipients" section
  renders the queue rows in a table with status badge, attempts
  N/M, last-activity relative time, and a one-line error summary.
- src/lib/queueRowSummary.ts: pure helpers (queue-status badge
  label/tone, summarizeError that trims to a single line + ellipsis,
  formatRelativeTime that takes a fixed `now` for testability).

29 vitest specs in tests/int/queueRowSummary.int.spec.ts cover every
known + unknown queue status, error truncation (empty / multi-line /
CRLF / over-max with rounded ellipsis / trailing-whitespace strip /
custom max), and relative-time formatting (just-now band, past +
future across m/h/d/w with rounding, ISO string input, NaN/null
handling).

Stacked on hir-94/campaign-list-detail (PR-pending). No schema, no
migrations, no send-pipeline changes. tsc clean. test:int 139
passed (only pre-existing PAYLOAD_SECRET api.int.spec.ts fails).

Co-Authored-By: Paperclip <noreply@paperclip.ing>

@jaredzwick jaredzwick left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CTO Review — LGTM

Solid feature addition. Three layers all consistent.

API layer (/api/campaigns/[id]/queue):

  • Ownership check is correct and ordered correctly: fetch campaign first, then check userId. A missing campaign 404s before we even look at the requester — no timing oracle.
  • clamp(parseInt(...), 1, 500) with Number.isNaN guard handles non-numeric inputs safely. Max 500 is reasonable for a UI table.
  • Explicit field allowlist in the response map — doesn't accidentally surface internal fields if the DB schema grows.

UI layer (campaigns list + detail pages):

  • cancelled = true cleanup in both useEffect hooks prevents stale-closure state updates on unmount — done correctly.
  • Error objects carry .status through the promise chain for 404 vs. 5xx differentiation in the render.
  • After successful campaign creation, router.push to the detail page — good UX, avoids the "success but now what?" dead end.

Helper libraries:

  • summarizeError: raw.split(/\r?\n/, 1)[0] correctly handles both CRLF and LF line endings; the ?? '' is safe.
  • formatRelativeTime: pure, no Intl.RelativeTimeFormat dependency (important for SSR/test environments), handles null/invalid dates gracefully.
  • describeCampaignStatus / describeQueueStatus: sealed lookup tables with in guard + capitalize fallback — future-proof without being over-engineered.

No concerns. Ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant