@zooly/contracts is the single source of truth for the shape of offer-related API calls. Both server routes and client callers import schemas from here, so the browser and the server can never disagree on what fields an offer has or what they mean.
The package contains three things:
route() builder and any typed client consuming these endpoints.allowedActions(offer, role) — a helper that returns the list of actions a given role may perform on an offer in its current status.@zooly/contractspackages/contractszod, @zooly/typesEvery error response from route()-wrapped endpoints is { error: string, code: ErrorCode, details?: Record<string, unknown> }.
| Code | HTTP | When |
|---|---|---|
BadRequest | 400 | Zod validation failure, malformed JSON, invalid query value |
Unauthorized | 401 | No cookie / invalid cookie |
Forbidden | 403 | Authenticated but not permitted (e.g. non-admin on an admin route) |
NotFound | 404 | Resource (offer, account, notification) not found |
Conflict | 409 | State conflict — e.g. transitioning an offer to a state that is not reachable from its current one |
UnprocessableEntity | 422 | Semantically invalid payload that passed schema validation (e.g. offer in wrong status for the requested action) |
RateLimited | 429 | Per-brand or per-account rate limit |
InternalError | 500 | Unhandled error (logged server-side) |
The optional details field carries structured context for cases where the message alone isn't enough. Example from POST /api/offers/submit when the brand's daily cap is hit:
{
"error": "Daily offer limit reached (5/day)",
"code": "RateLimited",
"details": { "currentCount": 5, "limit": 5 }
}
allowedActions(offer, role)Returns the set of actions a role can perform on an offer, derived from the offer's current status.
import { allowedActions } from "@zooly/contracts";
const offer = { status: "DELIVERED" } as const;
allowedActions(offer, "brand"); // ["COMPLETE", "REQUEST_REVISION", "DISPUTE"]
allowedActions(offer, "talent"); // []
allowedActions(offer, "admin"); // []
Roles: "brand" | "talent" | "admin".
Actions: ACCEPT | COUNTER | REJECT | PAY | CANCEL | DELIVER | COMPLETE | REQUEST_REVISION | DISPUTE | ADMIN_APPROVE | ADMIN_REJECT | RESOLVE_DISPUTE.
The full 12 × 3 matrix is defined in src/allowed-actions.ts and pinned by 36 unit tests in src/allowed-actions.test.ts. The matrix is the source of truth — it was signed off under W1-01 (see Appendix A of that ticket). Flipping a cell must come with a matching test update.
Each hot endpoint has a request-query (or request-body) schema and a response schema. Example:
import {
listNotificationsQuerySchema,
listNotificationsResponseSchema,
type ListNotificationsResponse,
} from "@zooly/contracts";
const query = listNotificationsQuerySchema.parse({ limit: "5" });
// { limit: 5 }
const response: ListNotificationsResponse = await fetch(...).then(r => r.json());
listNotificationsResponseSchema.parse(response); // throws if server drifted
Schemas defined so far cover the six hot endpoints:
| Schema | Endpoint |
|---|---|
listNotificationsQuerySchema, listNotificationsResponseSchema | GET /api/notifications/list |
listOffersQuerySchema, listOffersResponseSchema, offerSchema | GET /api/offers/list |
getOfferParamsSchema, getOfferResponseSchema, offerHistoryEntrySchema | GET /api/offers/:id |
submitOfferBodySchema, submitOfferResponseSchema | POST /api/offers/submit |
respondToOfferBodySchema, respondToOfferResponseSchema | POST /api/offers/respond |
createPaymentIntentBodySchema, createPaymentIntentResponseSchema | POST /api/payments/create-intent |
Additional schemas will be added as more endpoints migrate onto the route() builder (Week 2 work).
cd packages/contracts
pnpm test
Runs under Vitest. 48 tests cover the allowedActions matrix and schema defaults / rejection cases.
route() builder that consumes these schemas server-side.