Contracts Package

Shared Zod schemas, error codes, and the offer allowed-actions helper

Overview

@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:

  1. Zod schemas for request/response payloads of the hot API routes.
  2. A shared error-code enum used by the server route() builder and any typed client consuming these endpoints.
  3. allowedActions(offer, role) — a helper that returns the list of actions a given role may perform on an offer in its current status.

Package Details

  • Package Name: @zooly/contracts
  • Location: packages/contracts
  • Type: Isomorphic (server + browser)
  • Runtime dependencies: zod, @zooly/types

Error codes

Every error response from route()-wrapped endpoints is { error: string, code: ErrorCode, details?: Record<string, unknown> }.

CodeHTTPWhen
BadRequest400Zod validation failure, malformed JSON, invalid query value
Unauthorized401No cookie / invalid cookie
Forbidden403Authenticated but not permitted (e.g. non-admin on an admin route)
NotFound404Resource (offer, account, notification) not found
Conflict409State conflict — e.g. transitioning an offer to a state that is not reachable from its current one
UnprocessableEntity422Semantically invalid payload that passed schema validation (e.g. offer in wrong status for the requested action)
RateLimited429Per-brand or per-account rate limit
InternalError500Unhandled 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.

Schemas

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:

SchemaEndpoint
listNotificationsQuerySchema, listNotificationsResponseSchemaGET /api/notifications/list
listOffersQuerySchema, listOffersResponseSchema, offerSchemaGET /api/offers/list
getOfferParamsSchema, getOfferResponseSchema, offerHistoryEntrySchemaGET /api/offers/:id
submitOfferBodySchema, submitOfferResponseSchemaPOST /api/offers/submit
respondToOfferBodySchema, respondToOfferResponseSchemaPOST /api/offers/respond
createPaymentIntentBodySchema, createPaymentIntentResponseSchemaPOST /api/payments/create-intent

Additional schemas will be added as more endpoints migrate onto the route() builder (Week 2 work).

Testing

cd packages/contracts
pnpm test

Runs under Vitest. 48 tests cover the allowedActions matrix and schema defaults / rejection cases.