feat/admin #2

Merged
admin merged 10 commits from feat/admin into main 2026-03-07 20:51:13 +00:00
5 changed files with 918 additions and 4 deletions
Showing only changes of commit 83a5172397 - Show all commits

View File

@@ -1,4 +0,0 @@
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
CLERK_WEBHOOK_SECRET=whsec_...

View File

@@ -0,0 +1,236 @@
# Order Processing Backend — Phased Implementation Plan
**Audience:** Senior software engineers
**Scope:** Backend only (Convex, Stripe, Shippo). No UI.
**Source:** [Admin Order Processing User Stories](docs/project-documentation/storefront/admin-order-processing-user-stories.md).
---
## 1. Executive summary
This plan breaks the order-processing backend into four phases:
| Phase | Focus | Key deliverables |
|-------|--------|-------------------|
| **1** | Label creation | Resolve rate from shipment, POST Shippo transaction, persist label data, status → `processing`, timeline |
| **2** | Shippo Track Updated webhook | HTTP route, verify & parse, update order + timeline, set `delivered` on DELIVERED |
| **3** | Return/cancel only when confirmed | Enforce `status === "confirmed"` for customer return/cancel; optional schema for `labelUrl` |
| **4** | Refund idempotency & polish | Idempotent Stripe refund, optional tracking refresh action, tests |
**Already in place (no work in this plan):**
- **Schema:** `orders` (including `shippoShipmentId`, `shippingServiceCode`, `carrier`, `trackingNumber`, `trackingUrl`, `estimatedDelivery`, `actualDelivery`, `shippedAt`, `returnRequestedAt`, `returnReceivedAt`); `orderTimelineEvents` with `by_order` and `by_order_and_created_at`.
- **Model:** `recordOrderTimelineEvent` in `convex/model/orders.ts`.
- **Queries:** `orders.getTimeline` (timeline by order).
- **Orders:** `fulfillFromCheckout` (stores Shippo shipment ID and rate identifiers); `updateStatus` (admin) with timeline; `cancel` (customer, only when allowed) with timeline and stock restore; `requestReturn`, `markReturnReceived`, `getOrderForRefund`, `applyRefund`.
- **Refunds:** `returnActions.issueRefund` (admin, Stripe refund + `applyRefund`).
- **HTTP:** Clerk and Stripe webhooks in `convex/http.ts`.
**Resources:** Convex MCP, Stripe MCP, Shippo MCP (e.g. `shipments-get`, `transactions-create`, `tracking-status-get`, `webhooks-create`).
---
## 2. Phase 1 — Label creation (US-ADM-1, US-ADM-2)
**Goal:** Admin can create a shipping label for an order in status `confirmed`. On success, persist label data, set status to `processing` (or `shipped`), and record a timeline event. Enforce valid transitions and prevent duplicate labels.
### 2.1 Tasks
1. **Schema (optional)**
- Add `labelUrl: v.optional(v.string())` to `orders` if the team wants to store the label PDF/PNG URL from Shippo.
- If not stored, admin can open `tracking_url_provider` or use Shippo dashboard for the label.
2. **Shippo: GET shipment and resolve rate**
- In `convex/model/shippo.ts` (or a new internal helper used only by the label action):
- Add a function that calls Shippo **GET** `https://api.goshippo.com/shipments/{order.shippoShipmentId}` (or use Shippo MCP `shipments-get` from an action).
- From the response `rates` array, find the rate where `rate.servicelevel.token === order.shippingServiceCode` and `rate.provider === order.carrier`.
- Return that rates `object_id`.
- Handle: shipment not found, no matching rate, or multiple matches (pick first or fail explicitly).
3. **Shippo: Create transaction (purchase label)**
- Add a Convex **action** (e.g. in `convex/fulfillmentActions.ts` or `convex/orders.ts` with `"use node";` in a separate file if needed) that:
- Takes `orderId: v.id("orders")`.
- Runs with admin auth (e.g. resolve user via identity, then `requireAdmin`-style check via internal query).
- Loads order (internal query); validates `order.status === "confirmed"` and that the order has no `trackingNumber` yet (no label already created).
- Calls the new “resolve rate” helper to get the rate object ID from `order.shippoShipmentId`, `order.shippingServiceCode`, `order.carrier`.
- Calls Shippo **POST** `/transactions` with `{ rate: rateObjectId }` (and `async: true` for international if applicable).
- If Shippo returns async (e.g. status `QUEUED`), poll **GET** transaction until `SUCCESS` or `ERROR` (or schedule a follow-up action to poll).
- On success: extract `tracking_number`, `tracking_url_provider`, and optionally `label_url` from the transaction response.
4. **Persist label and update order**
- From the same action, call an **internal mutation** that:
- Receives `orderId`, `trackingNumber`, `trackingUrl` (from `tracking_url_provider`), optional `labelUrl`, and optional `eta` if Shippo provides it.
- Validates again that order is `confirmed` and has no existing `trackingNumber` (idempotency / duplicate-label guard).
- Patches order: `trackingNumber`, `trackingUrl`, `labelUrl` (if schema added), `estimatedDelivery` (if provided), `shippedAt: Date.now()`, `status: "processing"` (or `"shipped"` per team decision), `updatedAt: Date.now()`.
- Calls `recordOrderTimelineEvent` with `eventType: "label_created"`, `source: "admin"`, `fromStatus: "confirmed"`, `toStatus: "processing"` (or `"shipped"`), and optional `payload` (e.g. JSON with tracking number and carrier).
- On Shippo failure (rate expired, address issue, etc.): return a structured error (e.g. `{ success: false, code: "RATE_EXPIRED" | "SHIPPO_ERROR", message: string }`) so the client can show a clear message.
5. **Public API**
- Expose one public (or admin-only) **action** that orchestrates: resolve rate → create transaction → persist + timeline.
- Ensure only admins can call it (e.g. resolve user and check role in action, or call from a mutation that already enforces admin).
### 2.2 Acceptance (backend)
- Only orders in status `confirmed` and without an existing `trackingNumber` can receive a label.
- After a successful label creation, order has `trackingNumber`, `trackingUrl`, `shippedAt`, and status `processing` (or `shipped`); one `label_created` timeline event exists.
- Duplicate label creation for the same order is rejected.
- Rate resolution uses `shippoShipmentId` + `shippingServiceCode` + `carrier`; expired or missing rate returns a clear error.
### 2.3 Dependencies and references
- Shippo: [Transactions](https://docs.goshippo.com/docs/guides_general/transactions/) (POST with rate ID).
- Shippo MCP: `shipments-get` (ShipmentId = `order.shippoShipmentId`), `transactions-create` (request `{ rate: rateObjectId }`).
- Existing: `convex/model/shippo.ts` (GET shipment not present today; add or call from action via fetch/MCP).
---
## 3. Phase 2 — Shippo Track Updated webhook (US-ADM-3)
**Goal:** Receive Shippo “Track Updated” webhooks, verify and parse payload, update the order (tracking status, ETA, and on DELIVERED set `status: "delivered"`, `actualDelivery`), and append a `tracking_update` event to `orderTimelineEvents`. No manual refresh required for live tracking.
### 3.1 Tasks
1. **HTTP route**
- In `convex/http.ts`, add a route, e.g. `POST /shippo/webhook`.
- Handler: `httpAction` that reads body, optionally verifies Shippo signature (if Shippo supports it; check [Shippo webhooks](https://docs.goshippo.com/docs/tracking/tracking)), then calls an internal action with the raw body (or parsed JSON).
2. **Webhook handler action**
- Implement an **internal action** (e.g. `internal.fulfillmentActions.handleShippoTrackUpdated` or `internal.shippoWebhook.handleTrackUpdated`) that:
- Parses the JSON payload (carrier, tracking_number, tracking_status, status_details, location, eta, etc.).
- Finds the order: query by `trackingNumber` + `carrier`. Add an index on `orders` e.g. `by_tracking_number_and_carrier: ["trackingNumber", "carrier"]` for efficient lookup (only orders with a label have these set).
- If no order found, return 200 anyway (avoid retries for bad data) and log.
- If order found: call an **internal mutation** that:
- Updates the order: set latest tracking state (e.g. store a minimal `trackingStatus` / `trackingStatusDetails` on `orders` if desired for display; otherwise derive from timeline).
- Sets `estimatedDelivery` from webhook `eta` if provided.
- If tracking status is **DELIVERED**: set `order.status = "delivered"`, `actualDelivery = Date.now()`, `updatedAt = Date.now()`.
- Inserts one `orderTimelineEvents` row: `eventType: "tracking_update"`, `source: "shippo_webhook"`, `payload: JSON.stringify(webhookPayload)`, `createdAt: Date.now()`.
3. **Idempotency / duplicates**
- Shippo may send duplicate events. Options: (a) store last processed `tracking_status` + timestamp on the order and skip if same status already applied; (b) or accept duplicate timeline events and treat UI as “last event wins” for status. Prefer (a) if you want a clean timeline.
4. **Shippo webhook registration**
- Document or script: register in Shippo dashboard (or via Shippo MCP `webhooks-create`) a webhook with `event: "track_updated"` and URL `https://<deployment>.convex.site/shippo/webhook`. Use production URL and live API key for live tracking.
### 3.2 Optional: “Refresh tracking” action
- Add an **action** (admin-only) that takes `orderId`, loads order, then calls Shippo **GET** tracking (e.g. `tracks/{carrier}/{tracking_number}` or Shippo MCP `tracking-status-get`) and then updates the order + appends a `tracking_update` timeline event from the response. Use for debugging or one-off backfills; primary source remains the webhook.
### 3.3 Acceptance (backend)
- POST to `/shippo/webhook` with a valid Track Updated payload updates the correct order and appends a `tracking_update` event.
- When status is DELIVERED, order `status` becomes `delivered` and `actualDelivery` is set.
- Unknown tracking numbers or missing orders do not cause 5xx (return 200 and log).
### 3.4 Dependencies and references
- Shippo: [Track live shipments](https://docs.goshippo.com/docs/tracking/tracking), webhook payload shape.
- Shippo MCP: `webhooks-create` (event `track_updated`, url = Convex HTTP URL); `tracking-status-get` for optional refresh.
---
## 4. Phase 3 — Return / cancel only when confirmed (US-ADM-4)
**Goal:** Enforce in the backend that customer-initiated return and cancel are allowed **only** when `order.status === "confirmed"`. Align `canCustomerRequestReturn` (or equivalent) with this rule so that once the order moves to `processing`, `shipped`, or `delivered`, return/cancel from the storefront flow is rejected.
### 4.1 Tasks
1. **Cancel (already aligned)**
- `orders.cancel` already uses `canCustomerCancel(order)` which allows only `confirmed`. No change required unless you want to explicitly document or add a test that only `confirmed` is allowed.
2. **Return request**
- Today `canCustomerRequestReturn` allows return only when `order.status === "delivered"`. Per user stories, **return is only allowed while status is confirmed**.
- Update `canCustomerRequestReturn` in `convex/model/orders.ts` so that it returns `allowed: true` only when `order.status === "confirmed"` (and optionally not already cancelled/refunded/return requested).
- Adjust `orders.requestReturn` so that it uses this updated helper and rejects when not allowed.
- If the product decision is “return when confirmed = cancel”, you may keep a single cancel flow and treat “return” as cancel when confirmed; otherwise keep `requestReturn` for a distinct “return requested” state that stays in `confirmed` until admin processes it.
3. **Validation in mutations**
- Ensure any customer-facing mutation that cancels or requests a return checks the same rule: `status === "confirmed"`. Add an explicit guard at the start of `cancel` and `requestReturn` (e.g. “only confirmed orders can be cancelled/returned”) so the contract is clear.
4. **Timeline**
- When a return is requested (while confirmed), keep recording the existing `return_requested` (or equivalent) timeline event with `source: "customer_return"`.
### 4.2 Acceptance (backend)
- Customer cancel and customer return request succeed only when `order.status === "confirmed"`.
- For `processing`, `shipped`, or `delivered`, both flows return a clear error (e.g. “Return/cancel not available; contact support”).
---
## 5. Phase 4 — Refund idempotency and polish (US-ADM-5)
**Goal:** Make refunds idempotent (no double Stripe refund if admin clicks twice), and add optional tracking refresh and tests.
### 5.1 Tasks
1. **Idempotent Stripe refund**
- In `returnActions.issueRefund` (or equivalent): before calling `stripe.refunds.create`, list existing refunds for the payment intent (e.g. `stripe.refunds.list({ payment_intent: order.stripePaymentIntentId })`).
- If a refund already exists for the full amount (or the intended amount), skip creating a new one and still call `internal.orders.applyRefund` only if the order is not yet `refunded` (so order and payment status are updated once).
- If partial refunds are in scope, define the rule (e.g. “only one full refund” or “sum of refunds < total”) and implement accordingly.
2. **Order guard in applyRefund**
- In `orders.applyRefund`, at the start: if `order.paymentStatus === "refunded"` (and optionally `order.status === "refunded"`), skip patching and timeline (idempotent). This protects against double application when the action is called twice.
3. **Optional: Tracking refresh action**
- Implement the optional “Refresh tracking” action described in Phase 2 (GET Shippo tracking by carrier + tracking number, update order and append one `tracking_update` event). Secure with admin-only check.
4. **Tests**
- Unit/integration tests for: (a) label creation — only `confirmed`, no duplicate label; (b) webhook handler — find order by tracking + carrier, set delivered on DELIVERED; (c) return/cancel only when confirmed; (d) refund idempotency (second call does not create a second Stripe refund, order updated once).
### 5.2 Acceptance (backend)
- Issuing refund twice for the same order does not create two Stripe refunds and does not double-restore stock or double-write timeline.
- Refund flow is documented (full vs partial, idempotency rule).
---
## 6. Implementation order and dependencies
```
Phase 1 (Label creation) → no dependency on Phase 2/3/4
Phase 2 (Track webhook) → depends on orders having trackingNumber (from Phase 1 or manual)
Phase 3 (Return when confirmed) → independent; can run in parallel with 1 or 2
Phase 4 (Refund idempotency) → depends on existing refund flow; can run after or in parallel with 3
```
Recommended sequence: **Phase 1 → Phase 2 → Phase 3 → Phase 4**, with Phase 3 and 4 swappable.
---
## 7. File and module checklist
| Area | File / module | Changes |
|------|----------------|---------|
| Schema | `convex/schema.ts` | Optional: `orders.labelUrl`; optional: `orders.trackingStatus` / `trackingStatusDetails`; index `by_tracking_number_and_carrier` on `["trackingNumber", "carrier"]` for webhook lookup. |
| Model | `convex/model/orders.ts` | `canCustomerRequestReturn`: allow only `confirmed`; keep `recordOrderTimelineEvent`. |
| Model | `convex/model/shippo.ts` | New: get shipment by ID; resolve rate by `shippingServiceCode` + `carrier`. |
| Orders | `convex/orders.ts` | Optional: internal mutation for “apply label data + status + timeline”; guards in `requestReturn` / cancel. |
| Actions | New or existing (e.g. `convex/fulfillmentActions.ts`) | Label flow: resolve rate → POST transaction → internal mutation; optional: refresh tracking action. |
| Refunds | `convex/returnActions.ts` | Idempotent refund: list refunds, then create or skip; call `applyRefund` only when order not already refunded. |
| Refunds | `convex/orders.ts` (`applyRefund`) | Idempotent: skip if already `paymentStatus === "refunded"`. |
| HTTP | `convex/http.ts` | New route `POST /shippo/webhook` → internal action. |
| Webhook handler | New or existing (e.g. `convex/shippoWebhook.ts`) | Parse Track Updated, find order, internal mutation to update order + insert `tracking_update` event; on DELIVERED set `delivered` and `actualDelivery`. |
| Tests | `convex/orders.test.ts`, `convex/fulfillmentActions.test.ts`, etc. | Label creation rules, webhook handler, return/cancel guards, refund idempotency. |
---
## 8. Environment and configuration
- **Convex:** No new env vars required for core flow.
- **Shippo:** `SHIPPO_API_KEY` already used; ensure production webhook URL and (if applicable) webhook secret are configured for Track Updated.
- **Stripe:** Existing keys; no new config for refunds.
---
## 9. Story-to-phase mapping
| Story | Phase | Backend work |
|-------|--------|----------------|
| US-ADM-1 Create shipping label | 1 | Resolve rate, POST transaction, persist label, status, timeline |
| US-ADM-2 Order status when label printed | 1 | Same mutation sets status to `processing` (or `shipped`) |
| US-ADM-3 View tracking (live via webhook) | 2 | Shippo webhook handler; optional refresh action |
| US-ADM-4 Return only when confirmed | 3 | `canCustomerRequestReturn` + guards in cancel/requestReturn |
| US-ADM-5 Refund after successful return | 4 | Idempotent Stripe refund + idempotent `applyRefund` |
---
*Document version: 1.0. No UI work included; UI can consume the new/updated queries and actions as needed.*

View File

@@ -0,0 +1,488 @@
# Orders UI — Design Plan (Admin Dashboard)
**Audience:** Senior software engineers
**Scope:** Orders UI only — list page, detail page, all admin actions. No new backend work.
**Backend reference:** `06-order-processing-backend-implementation-plan.md`
**Design constraint:** Match the existing admin design language exactly (ShadCN only, HugeIcons, same layout shell, same patterns as Products page).
---
## 1. Design philosophy
Three principles guide every decision here:
1. **Single responsibility per component.** Each component owns one concern: a card, a table, a button, a timeline. No component manages both data display and action side-effects unless it is a leaf action component.
2. **Status-driven UI.** The order's `status` field is the single source of truth. What you see, what buttons appear, what badges render — all computed from status. No hidden state, no guessing.
3. **Consistency over novelty.** Use the exact same patterns already in the codebase: `rounded-lg border` for table wrappers, `text-xl font-semibold` headings, `usePaginatedQuery` + "Load more", `AlertDialog` for destructive actions, `toast` (Sonner) for feedback.
---
## 2. Route structure
```
/orders → OrdersPage (list)
/orders/[id] → OrderDetailPage (detail)
```
The detail page is a full Next.js route (`app/(dashboard)/orders/[id]/page.tsx`), not a dialog. Orders are complex enough to deserve their own page; a dialog would feel cramped.
---
## 3. Status vocabulary
Both pages share the same two badge components. Define color intent once, reuse everywhere.
### 3.1 Order status badges
| Status | Badge variant | Tailwind override | Meaning |
|--------|--------------|-------------------|---------|
| `pending` | `secondary` | — | Payment not yet confirmed |
| `confirmed` | `outline` | `border-blue-500 text-blue-600` | Paid, awaiting fulfilment |
| `processing` | `outline` | `border-violet-500 text-violet-600` | Label created, packing |
| `shipped` | `outline` | `border-indigo-500 text-indigo-600` | In transit |
| `delivered` | `default` | `bg-green-600` | Delivered |
| `cancelled` | `destructive` | — | Cancelled |
| `refunded` | `secondary` | `line-through` | Refunded |
### 3.2 Payment status badges
| Status | Badge variant | Tailwind override |
|--------|--------------|-------------------|
| `pending` | `secondary` | — |
| `paid` | `default` | `bg-green-600` |
| `failed` | `destructive` | — |
| `refunded` | `secondary` | — |
Both mappings live in `apps/admin/src/components/orders/shared/statusConfig.ts` — a plain TypeScript constants file, no JSX.
---
## 4. Orders list page
**File:** `apps/admin/src/app/(dashboard)/orders/page.tsx`
### 4.1 Layout (top to bottom)
```
┌─────────────────────────────────────────────────┐
│ Orders │
├─────────────────────────────────────────────────┤
│ [🔍 Search by order # or email…] [Status ▾] │
├─────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────┐ │
│ │ Order # │ Customer │ Date │ Status │ Total │ │
│ │──────────┼──────────┼──────┼────────┼───────│ │
│ │ ORD-AB12 │ Jane Doe │ … │ [Conf] │ £42 │ │
│ └─────────────────────────────────────────────┘ │
│ 12 loaded [Load more] │
└─────────────────────────────────────────────────┘
```
### 4.2 Data fetching
- **Default (no filter, no search):** `usePaginatedQuery(api.orders.listAll, {}, { initialNumItems: 25 })`
- **Status filter active:** `usePaginatedQuery(api.orders.listAll, { status }, { initialNumItems: 25 })`
- Pagination: "Load more" button + count footer — identical to ProductsPage.
- No search query for now (orders list does not have a full-text search index). Search by order number or email is client-side filtered from the loaded page; add a note in the component that server-side search can be added when an index exists.
### 4.3 Table columns
| Column | Notes |
|--------|-------|
| Order # | `font-mono text-xs`; clicking the row navigates to detail |
| Customer | `order.email`; `text-muted-foreground` |
| Date | `createdAt` formatted short; `text-xs text-muted-foreground` |
| Status | `<OrderStatusBadge status={order.status} />` |
| Payment | `<OrderPaymentBadge status={order.paymentStatus} />` |
| Total | `formatPrice(order.total)` right-aligned |
No actions menu column — clicking anywhere on the row navigates to the detail page. Keeps the list clean. A chevron icon on the right of each row communicates navigability.
### 4.4 Extracted components
| Component | File | Responsibility |
|-----------|------|---------------|
| `OrderStatusBadge` | `components/orders/shared/OrderStatusBadge.tsx` | Renders one `<Badge>` for order status |
| `OrderPaymentBadge` | `components/orders/shared/OrderPaymentBadge.tsx` | Renders one `<Badge>` for payment status |
| `OrdersTableSkeleton` | inline in list page | 10 skeleton rows, same columns |
### 4.5 Empty and loading states
- **Loading:** 10 skeleton rows with matching column widths.
- **Empty (no orders):** Centered row: *"No orders yet."*
- **Empty (filter applied):** *"No orders with status 'confirmed'."*
---
## 5. Order detail page
**File:** `apps/admin/src/app/(dashboard)/orders/[id]/page.tsx`
This page uses `useQuery(api.orders.getById, { id })` and `useQuery(api.orders.getTimeline, { orderId: id })` to load both the order and its timeline in parallel.
### 5.1 Layout
Two-column layout on `md+`, stacked on mobile. The left column is wider (about 60%) and holds the primary data. The right column holds contextual cards.
```
┌──────────────────────┬───────────────────┐
│ │ │
│ ← Orders │ Customer │
│ ORD-AB12 [Conf] │ Jane Doe │
│ 14 Jan 2026 │ jane@example.com │
│ ├───────────────────┤
│ [Admin actions bar] │ Shipping address │
│ │ 123 Main St… │
├──────────────────────┤ │
│ Order items ├───────────────────┤
│ ────────────────── │ Fulfilment │
│ Product Qty Total │ Carrier: DPD UK │
│ … │ Service: Next Day│
├──────────────────────┤ [Create Label] │
│ Financials ├───────────────────┤
│ Subtotal £38.00 │ │
│ Shipping £4.99 │ (empty if no │
│ Total £42.99 │ tracking yet) │
├──────────────────────┤ │
│ Timeline │ │
│ ○ Order confirmed │ │
│ ○ Label created │ │
│ ○ Delivered │ │
└──────────────────────┴───────────────────┘
```
On mobile, right column cards stack below the left column, in this order: Customer → Shipping → Fulfilment.
### 5.2 Loading state
All cards render as `Skeleton` blocks while data is `undefined`. Each card has its own skeleton shape so the layout does not shift when data loads. No full-page spinner.
### 5.3 Component breakdown
Every component below has **one job**. None of them fetch data themselves — the page fetches and passes props down.
---
#### 5.3.1 `OrderPageHeader`
**File:** `components/orders/detail/OrderPageHeader.tsx`
**Job:** Back link, order number, date, status badges.
```
← Orders ORD-AB12 [Confirmed] [Paid] 14 Jan 2026, 10:22
```
Props: `orderNumber`, `createdAt`, `status`, `paymentStatus`.
No actions here. Badges are read-only. Headings use `text-xl font-semibold`.
---
#### 5.3.2 `OrderActionsBar`
**File:** `components/orders/detail/OrderActionsBar.tsx`
**Job:** Render the correct set of action buttons based on the order's current status. This is the only component that "knows" which actions are valid — it reads `status`, `paymentStatus`, `trackingNumber`, `returnRequestedAt`, `returnReceivedAt` and decides what to render.
Action matrix:
| Condition | Buttons shown |
|-----------|--------------|
| `status === "confirmed"` and no `trackingNumber` | **Create Label** (primary), **Update Status** (outline) |
| `status === "confirmed"` and `trackingNumber` exists | **Update Status** (outline) |
| `status === "confirmed"` and `returnRequestedAt` and no `returnReceivedAt` | **Mark Return Received** (outline), **Update Status** |
| `status === "confirmed"` and `returnReceivedAt` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
| `status === "processing"` or `"shipped"` | **Update Status** only |
| `status === "delivered"` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
| `status === "cancelled"` or `"refunded"` | Read-only — no buttons |
**`OrderActionsBar` does not implement the action logic itself.** It renders the individual action components below. It is purely a conditional layout shell.
---
#### 5.3.3 `CreateLabelButton`
**File:** `components/orders/actions/CreateLabelButton.tsx`
**Job:** Call `api.fulfillmentActions.createShippingLabel`, show loading spinner, surface structured errors.
- Uses `useAction(api.fulfillmentActions.createShippingLabel)`.
- On click: sets loading, calls action, on success shows `toast.success("Label created. Tracking: {trackingNumber}")`, on structured error shows `toast.error(result.message)`, on thrown error shows generic toast.
- Button label: "Create Label" → "Creating…" while loading.
- Disabled while loading.
- No AlertDialog — creating a label is not destructive.
---
#### 5.3.4 `UpdateStatusDialog`
**File:** `components/orders/actions/UpdateStatusDialog.tsx`
**Job:** A `Dialog` containing a `Select` of valid next statuses. On confirm, calls `api.orders.updateStatus`.
- Triggered by "Update Status" button in `OrderActionsBar`.
- Select shows all statuses except the current one.
- Submit button shows spinner while mutating.
- On success: `toast.success("Status updated to {status}")`.
---
#### 5.3.5 `MarkReturnReceivedButton`
**File:** `components/orders/actions/MarkReturnReceivedButton.tsx`
**Job:** Call `api.orders.markReturnReceived` with an `AlertDialog` confirmation.
- AlertDialog title: *"Mark return as received?"*
- Description: *"Confirm that the returned items have arrived. You can then issue a refund."*
- Uses `useMutation(api.orders.markReturnReceived)`.
- Shows spinner on the confirm button while loading.
---
#### 5.3.6 `IssueRefundButton`
**File:** `components/orders/actions/IssueRefundButton.tsx`
**Job:** Call `api.returnActions.issueRefund` with an `AlertDialog` confirmation.
- AlertDialog title: *"Issue full refund?"*
- Description: *"A full refund of £{order.total} will be sent to the customer via Stripe. This cannot be undone."*
- Uses `useAction(api.returnActions.issueRefund)`.
- Confirm button: destructive variant, "Issue Refund" → "Refunding…".
- On success: `toast.success("Refund issued")`.
- On error: `toast.error(err.message)`.
---
#### 5.3.7 `OrderItemsCard`
**File:** `components/orders/detail/OrderItemsCard.tsx`
**Job:** Display the line items in a `Card` with a bordered table inside.
Columns: Product name (+ variant name below in `text-xs text-muted-foreground`), SKU (`font-mono text-xs`), Qty, Unit price, Total.
No actions. Read-only.
```
┌─ Order items ────────────────────────────────────┐
│ Product SKU Qty Unit Total │
│ Royal Canin… │
│ Adult 2kg SKU-001 2 £19.00 £38.00 │
└──────────────────────────────────────────────────┘
```
---
#### 5.3.8 `OrderFinancialsCard`
**File:** `components/orders/detail/OrderFinancialsCard.tsx`
**Job:** Render the price breakdown.
Uses `InfoRow`-style layout (label left, value right) inside a `Card`. Subtotal, shipping, discount (if > 0), tax (if > 0), and a `Separator` before the bold total.
---
#### 5.3.9 `CustomerCard`
**File:** `components/orders/detail/CustomerCard.tsx`
**Job:** Display the customer name and email.
`Card` with `CardHeader` (title "Customer") and `CardContent`. Email is a `mailto:` link styled as `text-sm text-muted-foreground hover:underline`.
---
#### 5.3.10 `ShippingAddressCard`
**File:** `components/orders/detail/ShippingAddressCard.tsx`
**Job:** Render the `shippingAddressSnapshot` in a `Card`.
Displays each address line in `text-sm`, country in `text-xs text-muted-foreground`. No editing — it's a snapshot.
---
#### 5.3.11 `FulfilmentCard`
**File:** `components/orders/detail/FulfilmentCard.tsx`
**Job:** Shipping service info + tracking state. No action buttons — those live in `OrderActionsBar`.
Three visual states:
**State A — No label yet (status: `confirmed`, no `trackingNumber`):**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Service Next Day │
│ Method Express │
│ │
│ No label created yet. │
└───────────────────────────────────────────────┘
```
**State B — Label created, in transit:**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Service Next Day │
│ Tracking 1Z999AA1… [↗ Track] │
│ Status TRANSIT │
│ Est. delivery 18 Jan 2026 │
│ Label [↗ Download] │
└───────────────────────────────────────────────┘
```
**State C — Delivered:**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Tracking 1Z999AA1… [↗ Track] │
│ Status DELIVERED │
│ Delivered 17 Jan 2026, 14:35 │
└───────────────────────────────────────────────┘
```
The `trackingStatus` field drives the displayed status string. Tracking and label URLs open in a new tab.
---
#### 5.3.12 `OrderTimelineCard`
**File:** `components/orders/detail/OrderTimelineCard.tsx`
**Job:** Render `orderTimelineEvents` as a vertical stepper/timeline.
Receives the `events` array as a prop (fetched by the page).
Each event row:
```
○ Status changed: confirmed → processing 14 Jan · 10:35
by admin
```
Event type → human label mapping:
| `eventType` | Display label |
|-------------|--------------|
| `status_change` | "Status changed: {fromStatus} → {toStatus}" |
| `label_created` | "Shipping label created" + tracking number from payload |
| `tracking_update` | "Tracking update: {status}" |
| `customer_cancel` | "Customer requested cancellation" |
| `return_requested` | "Customer requested return" |
| `return_received` | "Return marked as received" |
| `refund` | "Refund issued" |
The dot (`○`) color matches the status badge color for `status_change` events; neutral for all others.
Loading state: 34 skeleton rows.
Empty state: *"No activity yet."*
---
## 6. Shared utilities
**File:** `components/orders/shared/statusConfig.ts`
```typescript
// Order status → { label, badgeVariant, className }
// Payment status → { label, badgeVariant, className }
// Event type → human label
// Timeline dot color by toStatus
```
Keep all display constants here. Both pages and all badge components import from this single file. No duplication.
---
## 7. File tree
```
apps/admin/src/
├── app/(dashboard)/
│ └── orders/
│ ├── page.tsx ← Orders list page
│ └── [id]/
│ └── page.tsx ← Order detail page
└── components/
└── orders/
├── shared/
│ ├── statusConfig.ts ← Constants only, no JSX
│ ├── OrderStatusBadge.tsx
│ └── OrderPaymentBadge.tsx
├── detail/
│ ├── OrderPageHeader.tsx
│ ├── OrderActionsBar.tsx ← Layout shell, no action logic
│ ├── OrderItemsCard.tsx
│ ├── OrderFinancialsCard.tsx
│ ├── CustomerCard.tsx
│ ├── ShippingAddressCard.tsx
│ ├── FulfilmentCard.tsx
│ └── OrderTimelineCard.tsx
└── actions/
├── CreateLabelButton.tsx
├── UpdateStatusDialog.tsx
├── MarkReturnReceivedButton.tsx
└── IssueRefundButton.tsx
```
---
## 8. Data flow
```
OrderDetailPage (page.tsx)
│ useQuery(api.orders.getById, { id }) → order
│ useQuery(api.orders.getTimeline, { orderId }) → events
├── OrderPageHeader (order)
├── OrderActionsBar (order)
│ ├── CreateLabelButton (orderId, order.status, order.trackingNumber)
│ ├── UpdateStatusDialog (orderId, order.status)
│ ├── MarkReturnReceivedButton (orderId)
│ └── IssueRefundButton (orderId, order.total, order.currency)
├── [left column]
│ ├── OrderItemsCard (order.items)
│ ├── OrderFinancialsCard (order)
│ └── OrderTimelineCard (events)
└── [right column]
├── CustomerCard (order.email, order.userId)
├── ShippingAddressCard (order.shippingAddressSnapshot)
└── FulfilmentCard (order)
```
All queries are read-only in the page. Action components call mutations/actions themselves and handle their own loading/error state. The page never passes mutation functions as props.
---
## 9. Implementation order
| Step | What to build | Why first |
|------|---------------|-----------|
| 1 | `statusConfig.ts`, `OrderStatusBadge`, `OrderPaymentBadge` | Shared atoms used by both pages |
| 2 | Orders list page | Validates data fetching works, gives navigation to detail |
| 3 | `OrderPageHeader`, `OrderItemsCard`, `OrderFinancialsCard` | Core detail page scaffold — read-only |
| 4 | `CustomerCard`, `ShippingAddressCard`, `FulfilmentCard` | Right column — all read-only |
| 5 | `OrderTimelineCard` | Needs timeline query wired |
| 6 | `UpdateStatusDialog` | Lowest-risk action — any status |
| 7 | `CreateLabelButton` | Most impactful Phase 1 action |
| 8 | `MarkReturnReceivedButton`, `IssueRefundButton` | Phase 3/4 actions |
| 9 | `OrderActionsBar` | Wire all actions together with condition logic |
Build read-only views first, add write actions last. This way the detail page is useful even before all actions are wired.
---
## 10. Design decisions and rationale
**Why a full page for detail, not a dialog?**
Orders have 5+ cards, a timeline, and multiple action flows. A dialog forces vertical scrolling with no space for the two-column layout. A page also makes deep-linking (e.g. linking from a customer email) trivial.
**Why does `OrderActionsBar` not implement actions?**
If it did, adding or changing one action would require modifying a large, complex component. With leaf action components, each action is independently testable and replaceable.
**Why no search on the list page?**
The `orders` table has no full-text search index. Client-side filtering over 25 loaded rows is fine for a small operation, but it does not scale past the first page. A future iteration can add a `by_order_number` or `by_email` query when the need is clear.
**Why `useAction` for `createShippingLabel` and `issueRefund`?**
Both are Convex actions (they call external APIs). `useAction` from `convex/react` is the correct hook — not `useMutation`.
**Why show `trackingStatus` in `FulfilmentCard` rather than a separate card?**
Tracking info and fulfilment info are tightly related; an admin needs them together. Separating them would force eye-scanning across two cards to understand the delivery state.
---
*Document version: 1.0. UI only — no new backend work required.*

View File

@@ -0,0 +1,189 @@
# API Usage Guide
This document describes how to use the background removal API.
---
## Base URL
- Local: `http://localhost:8000`
- Production: `http://localhost:8000`
---
## Endpoints
### 1. API Info
```
GET /
```
Returns service metadata and links.
**Response:**
```json
{
"service": "withoutbg-api",
"version": "1.0.2",
"docs": "/docs",
"health": "/api/health"
}
```
---
### 2. Health Check
```
GET /api/health
```
Verify the API is running and models are loaded.
**Response:**
```json
{
"status": "healthy",
"version": "1.0.2",
"service": "withoutbg-api",
"models_loaded": true
}
```
---
### 3. Remove Background
```
POST /api/remove-background
Content-Type: multipart/form-data
```
Remove the background from an image and return the result.
#### Parameters
| Name | Type | Required | Default | Description |
|---------|--------|----------|---------|------------------------------------------------------------|
| `file` | File | Yes | — | Image file (PNG, JPEG, WebP) |
| `format`| String | No | `webp` | Output format: `webp`, `png`, or `jpg` |
| `quality` | Integer | No | `95` | Compression quality for WebP/JPEG (1100) |
#### Request Example (curl)
```bash
# WebP output (default)
curl -X POST https://your-api.com/api/remove-background \
-F "file=@/path/to/photo.jpg" \
-o result.webp
# PNG output
curl -X POST https://your-api.com/api/remove-background \
-F "file=@photo.png" \
-F "format=png" \
-o result.png
# JPEG output with quality
curl -X POST https://your-api.com/api/remove-background \
-F "file=@photo.jpg" \
-F "format=jpg" \
-F "quality=90" \
-o result.jpg
```
#### Request Example (Python)
```python
import requests
url = "https://your-api.com/api/remove-background"
with open("photo.jpg", "rb") as f:
response = requests.post(
url,
files={"file": ("photo.jpg", f, "image/jpeg")},
data={"format": "webp", "quality": 95},
)
if response.ok:
with open("result.webp", "wb") as out:
out.write(response.content)
else:
print(response.json())
```
#### Request Example (JavaScript / Fetch)
```javascript
const formData = new FormData();
formData.append("file", imageFile);
formData.append("format", "webp");
formData.append("quality", "95");
const response = await fetch("https://your-api.com/api/remove-background", {
method: "POST",
body: formData,
});
if (response.ok) {
const blob = await response.blob();
// Use blob (e.g. create download link, display in img)
} else {
const error = await response.json();
console.error(error.detail);
}
```
#### Response
- **Success (200):** Binary image (WebP, PNG, or JPEG)
- **Content-Type:** `image/webp`, `image/png`, or `image/jpeg`
- **Content-Disposition:** `inline; filename=withoutbg.{format}`
#### Response Headers
| Header | Description |
|-----------------------|-----------------------------------------------------------------------------|
| `X-Format-Fallback` | Present if WebP was requested but PNG was returned (e.g. `X-Format-Fallback: png`) |
---
## Processing Behavior
- Input image is centered on a transparent square canvas (height × height)
- Output is resized so the longest side is at most 1000px
- Output size is limited to ~50KB; if larger, the API may compress further
- Supported input formats: PNG, JPEG, WebP
---
## Error Responses
Errors return JSON with a `detail` field.
| Status | Meaning | Example `detail` |
|--------|----------------------------|-----------------------------------------|
| 400 | Bad request | `File too small (64 bytes)...` |
| 400 | Invalid image | `Invalid or unsupported image format` |
| 500 | Processing error | `Processing failed: ...` |
| 503 | Service not ready | `Models not loaded. Server may still be starting up.` |
**Example:**
```json
{
"detail": "File too small (64 bytes). Verify the file exists and use full path: -F 'file=@/path/to/image.webp'"
}
```
---
## Interactive Documentation
When the API is running, Swagger UI is available at:
```
GET /docs
```
Use it to try endpoints from the browser.

View File

@@ -1,6 +1,11 @@
const path = require("path");
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"], transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
turbopack: {
root: path.join(__dirname, "..", ".."),
},
images: { images: {
remotePatterns: [ remotePatterns: [
{ {