docs(buyer-tasks): design for mail-failed PO recovery in PO Draft tab

Mail-failed POs currently vanish from every buyer-tasks tab because
the po_draft and po_sent filters both exclude the 'mail_failed' status.
This adds a focused design that surfaces them in place inside PO Draft
with a red warning row + 'Resend' button that re-opens the shared
SendPoModal.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 10:25:51 +02:00
parent 4444842ef5
commit fc17b46f9a

View file

@ -0,0 +1,101 @@
# Buyer Tasks: Mail-Failed PO Recovery Design
> **Date:** 2026-08-03
> **Status:** Draft — pending user review
> **Supersedes:** nothing (additive to `2026-07-29-buyer-tasks-design.md`)
## Problem
When the buyer clicks **Send PO** on a draft PO from the buyer-tasks page, the send-email pipeline can fail. The send-email route (`src/app/api/v1/purchase-orders/[id]/send-email/route.ts`) flips the PO to `status: "mail_failed"` and populates `purchase_orders.email_error` with the reason. The buyer-tasks service then drops the group from every tab:
- `po_draft` filter (`src/lib/server/buyer-tasks-service.ts:78`): only `po.status === "draft"`.
- `po_sent` filter (`src/lib/server/buyer-tasks-service.ts:79`): only `po.status in ["sent", "sending"]`.
So `mail_failed` is in the gap between the two filters. The group disappears from the buyer-tasks UI entirely. Today the only way to retry is to open the parent order (`/orders/[id]`), find the PO row, and click **Resend**. This is friction the user has flagged.
## Goal
Keep a mail-failed PO visible in the buyer-tasks **PO Draft** tab, with a clear failure reason and a one-click **Resend** action that re-opens the shared `SendPoModal` (with the last-saved subject, body, and CC pre-filled, matching the order page's Resend behavior).
## Non-goals
- No new collections or schema changes. The `purchase_orders.email_error` field already exists and is already populated by the send-email route.
- No automatic / scheduled retry from the buyer-tasks page. The n8n cron pass (every 15 min) already covers that.
- No changes to the order-page, quotes-page, or stock-orders flows. They already do the right thing.
- No new "Failed" tab. The decision was to surface in place with a clear visual treatment.
## User flow (updated PO Draft step)
1. Operator is on `/orders/tasks` → **PO Draft** tab.
2. Card with a mail-failed PO renders with:
- Existing PO number, order number, supplier name, customer, company, item count, total cost.
- A red warning row: `⚠ Mail failed: <email_error>`.
- Action button label **Resend** (was **Send PO**), styled with the destructive colour token.
3. Operator clicks **Resend**. The shared `SendPoModal` opens, pre-seeded with the PO's last `email_payload` (subject, body, CC, BCC) and the supplier email. The modal title and CTA read "Resend PO" when the underlying status is `mail_failed`.
4. Operator edits (or doesn't) and confirms. The send-email route re-runs. If it succeeds (`status: "sent"` or `status: "sending"`), the group moves to the **PO Sent** tab. If it fails again, the group re-appears in **PO Draft** with the new `email_error` populated.
5. No new toast copy is needed — the existing `SendPoModal` already shows success/failure toasts.
## Changes
### 1. Service filter — `src/lib/server/buyer-tasks-service.ts`
**Before** (line 78):
```ts
if (tab === "po_draft" && po?.status !== "draft") continue;
```
**After**:
```ts
if (tab === "po_draft" && po?.status !== "draft" && po?.status !== "mail_failed") continue;
```
The `po_sent` filter is unchanged — `mail_failed` should not appear there.
### 2. Service data shape — `src/lib/server/buyer-tasks-service.ts`
Add one field to `BuyerTaskGroup`:
```ts
emailError: string | null;
```
Populate it from the related `purchase_orders.email_error` (already expanded via `expand: "...,purchase_order"`). Truncate to ~200 chars at the service boundary so a runaway SMTP error doesn't blow up the card layout; the full reason is still visible in the order page's send-modal banner.
### 3. Card visual — `src/components/buyer-tasks/buyer-task-group-card.tsx`
When `group.poStatus === "mail_failed"`:
- Render a red warning row above `BuyerTaskItemsList` showing `emailError`. Use the existing `bg-destructive/10 text-destructive` token pattern (mirrors how `stock-orders/[id]/page.tsx:64-80` already styles `mail_failed`).
- Change the action button label to **"Resend"** and apply the destructive colour class. The button is still driven by the existing `onAction` callback, so behaviour matches the current `po_draft` branch (open `SendPoModal`).
- The `data-testid` for the action button includes `${group.step}` (e.g. `buyer-task-action-{groupId}-po_draft`). Keep that as-is so existing tests still match; add a separate `data-po-status` attribute on the button so a new test can assert the label/colour change without depending on text.
For `po_draft` groups in normal `draft` status: no change.
### 4. Page wiring — `src/components/buyer-tasks/buyer-tasks-page.tsx`
The `po_draft` branch in `handleAction` already opens `SendPoModal` for any `po_draft` group. The modal's `onSent` already re-fetches the active tab, which re-runs the (now-included) `mail_failed` filter. So no logic change here. The `purchaseOrder` stub we pass already has `id`, `po_number`, and `expand.supplier.email`; verify on the order page that those are sufficient for the modal's lazy PO fetch (the modal reads its own PO via the API). If the modal also reads `status` to drive its title / CTA, we may need to also pass `status: sendPoFor.poStatus` into the stub. Add a guard for that during implementation.
### 5. Tests
**`src/lib/server/buyer-tasks-service.test.ts`** — add:
- `listBuyerTasks("po_draft")` returns a group whose PO has `status: "mail_failed"`.
- The returned group's `emailError` equals the related `purchase_orders.email_error` (truncated to 200 chars).
- `listBuyerTasks("po_sent")` does **not** return a group whose PO has `status: "mail_failed"`.
**`src/components/buyer-tasks/buyer-task-group-card.test.tsx`** — add:
- When `group.poStatus === "mail_failed"`, the card renders the `emailError` text and the action button reads "Resend" with the destructive class.
- When `group.poStatus === "draft"`, the card renders the original "Send PO" label and the amber class.
**`src/app/api/v1/buyer-tasks/route.test.ts`** — add a smoke test that the `tab=po_draft` response includes the mail-failed fixture.
## Risks
- **Tab counts**: the counts shown in `BuyerTaskTabs` are derived from the same service response, so a new `mail_failed` group will now correctly inflate the **PO Draft** count. No double-counting risk because the `po_sent` filter still excludes `mail_failed`.
- **Sort order**: unchanged. The existing sort by `orderNumber` then `supplierName` is applied after grouping, so failed POs interleave with their successful peers in the same order. That's the intended behaviour.
- **SendPoModal pre-fill**: the modal reads `email_payload` from the PO server-side. If `email_payload` is empty on a `mail_failed` PO (e.g. the very first send attempt failed before persisting `email_payload`), the modal will open with empty fields. This is a pre-existing edge case from the order-page Resend path — not introduced by this change. Document but do not fix here.
## Out of scope (deferred)
- Surfacing mail-failed POs in a dedicated "Failed" tab.
- An "edit supplier email inline" path from the buyer-tasks card.
- A "Skip" / "Cancel this PO" action for POs that should never have been sent.