diff --git a/docs/superpowers/specs/2026-08-03-buyer-tasks-mail-failed-recovery-design.md b/docs/superpowers/specs/2026-08-03-buyer-tasks-mail-failed-recovery-design.md new file mode 100644 index 0000000..6bda97e --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-buyer-tasks-mail-failed-recovery-design.md @@ -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: `. + - 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.