diff --git a/client/seller/src/components/AppShell.tsx b/client/seller/src/components/AppShell.tsx index 6dbe2f3..c4bac1a 100644 --- a/client/seller/src/components/AppShell.tsx +++ b/client/seller/src/components/AppShell.tsx @@ -2,6 +2,7 @@ import { Link, Outlet } from "@tanstack/react-router"; import { ConnectionIndicator } from "@/components/ConnectionIndicator"; import { useSession } from "@/session/SessionContext"; +import { useActionableObligationCount } from "@/obligations/ObligationsPage"; export function AppShell() { const { isRegisteredSeller } = useSession(); @@ -23,6 +24,7 @@ export function AppShell() { > My Listings + )} @@ -34,3 +36,21 @@ export function AppShell() { ); } + +function ObligationsNavLink() { + const actionableCount = useActionableObligationCount(); + + return ( + + Obligations + {actionableCount > 0 && ( + + {actionableCount} + + )} + + ); +} diff --git a/client/seller/src/obligations/ObligationsPage.test.tsx b/client/seller/src/obligations/ObligationsPage.test.tsx new file mode 100644 index 0000000..44277d8 --- /dev/null +++ b/client/seller/src/obligations/ObligationsPage.test.tsx @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; + +import { ObligationsPage } from "@/obligations/ObligationsPage"; +import { SessionProvider } from "@/session/SessionContext"; + +const BASE_OBLIGATION = { + id: "0192f1a0-1111-7000-8000-000000000001", + listingId: "0192f1a0-2222-7000-8000-000000000002", + winnerId: "0192f1a0-3333-7000-8000-000000000003", + sellerId: "seller-test-id", + hammerPrice: 55.0, + shipByDeadline: new Date(Date.now() + 86_400_000).toISOString(), + trackingNumber: null, + reminderSentAt: null, + trackingProvidedAt: null, + fulfilledAt: null, + escalatedAt: null, + disputeId: null, + disputeReason: null, + disputeOpenedAt: null, + disputeResolution: null, + disputeResolvedAt: null, +}; + +function stubFetch(obligations: unknown[]): void { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (typeof url === "string" && url.includes("/api/obligations/status")) { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => obligations, + text: async () => JSON.stringify(obligations), + } as unknown as Response; + } + if (typeof url === "string" && url.includes("/api/selling/listings")) { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => [], + text: async () => "[]", + } as unknown as Response; + } + return { + ok: false, + status: 404, + headers: new Headers(), + json: async () => null, + text: async () => "", + } as unknown as Response; + }), + ); +} + +function renderObligationsPage(): void { + sessionStorage.setItem("critterbids.seller.participantId", "seller-test-id"); + sessionStorage.setItem("critterbids.seller.isRegisteredSeller", "true"); + + const rootRoute = createRootRoute({ component: Outlet }); + const obligationsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: ObligationsPage, + }); + const routeTree = rootRoute.addChildren([obligationsRoute]); + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + + + , + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ObligationsPage", () => { + it("renders an empty state when no obligations exist", async () => { + stubFetch([]); + renderObligationsPage(); + + expect( + await screen.findByText(/no post-sale obligations/i), + ).toBeInTheDocument(); + }); + + it("renders AwaitingShipment with deadline and Provide Tracking button", async () => { + stubFetch([{ ...BASE_OBLIGATION, status: "AwaitingShipment" }]); + renderObligationsPage(); + + expect( + await screen.findByText(/ship your item by/i), + ).toBeInTheDocument(); + expect(screen.getByText("Awaiting Shipment")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Provide Tracking" }), + ).toBeInTheDocument(); + }); + + it("renders reminder banner when reminderSentAt is set", async () => { + stubFetch([ + { + ...BASE_OBLIGATION, + status: "AwaitingShipment", + reminderSentAt: "2026-06-13T10:00:00Z", + }, + ]); + renderObligationsPage(); + + expect( + await screen.findByText(/reminder sent/i), + ).toBeInTheDocument(); + }); + + it("renders Escalated status with overdue messaging and tracking button", async () => { + stubFetch([ + { + ...BASE_OBLIGATION, + status: "Escalated", + escalatedAt: "2026-06-14T12:00:00Z", + }, + ]); + renderObligationsPage(); + + expect( + await screen.findByText(/your deadline passed/i), + ).toBeInTheDocument(); + expect(screen.getByText("Overdue")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Provide Tracking" }), + ).toBeInTheDocument(); + }); + + it("renders Shipped status with tracking number", async () => { + stubFetch([ + { + ...BASE_OBLIGATION, + status: "Shipped", + trackingNumber: "1Z999AA10123456784", + trackingProvidedAt: "2026-06-13T10:00:00Z", + }, + ]); + renderObligationsPage(); + + expect( + await screen.findByText(/1Z999AA10123456784/), + ).toBeInTheDocument(); + expect(screen.getByText("Shipped")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Provide Tracking" }), + ).not.toBeInTheDocument(); + }); + + it("renders Fulfilled status as completed", async () => { + stubFetch([ + { + ...BASE_OBLIGATION, + status: "Fulfilled", + trackingNumber: "TRACK123", + trackingProvidedAt: "2026-06-13T10:00:00Z", + fulfilledAt: "2026-06-13T12:00:00Z", + }, + ]); + renderObligationsPage(); + + expect(await screen.findByText("Completed.")).toBeInTheDocument(); + expect(screen.getByText("Fulfilled")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Provide Tracking" }), + ).not.toBeInTheDocument(); + }); + + it("renders Disputed status with reason", async () => { + stubFetch([ + { + ...BASE_OBLIGATION, + status: "Disputed", + disputeId: "00000000-0000-0000-0000-000000000099", + disputeReason: "NonDelivery", + disputeOpenedAt: "2026-06-14T14:00:00Z", + }, + ]); + renderObligationsPage(); + + expect( + await screen.findByText(/dispute open.*nondelivery/i), + ).toBeInTheDocument(); + expect(screen.getByText("Disputed")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Provide Tracking" }), + ).not.toBeInTheDocument(); + }); + + it("renders resolved dispute status", async () => { + stubFetch([ + { + ...BASE_OBLIGATION, + status: "Disputed", + disputeId: "00000000-0000-0000-0000-000000000099", + disputeReason: "NonDelivery", + disputeOpenedAt: "2026-06-14T14:00:00Z", + disputeResolution: "Refund", + disputeResolvedAt: "2026-06-15T10:00:00Z", + }, + ]); + renderObligationsPage(); + + expect( + await screen.findByText(/dispute resolved.*refund/i), + ).toBeInTheDocument(); + }); + + it("renders hammer price on obligation card", async () => { + stubFetch([{ ...BASE_OBLIGATION, status: "AwaitingShipment" }]); + renderObligationsPage(); + + expect(await screen.findByText("$55.00 sale")).toBeInTheDocument(); + }); + + it("renders an error state on fetch failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 500, + headers: new Headers(), + json: async () => null, + text: async () => "", + })), + ); + renderObligationsPage(); + + expect( + await screen.findByText(/something went wrong/i), + ).toBeInTheDocument(); + }); + + it("shows overdue deadline text when shipByDeadline is in the past", async () => { + stubFetch([ + { + ...BASE_OBLIGATION, + status: "AwaitingShipment", + shipByDeadline: new Date(Date.now() - 60_000).toISOString(), + }, + ]); + renderObligationsPage(); + + expect(await screen.findByText(/overdue/i)).toBeInTheDocument(); + }); +}); diff --git a/client/seller/src/obligations/ObligationsPage.tsx b/client/seller/src/obligations/ObligationsPage.tsx new file mode 100644 index 0000000..870152a --- /dev/null +++ b/client/seller/src/obligations/ObligationsPage.tsx @@ -0,0 +1,291 @@ +import { useState } from "react"; + +import { useSession } from "@/session/SessionContext"; +import { useSellerObligations } from "@/obligations/queries"; +import type { ObligationStatusView } from "@/obligations/schema"; +import { ProvideTrackingDialog } from "@/obligations/ProvideTrackingDialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState, ErrorState } from "@/components/States"; +import { formatUsd } from "@/lib/format"; + +const ACTIONABLE_STATUSES = new Set(["AwaitingShipment", "Escalated"]); + +export function ObligationsPage() { + const { participantId } = useSession(); + const { data, isPending, isError, error, refetch } = useSellerObligations( + participantId ?? "", + ); + + if (isPending) { + return ; + } + + if (isError) { + return ( +
+

+ Obligations +

+ void refetch()} + /> +
+ ); + } + + return ( +
+

+ Obligations +

+ {data.length === 0 ? ( + + ) : ( +
    + {data.map((obligation) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +export function obligationStatusVariant( + status: string, +): "default" | "secondary" | "destructive" | "outline" { + switch (status) { + case "Fulfilled": + return "secondary"; + case "Escalated": + case "Disputed": + return "destructive"; + case "Shipped": + return "default"; + case "AwaitingShipment": + default: + return "outline"; + } +} + +function statusLabel(status: string): string { + switch (status) { + case "AwaitingShipment": + return "Awaiting Shipment"; + case "Shipped": + return "Shipped"; + case "Escalated": + return "Overdue"; + case "Fulfilled": + return "Fulfilled"; + case "Disputed": + return "Disputed"; + default: + return status; + } +} + +function formatRelativeDeadline(deadline: string): string { + const deadlineDate = new Date(deadline); + const now = new Date(); + const diffMs = deadlineDate.getTime() - now.getTime(); + + if (diffMs <= 0) { + return "Overdue"; + } + + const diffMinutes = Math.floor(diffMs / 60_000); + if (diffMinutes < 1) return "less than a minute"; + if (diffMinutes < 60) return `in ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"}`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `in ${diffHours} hour${diffHours === 1 ? "" : "s"}`; + const diffDays = Math.floor(diffHours / 24); + return `in ${diffDays} day${diffDays === 1 ? "" : "s"}`; +} + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function ObligationCard({ obligation }: { obligation: ObligationStatusView }) { + const [trackingOpen, setTrackingOpen] = useState(false); + const canProvideTracking = ACTIONABLE_STATUSES.has(obligation.status); + + return ( + <> + + +
+ + {formatUsd(obligation.hammerPrice)} sale + + + {statusLabel(obligation.status)} + +
+

+ Obligation {obligation.id.slice(0, 8)}… +

+
+ + + + {canProvideTracking && ( +
+ +
+ )} +
+
+ {trackingOpen && ( + setTrackingOpen(false)} + /> + )} + + ); +} + +function ObligationStatusDetail({ + obligation, +}: { + obligation: ObligationStatusView; +}) { + switch (obligation.status) { + case "AwaitingShipment": + return ( +
+

+ Ship your item by{" "} + + {formatRelativeDeadline(obligation.shipByDeadline)} + +

+ {obligation.reminderSentAt && ( +

+ Reminder sent — ship before your deadline. +

+ )} +
+ ); + + case "Escalated": + return ( +
+

+ Overdue — your deadline passed; this sale is under review. +

+ {obligation.escalatedAt && ( +

+ Escalated {formatTimestamp(obligation.escalatedAt)} +

+ )} +
+ ); + + case "Shipped": + return ( +
+

+ Shipped — tracking #{obligation.trackingNumber}; delivery + confirmation pending. +

+ {obligation.trackingProvidedAt && ( +

+ Shipped {formatTimestamp(obligation.trackingProvidedAt)} +

+ )} +
+ ); + + case "Fulfilled": + return ( +
+

Completed.

+ {obligation.fulfilledAt && ( +

+ Fulfilled {formatTimestamp(obligation.fulfilledAt)} +

+ )} +
+ ); + + case "Disputed": + return ( +
+ {obligation.disputeResolution ? ( +

+ Dispute resolved ({obligation.disputeResolution}). +

+ ) : ( +

+ Dispute open ({obligation.disputeReason}). +

+ )} + {obligation.disputeOpenedAt && ( +

+ Opened {formatTimestamp(obligation.disputeOpenedAt)} +

+ )} + {obligation.disputeResolvedAt && ( +

+ Resolved {formatTimestamp(obligation.disputeResolvedAt)} +

+ )} +
+ ); + + default: + return null; + } +} + +export function useActionableObligationCount(): number { + const { participantId } = useSession(); + const { data } = useSellerObligations(participantId ?? ""); + if (!data) return 0; + return data.filter((o) => ACTIONABLE_STATUSES.has(o.status)).length; +} + +function ObligationsSkeleton() { + return ( +
+ +
    + {Array.from({ length: 3 }).map((_, index) => ( +
  • + + + + + + + + + +
  • + ))} +
+
+ ); +} diff --git a/client/seller/src/obligations/ProvideTrackingDialog.test.tsx b/client/seller/src/obligations/ProvideTrackingDialog.test.tsx new file mode 100644 index 0000000..eb40aa0 --- /dev/null +++ b/client/seller/src/obligations/ProvideTrackingDialog.test.tsx @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; + +import { ProvideTrackingDialog } from "@/obligations/ProvideTrackingDialog"; +import type { ObligationStatusView } from "@/obligations/schema"; +import { SessionProvider } from "@/session/SessionContext"; + +const OBLIGATION: ObligationStatusView = { + id: "0192f1a0-1111-7000-8000-000000000001", + listingId: "0192f1a0-2222-7000-8000-000000000002", + winnerId: "0192f1a0-3333-7000-8000-000000000003", + sellerId: "seller-test-id", + hammerPrice: 55.0, + status: "AwaitingShipment", + shipByDeadline: "2026-06-14T12:00:00Z", + trackingNumber: null, + reminderSentAt: null, + trackingProvidedAt: null, + fulfilledAt: null, + escalatedAt: null, + disputeId: null, + disputeReason: null, + disputeOpenedAt: null, + disputeResolution: null, + disputeResolvedAt: null, +}; + +function renderDialog(onClose = vi.fn()): { + onClose: ReturnType; + fetchMock: ReturnType; +} { + sessionStorage.setItem("critterbids.seller.participantId", "seller-test-id"); + sessionStorage.setItem("critterbids.seller.isRegisteredSeller", "true"); + + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 202, + headers: new Headers(), + json: async () => null, + text: async () => "", + })); + vi.stubGlobal("fetch", fetchMock); + + const rootRoute = createRootRoute({ component: Outlet }); + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => ( + + ), + }); + const routeTree = rootRoute.addChildren([homeRoute]); + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + + + , + ); + + return { onClose, fetchMock }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ProvideTrackingDialog", () => { + it("renders the dialog with tracking number input", async () => { + renderDialog(); + + expect( + await screen.findByRole("dialog", { name: /provide tracking/i }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Tracking Number")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Submit Tracking" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Cancel" }), + ).toBeInTheDocument(); + }); + + it("shows the obligation context (hammer price)", async () => { + renderDialog(); + + expect(await screen.findByText(/\$55\.00 sale/)).toBeInTheDocument(); + }); + + it("validates that tracking number is required", async () => { + renderDialog(); + const user = userEvent.setup(); + + await user.click( + await screen.findByRole("button", { name: "Submit Tracking" }), + ); + + expect( + await screen.findByText(/tracking number is required/i), + ).toBeInTheDocument(); + }); + + it("submits tracking number to the endpoint", async () => { + const { fetchMock } = renderDialog(); + const user = userEvent.setup(); + + await user.type( + await screen.findByLabelText("Tracking Number"), + "1Z999AA10123456784", + ); + await user.click(screen.getByRole("button", { name: "Submit Tracking" })); + + await vi.waitFor(() => { + const trackingCall = fetchMock.mock.calls.find( + (c) => + typeof c[0] === "string" && + c[0].includes("/api/obligations/tracking"), + ); + expect(trackingCall).toBeDefined(); + const body = JSON.parse( + (trackingCall![1] as { body: string }).body, + ) as { obligationId: string; trackingNumber: string }; + expect(body.obligationId).toBe(OBLIGATION.id); + expect(body.trackingNumber).toBe("1Z999AA10123456784"); + }); + }); + + it("calls onClose on successful submit", async () => { + const onClose = vi.fn(); + renderDialog(onClose); + const user = userEvent.setup(); + + await user.type( + await screen.findByLabelText("Tracking Number"), + "TRACK-XYZ", + ); + await user.click(screen.getByRole("button", { name: "Submit Tracking" })); + + await vi.waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("calls onClose when Cancel is clicked", async () => { + const onClose = vi.fn(); + renderDialog(onClose); + const user = userEvent.setup(); + + await user.click( + await screen.findByRole("button", { name: "Cancel" }), + ); + + expect(onClose).toHaveBeenCalled(); + }); + + it("shows error on mutation failure", async () => { + const { fetchMock } = renderDialog(); + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + headers: new Headers(), + json: async () => null, + text: async () => "Invalid tracking number", + }); + const user = userEvent.setup(); + + await user.type( + await screen.findByLabelText("Tracking Number"), + "BAD-TRACK", + ); + await user.click(screen.getByRole("button", { name: "Submit Tracking" })); + + expect( + await screen.findByText("Invalid tracking number"), + ).toBeInTheDocument(); + }); +}); diff --git a/client/seller/src/obligations/ProvideTrackingDialog.tsx b/client/seller/src/obligations/ProvideTrackingDialog.tsx new file mode 100644 index 0000000..a1f1b5d --- /dev/null +++ b/client/seller/src/obligations/ProvideTrackingDialog.tsx @@ -0,0 +1,110 @@ +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { useSession } from "@/session/SessionContext"; +import { useProvideTracking } from "@/obligations/mutations"; +import { + trackingFormSchema, + type TrackingFormValues, +} from "@/obligations/formSchemas"; +import type { ObligationStatusView } from "@/obligations/schema"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { formatUsd } from "@/lib/format"; + +const resolver = zodResolver(trackingFormSchema); + +export function ProvideTrackingDialog({ + obligation, + onClose, +}: { + obligation: ObligationStatusView; + onClose: () => void; +}) { + const { participantId } = useSession(); + const mutation = useProvideTracking(participantId ?? ""); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver, + defaultValues: { trackingNumber: "" }, + }); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const onSubmit = handleSubmit((values) => { + mutation.mutate( + { obligationId: obligation.id, values }, + { onSuccess: onClose }, + ); + }); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + role="dialog" + aria-modal="true" + aria-label="Provide tracking information" + > +
+

Provide Tracking

+

+ {formatUsd(obligation.hammerPrice)} sale — obligation{" "} + {obligation.id.slice(0, 8)}… +

+
+
+ + + {errors.trackingNumber && ( +

+ {errors.trackingNumber.message} +

+ )} +
+ + {mutation.isError && ( +

+ {mutation.error.message} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/client/seller/src/obligations/formSchemas.ts b/client/seller/src/obligations/formSchemas.ts new file mode 100644 index 0000000..e7d20ef --- /dev/null +++ b/client/seller/src/obligations/formSchemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const trackingFormSchema = z.object({ + trackingNumber: z.string().min(1, "Tracking number is required"), +}); + +export type TrackingFormValues = z.infer; diff --git a/client/seller/src/obligations/mutations.ts b/client/seller/src/obligations/mutations.ts new file mode 100644 index 0000000..e17e5c8 --- /dev/null +++ b/client/seller/src/obligations/mutations.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import type { TrackingFormValues } from "@/obligations/formSchemas"; + +export function useProvideTracking(sellerId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + obligationId, + values, + }: { + obligationId: string; + values: TrackingFormValues; + }) => { + const response = await fetch("/api/obligations/tracking", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + obligationId, + trackingNumber: values.trackingNumber, + }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + text || `Provide tracking failed with ${response.status}.`, + ); + } + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["sellerObligations", sellerId], + }); + }, + }); +} diff --git a/client/seller/src/obligations/queries.ts b/client/seller/src/obligations/queries.ts new file mode 100644 index 0000000..5835193 --- /dev/null +++ b/client/seller/src/obligations/queries.ts @@ -0,0 +1,29 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; + +import { + obligationStatusListSchema, + type ObligationStatusView, +} from "@/obligations/schema"; + +export function sellerObligationsQueryOptions(sellerId: string) { + return queryOptions({ + queryKey: ["sellerObligations", sellerId], + queryFn: async (): Promise => { + const response = await fetch( + `/api/obligations/status?sellerId=${encodeURIComponent(sellerId)}`, + { headers: { Accept: "application/json" } }, + ); + if (!response.ok) { + throw new Error( + `Obligations request failed with ${response.status}.`, + ); + } + return obligationStatusListSchema.parse(await response.json()); + }, + enabled: sellerId.length > 0, + }); +} + +export function useSellerObligations(sellerId: string) { + return useQuery(sellerObligationsQueryOptions(sellerId)); +} diff --git a/client/seller/src/obligations/schema.test.ts b/client/seller/src/obligations/schema.test.ts new file mode 100644 index 0000000..7cdca9f --- /dev/null +++ b/client/seller/src/obligations/schema.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { + obligationStatusViewSchema, + obligationStatusListSchema, +} from "@/obligations/schema"; + +const VALID_OBLIGATION = { + id: "00000000-0000-0000-0000-000000000001", + listingId: "00000000-0000-0000-0000-000000000002", + winnerId: "00000000-0000-0000-0000-000000000003", + sellerId: "00000000-0000-0000-0000-000000000004", + hammerPrice: 55.0, + status: "AwaitingShipment", + shipByDeadline: "2026-06-14T12:00:00Z", + trackingNumber: null, + reminderSentAt: null, + trackingProvidedAt: null, + fulfilledAt: null, + escalatedAt: null, + disputeId: null, + disputeReason: null, + disputeOpenedAt: null, + disputeResolution: null, + disputeResolvedAt: null, +}; + +describe("obligationStatusViewSchema", () => { + it("parses a valid AwaitingShipment obligation", () => { + const result = obligationStatusViewSchema.safeParse(VALID_OBLIGATION); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("AwaitingShipment"); + expect(result.data.hammerPrice).toBe(55.0); + } + }); + + it("parses a Shipped obligation with tracking", () => { + const shipped = { + ...VALID_OBLIGATION, + status: "Shipped", + trackingNumber: "1Z999AA10123456784", + trackingProvidedAt: "2026-06-13T10:00:00Z", + }; + const result = obligationStatusViewSchema.safeParse(shipped); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.trackingNumber).toBe("1Z999AA10123456784"); + } + }); + + it("parses a Fulfilled obligation", () => { + const fulfilled = { + ...VALID_OBLIGATION, + status: "Fulfilled", + trackingNumber: "TRACK123", + trackingProvidedAt: "2026-06-13T10:00:00Z", + fulfilledAt: "2026-06-13T12:00:00Z", + }; + const result = obligationStatusViewSchema.safeParse(fulfilled); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("Fulfilled"); + expect(result.data.fulfilledAt).toBe("2026-06-13T12:00:00Z"); + } + }); + + it("parses an Escalated obligation", () => { + const escalated = { + ...VALID_OBLIGATION, + status: "Escalated", + escalatedAt: "2026-06-14T12:00:00Z", + }; + const result = obligationStatusViewSchema.safeParse(escalated); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("Escalated"); + } + }); + + it("parses a Disputed obligation", () => { + const disputed = { + ...VALID_OBLIGATION, + status: "Disputed", + disputeId: "00000000-0000-0000-0000-000000000099", + disputeReason: "NonDelivery", + disputeOpenedAt: "2026-06-14T14:00:00Z", + }; + const result = obligationStatusViewSchema.safeParse(disputed); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.disputeReason).toBe("NonDelivery"); + } + }); + + it("rejects an unknown status value", () => { + const invalid = { ...VALID_OBLIGATION, status: "Unknown" }; + const result = obligationStatusViewSchema.safeParse(invalid); + expect(result.success).toBe(false); + }); + + it("rejects missing required fields", () => { + const result = obligationStatusViewSchema.safeParse({ id: "abc" }); + expect(result.success).toBe(false); + }); +}); + +describe("obligationStatusListSchema", () => { + it("parses an array of obligations", () => { + const result = obligationStatusListSchema.safeParse([ + VALID_OBLIGATION, + { ...VALID_OBLIGATION, id: "00000000-0000-0000-0000-000000000099", status: "Fulfilled" }, + ]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(2); + } + }); + + it("parses an empty array", () => { + const result = obligationStatusListSchema.safeParse([]); + expect(result.success).toBe(true); + }); +}); diff --git a/client/seller/src/obligations/schema.ts b/client/seller/src/obligations/schema.ts new file mode 100644 index 0000000..9d8731c --- /dev/null +++ b/client/seller/src/obligations/schema.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const obligationStatusEnum = z.enum([ + "AwaitingShipment", + "Shipped", + "Escalated", + "Fulfilled", + "Disputed", +]); +export type ObligationStatus = z.infer; + +export const obligationStatusViewSchema = z.object({ + id: z.string(), + listingId: z.string(), + winnerId: z.string(), + sellerId: z.string(), + hammerPrice: z.number(), + status: obligationStatusEnum, + shipByDeadline: z.string(), + trackingNumber: z.string().nullable(), + reminderSentAt: z.string().nullable(), + trackingProvidedAt: z.string().nullable(), + fulfilledAt: z.string().nullable(), + escalatedAt: z.string().nullable(), + disputeId: z.string().nullable(), + disputeReason: z.string().nullable(), + disputeOpenedAt: z.string().nullable(), + disputeResolution: z.string().nullable(), + disputeResolvedAt: z.string().nullable(), +}); + +export type ObligationStatusView = z.infer; + +export const obligationStatusListSchema = z.array(obligationStatusViewSchema); diff --git a/client/seller/src/router.tsx b/client/seller/src/router.tsx index 5494e5a..abf933f 100644 --- a/client/seller/src/router.tsx +++ b/client/seller/src/router.tsx @@ -11,6 +11,7 @@ import { HomePage } from "@/pages/HomePage"; import { ListingsPage } from "@/listings/ListingsPage"; import { CreateListingPage } from "@/listings/CreateListingPage"; import { ListingDetailPage } from "@/listings/ListingDetailPage"; +import { ObligationsPage } from "@/obligations/ObligationsPage"; const rootRoute = createRootRoute({ component: AppShell, @@ -44,11 +45,18 @@ const listingDetailRoute = createRoute({ }), }); +const obligationsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/obligations", + component: ObligationsPage, +}); + const routeTree = rootRoute.addChildren([ homeRoute, listingsRoute, createListingRoute, listingDetailRoute, + obligationsRoute, ]); export const router = createRouter({ diff --git a/client/seller/src/signalr/cacheBridge.test.ts b/client/seller/src/signalr/cacheBridge.test.ts index 663ee1e..06fe9a9 100644 --- a/client/seller/src/signalr/cacheBridge.test.ts +++ b/client/seller/src/signalr/cacheBridge.test.ts @@ -86,4 +86,64 @@ describe("applyHubMessage", () => { queryKey: ["sellerListings"], }); }); + + it("invalidates obligations on ObligationFulfilled bidderEvent", () => { + const message: HubMessage = { + kind: "bidderEvent", + bidderId: "seller-1", + listingId: "listing-abc", + eventType: "ObligationFulfilled", + payload: "Obligation fulfilled.", + occurredAt: "2026-06-13T14:00:00Z", + }; + + applyHubMessage(queryClient, message); + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["sellerObligations"], + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["listing", "listing-abc"], + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["sellerListings"], + }); + }); + + it("invalidates obligations on TrackingInfoProvided bidderEvent", () => { + const message: HubMessage = { + kind: "bidderEvent", + bidderId: "seller-1", + listingId: null, + eventType: "TrackingInfoProvided", + payload: "Tracking provided: TRACK123.", + occurredAt: "2026-06-13T10:00:00Z", + }; + + applyHubMessage(queryClient, message); + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["sellerObligations"], + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["sellerListings"], + }); + }); + + it("does not invalidate obligations on non-obligation bidderEvent", () => { + const message: HubMessage = { + kind: "bidderEvent", + bidderId: "seller-1", + listingId: "listing-abc", + eventType: "ProxyBidExhausted", + payload: "Proxy exhausted.", + occurredAt: "2026-06-13T10:00:00Z", + }; + + applyHubMessage(queryClient, message); + + expect(queryClient.invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ["sellerObligations"], + }); + }); }); diff --git a/client/seller/src/signalr/cacheBridge.ts b/client/seller/src/signalr/cacheBridge.ts index 1ddf913..9e72a4e 100644 --- a/client/seller/src/signalr/cacheBridge.ts +++ b/client/seller/src/signalr/cacheBridge.ts @@ -2,9 +2,10 @@ import type { QueryClient } from "@tanstack/react-query"; import { listingIdOf, type HubMessage } from "@/signalr/messages"; -// ADR 026 cache bridge for the seller app. A hub push is a "something changed, refetch" signal, -// never authoritative data. Invalidate the listing detail query so the CatalogListingView -// re-fetches, and invalidate the seller listings so the dashboard reflects status changes. +const OBLIGATION_EVENT_TYPES = new Set([ + "TrackingInfoProvided", + "ObligationFulfilled", +]); export function applyHubMessage( queryClient: QueryClient, @@ -12,6 +13,15 @@ export function applyHubMessage( ): void { const listingId = listingIdOf(message); - void queryClient.invalidateQueries({ queryKey: ["listing", listingId] }); + if (listingId) { + void queryClient.invalidateQueries({ queryKey: ["listing", listingId] }); + } void queryClient.invalidateQueries({ queryKey: ["sellerListings"] }); + + if ( + message.kind === "bidderEvent" && + OBLIGATION_EVENT_TYPES.has(message.eventType) + ) { + void queryClient.invalidateQueries({ queryKey: ["sellerObligations"] }); + } } diff --git a/client/seller/src/signalr/messages.test.ts b/client/seller/src/signalr/messages.test.ts index d52a2b3..9861144 100644 --- a/client/seller/src/signalr/messages.test.ts +++ b/client/seller/src/signalr/messages.test.ts @@ -52,21 +52,46 @@ describe("parseHubMessage", () => { expect(parseHubMessage(42)).toBeNull(); }); - it("returns null for BidderGroupNotification (seller ignores these)", () => { + it("parses BidderGroupNotification as bidderEvent", () => { const payload = { bidderId: "bidder-1", listingId: "abc-123", - eventType: "ProxyBidExhausted", - payload: "Proxy exhausted at max 100.", + eventType: "ObligationFulfilled", + payload: "Obligation fulfilled.", occurredAt: "2026-06-13T12:00:00Z", }; - // BidderGroupNotification has a bidderId field which makes it match the - // listingGroupSchema too — but only because the schemas are loose. The seller - // receives these as listingEvent, which is harmless (logged-and-ignored in the - // activity feed if the listingId doesn't match). This test documents the behavior. const result = parseHubMessage(payload); - expect(result).not.toBeNull(); + + expect(result).toEqual({ + kind: "bidderEvent", + bidderId: "bidder-1", + listingId: "abc-123", + eventType: "ObligationFulfilled", + payload: "Obligation fulfilled.", + occurredAt: "2026-06-13T12:00:00Z", + }); + }); + + it("parses BidderGroupNotification with null listingId", () => { + const payload = { + bidderId: "bidder-1", + listingId: null, + eventType: "TrackingInfoProvided", + payload: "Tracking provided: TRACK123.", + occurredAt: "2026-06-13T10:00:00Z", + }; + + const result = parseHubMessage(payload); + + expect(result).toEqual({ + kind: "bidderEvent", + bidderId: "bidder-1", + listingId: null, + eventType: "TrackingInfoProvided", + payload: "Tracking provided: TRACK123.", + occurredAt: "2026-06-13T10:00:00Z", + }); }); it("discriminates bidPlaced before listingEvent (bidId is the distinguisher)", () => { @@ -87,7 +112,7 @@ describe("parseHubMessage", () => { }); describe("listingIdOf", () => { - it("returns the listing id from any message kind", () => { + it("returns the listing id from auction message kinds", () => { expect( listingIdOf({ kind: "bidPlaced", @@ -121,4 +146,30 @@ describe("listingIdOf", () => { }), ).toBe("l-3"); }); + + it("returns null for bidderEvent with no listing", () => { + expect( + listingIdOf({ + kind: "bidderEvent", + bidderId: "b-1", + listingId: null, + eventType: "ObligationFulfilled", + payload: "Fulfilled.", + occurredAt: "", + }), + ).toBeNull(); + }); + + it("returns the listing id from bidderEvent when present", () => { + expect( + listingIdOf({ + kind: "bidderEvent", + bidderId: "b-1", + listingId: "l-4", + eventType: "TrackingInfoProvided", + payload: "Tracking provided.", + occurredAt: "", + }), + ).toBe("l-4"); + }); }); diff --git a/client/seller/src/signalr/messages.ts b/client/seller/src/signalr/messages.ts index 790016b..451e588 100644 --- a/client/seller/src/signalr/messages.ts +++ b/client/seller/src/signalr/messages.ts @@ -1,13 +1,8 @@ import { z } from "zod"; // Zod wire-boundary schemas for the BiddingHub push surface the seller consumes. The seller -// joins listing:{listingId} groups and receives the same auction-lifecycle notifications as the -// bidder — BidPlacedNotification, ListingSoldNotification, and the eventType-tagged -// ListingGroupNotification family. The seller does NOT receive bidder-targeted pushes -// (BidderGroupNotification, SettlementCompletedNotification). -// -// Structural discrimination: no uniform wire discriminator (M8-S3b finding). Parse -// most-specific-first and assign a client-side `kind` discriminator. +// joins listing:{listingId} groups for auction-lifecycle notifications and bidder:{participantId} +// for obligation-lifecycle pushes. Parse most-specific-first; assign a client-side `kind`. const bidPlacedSchema = z.object({ listingId: z.string(), @@ -26,6 +21,14 @@ const listingSoldSchema = z.object({ soldAt: z.string(), }); +const bidderGroupSchema = z.object({ + bidderId: z.string(), + listingId: z.string().nullish(), + eventType: z.string(), + payload: z.string(), + occurredAt: z.string(), +}); + const listingGroupSchema = z.object({ listingId: z.string(), eventType: z.string(), @@ -51,6 +54,14 @@ export type HubMessage = bidCount: number; soldAt: string; } + | { + kind: "bidderEvent"; + bidderId: string; + listingId: string | null; + eventType: string; + payload: string; + occurredAt: string; + } | { kind: "listingEvent"; listingId: string; @@ -70,6 +81,18 @@ export function parseHubMessage(payload: unknown): HubMessage | null { return { kind: "listingSold", ...listingSold.data }; } + const bidderEvent = bidderGroupSchema.safeParse(payload); + if (bidderEvent.success) { + return { + kind: "bidderEvent", + bidderId: bidderEvent.data.bidderId, + listingId: bidderEvent.data.listingId ?? null, + eventType: bidderEvent.data.eventType, + payload: bidderEvent.data.payload, + occurredAt: bidderEvent.data.occurredAt, + }; + } + const listingEvent = listingGroupSchema.safeParse(payload); if (listingEvent.success) { return { kind: "listingEvent", ...listingEvent.data }; @@ -78,6 +101,6 @@ export function parseHubMessage(payload: unknown): HubMessage | null { return null; } -export function listingIdOf(message: HubMessage): string { +export function listingIdOf(message: HubMessage): string | null { return message.listingId; } diff --git a/docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md b/docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md new file mode 100644 index 0000000..d62419b --- /dev/null +++ b/docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md @@ -0,0 +1,167 @@ +# M9-S6: Seller SPA — Obligation Fulfillment + +**Milestone:** M9 ([Seller Console](../../milestones/M9-seller-console.md)) +**Slice:** S6 of M9 (fourth frontend-heavy seller slice — post-sale obligation tracking + provide-tracking form) +**Narrative:** `docs/narratives/006-seller-fulfills-post-sale-obligation.md` (Moments 1–4 — obligation begins, reminder nudges, seller ships, auto-confirm fulfills) + `docs/narratives/007-seller-recovers-missed-shipping-deadline.md` (Moments 1–3 — deadline escalates, late tracking recovers, auto-confirm fulfills) +**Agent:** @PSA +**Estimated scope:** one PR, ~20–25 files (obligation queries, provide-tracking form, obligation status page, message parsing extension, tests, retro) + +--- + +## Preconditions + +This prompt assumes M9-S5 shipped (PR #109, `0fdfcd6`). The seller SPA has: registration, listing management (create/edit/submit), dashboard, listing detail page with live auction observation (SignalR-wired, reserve indicator, terminal outcomes). The seller's `parseHubMessage` parses `BidPlacedNotification`, `ListingSoldNotification`, and `ListingGroupNotification` — it does NOT parse `BidderGroupNotification`. The seller joins `listing:{listingId}` groups for live auction observation and `bidder:{participantId}` for session-level pushes, but obligation-lifecycle pushes (`ObligationFulfilled`, `TrackingInfoProvided`) arrive as `BidderGroupNotification` on `bidder:{sellerId}` and are currently dropped (return `null`). + +## Goal + +Build the seller's obligation fulfillment surface: a new `/obligations` route showing the seller's post-sale obligations (from `GET /api/obligations/status?sellerId=X`), a provide-tracking form (driving `POST /api/obligations/tracking`), ship-by deadline countdown, status-driven UI (awaiting shipment → shipped → fulfilled; escalated → shipped → fulfilled), and live updates via BidderGroupNotification parsing for obligation events. This is the seller's **first actor surface** since S4b — unlike S5's observer-protagonist, here GreyOwl12 submits the tracking form (narrative 006 Moment 3). + +## Context to load + +| File | Purpose | +|---|---| +| `docs/milestones/M9-seller-console.md` | Authoritative for scope. §7 S6 row. | +| `CLAUDE.md` | Routing layer, global conventions, §Frontend. | +| `.claude/skills/frontend-slice-discipline/SKILL.md` | **Required** — verification ladder, live-smoke rules. | +| `docs/narratives/006-seller-fulfills-post-sale-obligation.md` | Happy-path obligation lifecycle (Moments 1–4). | +| `docs/narratives/007-seller-recovers-missed-shipping-deadline.md` | Escalation → late-tracking recovery (Moments 1–3). | +| `client/seller/src/` | The S5 foundation to build on. | +| `client/seller/src/listings/CreateListingPage.tsx` | `react-hook-form` + Zod + `zodResolver` precedent for the provide-tracking form. | +| `client/seller/src/listings/mutations.ts` | TanStack Mutation + cache invalidation pattern. | +| `client/seller/src/listings/formSchemas.ts` | Zod form schema precedent. | +| `client/seller/src/signalr/messages.ts` | Current message parser (needs BidderGroupNotification extension). | +| `client/seller/src/signalr/cacheBridge.ts` | Current cache bridge (needs obligation query invalidation). | +| `client/bidder/src/signalr/messages.ts` | Bidder's `bidderGroupSchema` and `bidderEvent` kind as precedent. | +| `src/CritterBids.Obligations/GetSellerObligationsEndpoint.cs` | `GET /api/obligations/status?sellerId=X` — the query endpoint. | +| `src/CritterBids.Obligations/ProvideTrackingEndpoint.cs` | `POST /api/obligations/tracking` — the command endpoint. | +| `src/CritterBids.Obligations/ObligationStatusView.cs` | Read-model shape: `Id`, `ListingId`, `SellerId`, `WinnerId`, `HammerPrice`, `Status`, `ShipByDeadline`, `TrackingNumber`, `ReminderSentAt`, `TrackingProvidedAt`, `FulfilledAt`, `EscalatedAt`, `DisputeId`, `DisputeReason`, `DisputeOpenedAt`, `DisputeResolution`, `DisputeResolvedAt`. | +| `src/CritterBids.Obligations/ObligationStatus.cs` | Enum: `AwaitingShipment`, `Shipped`, `Escalated`, `Fulfilled`, `Disputed`. | +| `src/CritterBids.Obligations/ObligationCommands.cs` | `ProvideTracking(ObligationId, TrackingNumber)` — the command shape. | +| `src/CritterBids.Relay/Handlers/ObligationsRelayHandlers.cs` | Relay push routing: `TrackingInfoProvided` → `bidder:{WinnerId ?? SellerId}`; `ObligationFulfilled` → `bidder:{WinnerId}` + `bidder:{SellerId}`; both as `BidderGroupNotification`. | + +## In scope + +### S6-1: Obligation status Zod schema + query hook + +Add a Zod schema for the `ObligationStatusView` response shape. The backend returns an array of obligation records, each with: `id`, `listingId`, `sellerId`, `winnerId`, `hammerPrice`, `status` (string enum), `shipByDeadline`, `trackingNumber`, `reminderSentAt`, `trackingProvidedAt`, `fulfilledAt`, `escalatedAt`, `disputeId`, `disputeReason`, `disputeOpenedAt`, `disputeResolution`, `disputeResolvedAt`. All timestamp fields are nullable ISO strings; `status` is one of `AwaitingShipment | Shipped | Escalated | Fulfilled | Disputed`. + +Add a `useSellerObligations(sellerId)` query hook backed by `GET /api/obligations/status?sellerId=X`. Query key: `["sellerObligations", sellerId]`. + +### S6-2: Provide-tracking mutation + +Add a `useProvideTracking(sellerId)` mutation that POSTs to `/api/obligations/tracking` with `{ obligationId, trackingNumber }`. On success, invalidate `["sellerObligations", sellerId]`. Follow the S4a/S4b mutation pattern (`useMutation` + `useQueryClient` + `invalidateQueries` on success). + +### S6-3: Provide-tracking form schema + +Add a Zod form schema for the tracking entry form. Two fields: `trackingNumber` (required, non-empty string). The `obligationId` comes from the obligation record, not the form. Follow the `formSchemas.ts` precedent: `z.object()` with `.superRefine()` for validation messages. + +### S6-4: BidderGroupNotification parsing + +Extend the seller's `parseHubMessage` in `messages.ts` to parse `BidderGroupNotification`. The wire shape is `{ bidderId, listingId?, eventType, payload, occurredAt }` (same schema the bidder uses as `bidderGroupSchema`). Add a `bidderEvent` kind to the seller's `HubMessage` union. The seller cares about obligation-lifecycle eventTypes: `TrackingInfoProvided`, `ObligationFulfilled`. Other bidder-group events are accepted by the parser (unknown eventTypes are not fatal) but only the obligation-related ones drive cache invalidation. + +Parse order: insert the bidderGroup parse before the listingGroup parse (same as bidder) so a `BidderGroupNotification` with `listingId` is not misidentified as a `ListingGroupNotification`. + +### S6-5: Cache bridge extension + +Extend `applyHubMessage` to invalidate `["sellerObligations"]` on `bidderEvent` messages whose `eventType` is `TrackingInfoProvided` or `ObligationFulfilled`. The existing listing/sellerListings invalidations continue unchanged for auction-lifecycle messages. + +### S6-6: Obligations page at `/obligations` + +A new route and page component. The seller navigates here from the app shell navigation. The page shows the seller's obligations in a list/grid. Each obligation card renders: + +**Status-driven display:** +- **Awaiting Shipment** — "Ship your item by \." Ship-by deadline countdown (relative time: "in X hours" or "in X days"; past deadline: "Overdue"). Reminder banner if `reminderSentAt` is set: "Reminder sent — ship before your deadline." "Provide Tracking" button opening the tracking form. +- **Escalated** — "Overdue — your deadline passed; this sale is under review." Still shows the "Provide Tracking" button (narrative 007: the door is still open for late tracking recovery). +- **Shipped** — "Shipped — tracking #\; delivery confirmation pending." No action needed. Shows `trackingProvidedAt` timestamp. +- **Fulfilled** — "Completed." Terminal state with green treatment. Shows `fulfilledAt` timestamp. +- **Disputed** — "Dispute open (\)." Shows `disputeOpenedAt`. If resolved: "Dispute resolved (\)." No action for the seller in MVP disputes. + +**Common fields on every card:** +- Listing reference: hammer price (`formatUsd`), obligation ID (abbreviated) +- `ListingId` for cross-reference (link to `/listings/$id` if the listing detail page exists) + +### S6-7: Provide-tracking dialog/form + +A dialog or inline form rendered when the seller clicks "Provide Tracking" on an AwaitingShipment or Escalated obligation. Uses `react-hook-form` + `zodResolver` with the S6-3 form schema. Fields: tracking number (text input, required). On submit: call the `useProvideTracking` mutation with `{ obligationId, trackingNumber }`. On success: close the form, show success feedback, the cache bridge re-fetch shows the obligation as "Shipped". On error: show the error message inline. + +Follow the S4a `CreateListingPage` pattern: `useForm` + `zodResolver`, `register()`, `handleSubmit()`, inline error messages via `formState.errors`. + +### S6-8: App shell navigation + +Add an "Obligations" nav link to the app shell so the seller can navigate between "My Listings" and "Obligations" (and Home). The obligation link should show a visual indicator (e.g. badge count) if there are obligations in actionable states (AwaitingShipment or Escalated). + +### S6-9: Tests + +- **Schema tests:** Validate the obligation status Zod schema against known payload shapes. +- **Mutation tests:** Validate `useProvideTracking` sends correct payload, invalidates correct query keys. +- **Message parsing tests:** Validate the extended `parseHubMessage` parses `BidderGroupNotification` into `bidderEvent` kind; verify existing auction-lifecycle parsing is unchanged. +- **Cache bridge tests:** Validate `applyHubMessage` invalidates `["sellerObligations"]` on obligation-related `bidderEvent` messages. +- **Obligations page tests:** Render with mock obligation data; verify status-driven display for each obligation status (AwaitingShipment, Escalated, Shipped, Fulfilled, Disputed); verify deadline countdown; verify reminder banner; verify "Provide Tracking" button visibility. +- **Provide-tracking form tests:** Render form; verify validation (empty tracking number rejected); verify submit calls mutation; verify success closes form. + +### S6-10: Live smoke + +- Run `dotnet run --project src/CritterBids.AppHost --launch-profile http` +- Open the seller SPA at `http://localhost:5175/seller/` +- Verify: obligations route loads; with no obligations, shows empty state +- Verify: the "Obligations" nav link appears in the app shell +- (Full obligation lifecycle requires a sold listing + settlement completion — record what's observable from the seller's entry point) +- Record smoke findings in the retro + +### S6-11: Retrospective + +- `docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md` + +## Explicitly out of scope + +- **Backend changes.** Zero `.cs` touches. The obligation query endpoint (`GET /api/obligations/status?sellerId=X`), the provide-tracking endpoint (`POST /api/obligations/tracking`), and the Relay obligation push handlers all exist and are live. The `ObligationStatusView` projection is Inline and up-to-date. +- **Dispute actions from the seller console.** The seller sees dispute status (read-only) but cannot open or resolve disputes — that's operator-side (ops dashboard, M8-S6b). +- **Settlement summary view.** The milestone doc mentions settlement summary as a separate surface; the `ObligationStatusView` carries `HammerPrice` which is sufficient for the post-sale context. A dedicated settlement query is out of scope for S6. +- **Dashboard obligation-status enrichment.** The my-listings dashboard (S4a/S4b) shows Selling BC lifecycle only. Enriching it with obligation status (e.g. "Awaiting Shipment" on a sold listing card) would require cross-BC data joining on the dashboard — out of scope; the obligations page is the dedicated surface. +- **`@critterbids/shared` message extraction.** The seller's message parser now adds `bidderEvent`, increasing overlap with the bidder. Extraction remains deferred to M9-S7. +- **Cache-bridge burst-final hardening.** Carry-forward from M8-S7; deferred to M9-S7. +- **`docs/STATUS.md` regeneration.** Deferred to M9-S7. +- **Email/push notification for obligation reminders.** Relay is SignalR-only at MVP. + +## Conventions to pin or follow + +- **Frontend-slice-discipline** — all rules apply. Rule 1: read backend shapes before writing client code (done — `ObligationStatusView`, `ProvideTracking`, Relay handlers all audited). Rule 3: live smoke. +- **ADR 026 (SignalR integration pattern)** — push is a re-query signal; cache bridge invalidates queries; the obligation page re-queries on `ObligationFulfilled` push. +- **`react-hook-form` + `zodResolver` pattern** — established in S4a/S4b for create-draft and edit-draft forms; the provide-tracking form follows the same pattern (smaller surface: one field). +- **TanStack Mutation + cache invalidation** — `mutations.ts` pattern from S4a/S4b: `useMutation` + `useQueryClient` + `invalidateQueries` on success. +- **shadcn/ui components are locally owned** — copy new components as needed (e.g. dialog if used for the tracking form). + +## Spec delta + +Per ADR 020: this slice has **significant spec consequence**. Narrative 006 Moments 1–4 (obligation begins, reminder nudges, seller ships, auto-confirm fulfills) gain concrete frontend implementations from the seller's vantage. Narrative 007 Moments 1–3 (deadline escalates, late tracking recovers, auto-confirm fulfills) gain UX surface: the Escalated status shows "Overdue — under review" with the tracking form still available. The milestone doc's exit criteria advance: "Obligation management — the seller console surfaces the `ObligationStatusView` for the seller's own listings and drives the `ProvideTracking` command." + +## Acceptance criteria + +- [ ] Obligation status Zod schema validates `ObligationStatusView` response shape +- [ ] `useSellerObligations(sellerId)` query hook fetches from `GET /api/obligations/status?sellerId=X` +- [ ] `useProvideTracking` mutation POSTs to `/api/obligations/tracking` with `{ obligationId, trackingNumber }` +- [ ] Provide-tracking mutation invalidates `["sellerObligations", sellerId]` on success +- [ ] Seller `parseHubMessage` extended to parse `BidderGroupNotification` into `bidderEvent` kind +- [ ] Existing auction-lifecycle message parsing unchanged (bidPlaced, listingSold, listingEvent all still work) +- [ ] Cache bridge invalidates `["sellerObligations"]` on obligation-related `bidderEvent` messages +- [ ] Obligations page at `/obligations` renders obligation status per lifecycle state +- [ ] Awaiting Shipment: deadline countdown + reminder banner + "Provide Tracking" button +- [ ] Escalated: "Overdue" messaging + "Provide Tracking" button still available +- [ ] Shipped: tracking number + timestamp + "delivery confirmation pending" +- [ ] Fulfilled: "Completed" terminal state +- [ ] Disputed: dispute reason + status (read-only) +- [ ] Provide-tracking form uses `react-hook-form` + `zodResolver` with validation +- [ ] App shell navigation includes "Obligations" link +- [ ] `npm run build` succeeds for seller app (TypeScript strict, no errors) +- [ ] Existing frontend baselines preserved: bidder 25 tests, ops 47 tests +- [ ] Seller Vitest count grows from 84 +- [ ] Live smoke against Aspire stack recorded in retro +- [ ] No backend changes (zero `.cs` files touched) +- [ ] No commit to `main`; one PR off `main`; no `Co-Authored-By` trailer +- [ ] `docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md` written + +## Open questions + +- **Obligations page layout.** Should obligations be a full-page list/grid at `/obligations`, or a section within an expanded listing detail page? Lean: dedicated `/obligations` route — the obligation lifecycle is distinct from the auction lifecycle; mixing them on the detail page overloads the seller's vantage. The dashboard links to `/listings/$id` for auctions and `/obligations` for post-sale tracking. +- **Provide-tracking form placement.** Inline expansion on the obligation card, or a dialog/modal? Lean: dialog — follows the S4b `EditDraftDialog` precedent; keeps the obligation list stable while the form is open. If the form is simple enough (one field), inline expansion is also acceptable. +- **Deadline countdown granularity.** Should the ship-by countdown show real-time seconds ticking, or a human-friendly relative format ("in 2 hours", "in 3 days")? Lean: human-friendly relative format with periodic refresh (the page already re-queries on cache invalidation; the countdown is informational, not real-time critical). In demo mode the deadline is seconds away, so "less than a minute" is the likely display. diff --git a/docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md b/docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md new file mode 100644 index 0000000..5c446f6 --- /dev/null +++ b/docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md @@ -0,0 +1,223 @@ +# M9-S6: Seller SPA — Obligation Fulfillment - Retrospective + +**Date:** 2026-06-13 +**Milestone:** M9 - Seller Console +**Slice:** S6 - Obligation fulfillment (post-sale tracking + provide-tracking form) +**Agent:** @PSA +**Prompt:** `docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md` +**Duration:** ~2h + +## Baseline + +- Clean main at `0fdfcd6` (M9-S5 shipped) +- Seller SPA: 84 Vitest tests passing, build clean, TypeScript strict clean +- Bidder SPA: 25 tests (baseline), ops SPA: 47 tests (baseline) +- Seller `parseHubMessage` parsed `BidPlacedNotification`, `ListingSoldNotification`, `ListingGroupNotification` — no `BidderGroupNotification` +- No obligation-related UI surface; no `/obligations` route +- `GET /api/obligations/status?sellerId=X` and `POST /api/obligations/tracking` both existed and were live + +## Items completed + +| Item | Description | +|------|-------------| +| S6-1 | Obligation status Zod schema + `useSellerObligations(sellerId)` query hook | +| S6-2 | `useProvideTracking` mutation with cache invalidation | +| S6-3 | Tracking form Zod schema (`trackingFormSchema`) | +| S6-4 | BidderGroupNotification parsing — `bidderEvent` kind added to seller message parser | +| S6-5 | Cache bridge extended — `sellerObligations` invalidated on obligation-related bidder events | +| S6-6 | Obligations page at `/obligations` with status-driven display per lifecycle state | +| S6-7 | Provide-tracking dialog (`react-hook-form` + `zodResolver`) | +| S6-8 | App shell navigation — "Obligations" link with actionable-count badge | +| S6-9 | Tests — 33 new tests across 4 test files | +| S6-10 | Live smoke (HTTP contract verified) | +| S6-11 | Retrospective (this file) | + +## S6-1: Obligation status schema + query + +The `ObligationStatusView` Zod schema (`obligations/schema.ts`) mirrors the backend's read model exactly — 17 fields including the full dispute lifecycle. The `obligationStatusEnum` covers all five `ObligationStatus` values: `AwaitingShipment`, `Shipped`, `Escalated`, `Fulfilled`, `Disputed`. + +The `useSellerObligations(sellerId)` hook follows the established `sellerListingsQueryOptions` pattern from S4a: `queryOptions` + `fetchParsed`-style fetch + Zod parse at the boundary. + +Query key: `["sellerObligations", sellerId]` — distinct from `["sellerListings", sellerId]` so cache invalidation targets the right surface. + +## S6-2: Provide-tracking mutation + +`useProvideTracking(sellerId)` follows the S4a/S4b `useMutation` pattern exactly: POST to `/api/obligations/tracking` with `{ obligationId, trackingNumber }`, invalidate `["sellerObligations", sellerId]` on success. The mutation takes a composite argument `{ obligationId, values }` so the form values and obligation identity travel together. + +**Why `obligationId` is not in the form:** The backend command is `ProvideTracking(ObligationId, TrackingNumber)`. The `ObligationId` comes from the obligation record (the card the seller clicked on), not from user input. Putting it in the form schema would make it a hidden field — cleaner to pass it alongside the form values at mutation call time. + +## S6-3: Tracking form schema + +Minimal schema: `{ trackingNumber: z.string().min(1, "Tracking number is required") }`. No carrier field — the frozen `ProvideTracking` contract carries `TrackingNumber` only (narrative 006 notes carrier is aspirational until an additive contract change). + +## S6-4: BidderGroupNotification parsing + +The seller's `parseHubMessage` now parses four notification shapes (was three). The `bidderGroupSchema` mirrors the bidder's exactly: `{ bidderId, listingId?, eventType, payload, occurredAt }`. Parse order: bidPlaced → listingSold → **bidderGroup** → listingGroup (bidderGroup before listingGroup because a `BidderGroupNotification` with `listingId` also satisfies the `listingGroupSchema`'s looser requirements). + +**Why this matters:** The Relay obligation handlers push `ObligationFulfilled` and `TrackingInfoProvided` to `bidder:{sellerId}` as `BidderGroupNotification`. Without S6-4, these pushes were silently dropped (returned `null`). With S6-4, they flow through the cache bridge and trigger obligation query re-fetch — the seller's obligation page updates live when the auto-confirm fires. + +`listingIdOf` return type changed from `string` to `string | null` to accommodate `bidderEvent` messages where `listingId` may be null. + +## S6-5: Cache bridge extension + +`applyHubMessage` now checks for `bidderEvent` messages with obligation-related `eventType` values (`TrackingInfoProvided`, `ObligationFulfilled`) and invalidates `["sellerObligations"]`. The existing `["listing", id]` and `["sellerListings"]` invalidations continue for all message kinds (with a null guard on `listingId` for bidder events that may carry no listing). + +## S6-6: Obligations page + +The `/obligations` route renders the seller's obligations in a responsive grid. Each obligation card shows: + +- **Header:** hammer price + abbreviated obligation ID + status badge +- **Status-driven detail:** renders different content per `ObligationStatus`: + - `AwaitingShipment`: deadline countdown (relative time) + reminder banner if set + "Provide Tracking" button + - `Escalated`: "Overdue — your deadline passed; this sale is under review." + still shows tracking button (narrative 007's recovery door) + - `Shipped`: tracking number + "delivery confirmation pending" + timestamp + - `Fulfilled`: "Completed." (green) + fulfilled timestamp + - `Disputed`: dispute reason (if open) or resolution (if resolved) + timestamps + +**Deadline countdown:** Uses a human-friendly relative format (`formatRelativeDeadline`): "in X hours", "in X days", "less than a minute", or "Overdue" if past. In demo mode the deadline is seconds away, so "less than a minute" is the likely display. + +**`useActionableObligationCount` hook:** Exported from the obligations page for the nav badge. Counts obligations in `AwaitingShipment` or `Escalated` status. + +## S6-7: Provide-tracking dialog + +Follows the S4b `EditDraftDialog` pattern exactly: fixed-position overlay, Escape-key dismiss, backdrop-click dismiss, `react-hook-form` + `zodResolver`, inline error messages, mutation-pending state on the submit button. + +One field: tracking number (text input, required). The obligation context (hammer price, abbreviated ID) is shown in the dialog header for context. + +## S6-8: App shell navigation + +Added "Obligations" nav link alongside "My Listings". The `ObligationsNavLink` component renders an actionable-count badge (destructive variant) when there are obligations in `AwaitingShipment` or `Escalated` status — the visual indicator the prompt specified. + +## S6-9: Tests + +| File | Tests | Coverage | +|------|-------|----------| +| `schema.test.ts` | 9 | All five obligation statuses + list schema + rejection of unknown status + missing fields + empty array | +| `messages.test.ts` | +1 (now 9) | BidderGroupNotification with listingId + BidderGroupNotification with null listingId (replaced the "returns null" test) | +| `cacheBridge.test.ts` | +3 (now 7) | ObligationFulfilled invalidates obligations, TrackingInfoProvided invalidates obligations, non-obligation bidderEvent does NOT invalidate obligations | +| `ObligationsPage.test.tsx` | 11 | Empty state, AwaitingShipment (deadline + tracking button), reminder banner, Escalated (overdue + tracking button), Shipped (tracking number), Fulfilled (completed), Disputed (open + resolved), hammer price display, error state, overdue deadline text | +| `ProvideTrackingDialog.test.tsx` | 7 | Dialog rendering, obligation context, validation (empty rejected), submit sends correct payload, onClose on success, onClose on cancel, error display on failure | +| `listingIdOf tests` | +2 (now 5) | bidderEvent with null listing, bidderEvent with listing | + +All test files use the established seller test patterns: `vi.stubGlobal("fetch")` for API mocking, TanStack Router memory history for route testing, `SessionProvider` for session context. + +## Test results + +| Phase | Seller Tests | Bidder Tests | Ops Tests | Result | +|-------|-------------|-------------|-----------|--------| +| Baseline (S5 close) | 84 | 25 | 47 | Pass | +| S6-1 + S6-3 (schema + form) | 93 (+9) | 25 | 47 | Pass | +| S6-4 + S6-5 (messages + cache) | 99 (+6) | 25 | 47 | Pass | +| S6-6 + S6-7 + S6-8 (UI) | 117 (+18) | 25 | 47 | Pass | +| **Final** | **117** | **25** | **47** | **Pass** | + +Seller test count: 84 → 117 (+33 tests, +39%). + +## Build state at session close + +- **Errors:** 0 (all three SPAs) +- **TypeScript strict:** Clean (`tsc --noEmit` passes for seller) +- **Vite build:** Clean (seller dist generated, 6 precache entries) +- **Backend:** Zero `.cs` files touched — `dotnet build` verified 0 errors / 2 warnings (existing) +- **Seller routes:** 5 (home, listings, listings/new, listings/$id, **obligations**) +- **Seller `HubMessage` union members:** 4 (was 3; added `bidderEvent`) +- **Seller mutation hooks:** 4 (`useCreateDraft`, `useEditDraft`, `useSubmitListing`, **`useProvideTracking`**) +- **`react-hook-form` + `zodResolver` usage:** 3 forms (CreateListingPage, EditDraftDialog, **ProvideTrackingDialog**) + +## Key learnings + +1. **BidderGroupNotification is the obligation-lifecycle push channel for the seller.** The Relay obligation handlers push to `bidder:{sellerId}` as `BidderGroupNotification`, not to `listing:{listingId}` groups. This means the seller's `bidder:{participantId}` group (which was already joined at connection time for session-level events) receives obligation pushes. The S5 parser dropped these (returned `null`); S6 activates them. + +2. **Actor forms are simpler when the identity comes from context, not the form.** The provide-tracking form has one user field (`trackingNumber`). The `obligationId` comes from the obligation record the seller clicked on. This is cleaner than the create-draft form (which derives `sellerId` from the session) because the obligation identity is fully determined before the form opens. + +3. **Escalated + tracking form available = narrative 007's recovery UX.** The non-terminal escalation's most important frontend property is that the "Provide Tracking" button remains visible. The seller sees "Overdue — under review" but can still act. This required no special logic — just including `Escalated` in the `ACTIONABLE_STATUSES` set alongside `AwaitingShipment`. + +4. **`listingIdOf` return type change propagates cleanly.** Changing from `string` to `string | null` to handle `bidderEvent` messages only affected `cacheBridge.ts` (null guard on the `["listing", id]` invalidation). TypeScript strict caught the change at compile time. + +5. **Test async rendering is essential with TanStack Router.** The `ProvideTrackingDialog` tests initially failed because the router's first render cycle produces an empty DOM. Using `findByRole` (async) instead of `getByRole` (synchronous) for the first assertion resolved this — same pattern the bidder app uses. + +## Findings against narratives + +**Narrative 006:** `docs/narratives/006-seller-fulfills-post-sale-obligation.md` + +- **Moments 1–4 implemented as drafted.** The seller sees obligation status (Moment 1: "Awaiting Shipment"), reminder banner (Moment 2: "Reminder sent"), provide-tracking form (Moment 3: the one actor beat), and fulfilled terminal (Moment 4: "Completed"). +- **No drift found.** The `ProvideTracking(ObligationId, TrackingNumber)` command shape matches the form exactly. The `ObligationStatusView` read model provides all fields the page needs. +- **`document-as-intentional`:** The narrative mentions a "carrier" field alongside the tracking number (Moment 3: "carrier and tracking number"). The frozen `ProvideTracking` contract carries `TrackingNumber` only — no `Carrier` field. The narrative's carrier mention is aspirational (flagged in narrative 006's Document History); the form sends `trackingNumber` only, matching the lived contract. + +**Narrative 007:** `docs/narratives/007-seller-recovers-missed-shipping-deadline.md` + +- **Moments 1–3 UX implemented.** Escalated status shows "Overdue — your deadline passed; this sale is under review." (Moment 1). The tracking form remains available on escalated obligations (Moment 2: recovery door). Auto-confirm to fulfilled (Moment 3) shows "Completed" terminal. +- **No drift found.** The narrative's defining property — non-terminal escalation with late-tracking recovery — maps directly to `ACTIONABLE_STATUSES.has("Escalated")`. + +## Spec delta — landed? + +Prompt declared significant spec consequence: narratives 006 Moments 1–4 and 007 Moments 1–3 gain concrete frontend implementations. **Landed as written.** The seller can now view obligation status, provide tracking, and observe obligation lifecycle through the UI. The milestone doc's exit criteria advance: "Obligation management — the seller console surfaces the `ObligationStatusView` for the seller's own listings and drives the `ProvideTracking` command." No narrative or workshop amendment was required. + +## Verification checklist + +- [x] Obligation status Zod schema validates `ObligationStatusView` response shape +- [x] `useSellerObligations(sellerId)` query hook fetches from `GET /api/obligations/status?sellerId=X` +- [x] `useProvideTracking` mutation POSTs to `/api/obligations/tracking` with `{ obligationId, trackingNumber }` +- [x] Provide-tracking mutation invalidates `["sellerObligations", sellerId]` on success +- [x] Seller `parseHubMessage` extended to parse `BidderGroupNotification` into `bidderEvent` kind +- [x] Existing auction-lifecycle message parsing unchanged (bidPlaced, listingSold, listingEvent all still work) +- [x] Cache bridge invalidates `["sellerObligations"]` on obligation-related `bidderEvent` messages +- [x] Obligations page at `/obligations` renders obligation status per lifecycle state +- [x] Awaiting Shipment: deadline countdown + reminder banner + "Provide Tracking" button +- [x] Escalated: "Overdue" messaging + "Provide Tracking" button still available +- [x] Shipped: tracking number + timestamp + "delivery confirmation pending" +- [x] Fulfilled: "Completed" terminal state +- [x] Disputed: dispute reason + status (read-only) +- [x] Provide-tracking form uses `react-hook-form` + `zodResolver` with validation +- [x] App shell navigation includes "Obligations" link +- [x] `npm run build` succeeds for seller app (TypeScript strict, no errors) +- [x] Existing frontend baselines preserved: bidder 25 tests, ops 47 tests +- [x] Seller Vitest count grew from 84 to 117 (+33) +- [x] Live smoke against Aspire stack recorded in retro +- [x] No backend changes (zero `.cs` files touched) +- [x] No commit to `main`; one PR off `main`; no `Co-Authored-By` trailer +- [x] This retrospective written + +## Live smoke results + +**HTTP contract verification:** +1. `GET /api/obligations/status?sellerId=` → 200, returns array (empty when no obligations exist) ✓ +2. `POST /api/obligations/tracking` with `{ obligationId: "", trackingNumber: "TEST123" }` → endpoint exists and accepts the payload shape ✓ +3. Seller SPA `/obligations` route loads and shows empty state ✓ +4. "Obligations" nav link visible in app shell when seller is registered ✓ + +**Live-obligation lifecycle limitation (expected):** A full obligation lifecycle (AwaitingShipment → provide tracking → Shipped → Fulfilled) requires a sold listing + settlement completion + obligation saga start. This is an end-to-end flow requiring operator session start + bidder bids + settlement → obligation propagation. The smoke verified the seller's entry point (obligations page loads, endpoint responds, empty state renders). Full lifecycle verification deferred to M9-S7 Playwright e2e. + +## What remains / next session should verify + +- **M9-S7 (e2e + housekeeping):** Closing milestone slice. Playwright seller-perspective e2e, CI frontend job extension, CLAUDE.md updates, STATUS.md regeneration, skills audit, M9 retrospective. +- **Full obligation lifecycle e2e:** A Playwright test that runs: seller publishes → operator starts session → bidder bids → settlement → obligation → seller provides tracking → fulfilled. +- **Cache-bridge burst-final hardening:** Carry-forward from M8-S7. Evaluate in M9-S7. +- **`@critterbids/shared` message extraction:** Seller and bidder parsers now overlap ~65% (three shared schemas, one seller-only `bidderEvent` addition). Evaluate extraction in M9-S7. +- **`docs/STATUS.md` regeneration:** Deferred to M9-S7. +- **`FakeHubConnection` shared extraction:** Three copies across three SPAs. Extract to `@critterbids/shared` test utilities (M9 skills review). + +## Files changed + +**New:** +- `client/seller/src/obligations/schema.ts` — Obligation status Zod schema +- `client/seller/src/obligations/queries.ts` — `useSellerObligations` query hook +- `client/seller/src/obligations/mutations.ts` — `useProvideTracking` mutation +- `client/seller/src/obligations/formSchemas.ts` — Tracking form Zod schema +- `client/seller/src/obligations/ObligationsPage.tsx` — Obligations page + `useActionableObligationCount` +- `client/seller/src/obligations/ProvideTrackingDialog.tsx` — Provide-tracking dialog +- `client/seller/src/obligations/schema.test.ts` — 9 tests +- `client/seller/src/obligations/ObligationsPage.test.tsx` — 11 tests +- `client/seller/src/obligations/ProvideTrackingDialog.test.tsx` — 7 tests + +**Modified:** +- `client/seller/src/signalr/messages.ts` — Added `bidderGroupSchema` + `bidderEvent` kind; `listingIdOf` returns `string | null` +- `client/seller/src/signalr/cacheBridge.ts` — Obligation query invalidation on bidder events +- `client/seller/src/router.tsx` — Added `obligationsRoute` +- `client/seller/src/components/AppShell.tsx` — "Obligations" nav link with actionable-count badge +- `client/seller/src/signalr/messages.test.ts` — Updated BidderGroupNotification tests + `listingIdOf` null tests +- `client/seller/src/signalr/cacheBridge.test.ts` — Added 3 obligation cache invalidation tests + +**Docs:** +- `docs/prompts/implementations/M9-S6-seller-obligation-fulfillment.md` — Session prompt +- `docs/retrospectives/M9-S6-seller-obligation-fulfillment-retrospective.md` — This file