08 — Print
PDF compilation, artifacts, the print-shop directory, orders, reviews, and claim/verify.
Scopes: print:read for GETs, print:write for mutations, plus
role checks (shop owner / platform admin) where noted.
Compile & artifacts
| Method | Path | Notes |
|---|---|---|
POST | /print/compile | Render a stored artifact or an inline compiled sheet to PDF or PNG (see below) |
GET | /print/artifacts | List stored artifacts (print_artifacts) |
POST | /print/artifacts | Store an artifact |
DELETE | /print/artifacts/:id | Remove |
/print/compile is mounted at /print behind requireAuth (and is the one print route an API key with only print:read may call). Query: format=pdf|png (default pdf), pageSize=A4|A5|A6 (default A4); a bad query is 400 invalid_query. Body, strict, discriminated on source:
{ "source": "artifact", "artifactId": "pa_…" }— renders the caller's own stored artifact. An unknown or someone else's artifact is404 unknown_artifact.{ "source": "inline", "docId": "[A-Za-z0-9_-]+", "compiled": { pageMm: { w, h }, ops: [...] } }—opsarerect,circle,line,pathandtextvector ops with RGBA colours in 0–1.
A body that fails the schema is 400 invalid_compiled_sheet. Render failures: 400 unsupported_op, 400 invalid_color, 400 invalid_transform, otherwise 500 render_failed. Success is 200 with the bytes (application/pdf or image/png, non-JSON; use a raw fetch) and Content-Disposition: attachment; filename="sheet-<docId or artifactId>.<ext>". Every compile, successful or not, writes a print.compile audit row.
Directory — /print-shops
DB-backed (migration 0030), haversine-filtered:
| Method | Path | Notes |
|---|---|---|
GET | /print-shops?near=lat,lng&radius_km=10 | List / nearby search (distanceKm when filtered) |
GET | /print-shops/:id | Detail |
POST | /print-shops | Admin create — platform_admin role (user_roles), not a scope |
Owner surface
| Method | Path | Notes |
|---|---|---|
GET | /print-shops/mine | Shops owned by the caller |
PATCH | /print-shops/:id | Edit (owner only, via owner_user_id) |
GET | /print-shops/:id/orders | The shop's orders, newest first: { orders: [{ id, pickupName, pageCount, mode, artifactId, qrPayload, status, createdAt }] }. Shop owner or platform admin; scope print-shop:write. 404 unknown_shop, 403 not_owner |
PATCH | /print-shops/:id/orders/:orderId | Update an order's status. Body, strict: { status: "ready" | "picked_up" }. Forward-only (pending → ready → picked_up, pending → picked_up allowed); the same status is a no-op, going back is 409 invalid_transition. 404 unknown_order. Answers { order: { id, status } }, plus notified when the customer was told the order is ready |
POST | /print-shops/:id/claim-token | Admin-issued one-shot claim token |
POST | /print-shops/:id/claim | Redeem a token to become owner |
Reviews & replies
| Method | Path | Notes |
|---|---|---|
GET | /print-shops/:id/reviews?limit=&offset= | Public reviews, newest first, no auth. limit 1–50 (20), offset ≥ 0 (0), strict (400 invalid_query); 404 unknown_shop. { reviews: [{ id, stars, comment, author: "Verified customer", ownerReply, createdAt, updatedAt }], total, ratingAvg, ratingCount }. Only ratings with a comment are listed and comments a moderator hid are left out (their stars still count in ratingAvg/ratingCount); id is the order id |
GET | /print-shops/:id/reviews/all | Unfiltered (owner) |
POST | /print-shops/:id/reviews/:orderId/reply | Owner reply — idempotent POST |
PATCH | /print-shops/:id/reviews/:orderId/reply | Edit the reply |
DELETE | /print-shops/:id/reviews/:orderId/reply | Remove it |
POST | /print-shops/:id/reviews/:orderId/report | Report a review |
POST | /print-shops/:id/verify | Owner/admin verify |
DELETE | /print-shops/:id/verify | Unverify |
Admin extras
| Method | Path | Notes |
|---|---|---|
GET | /print-shops/admin/shops | Admin listing |
GET | /print-shops/admin/places/search | Place search (Google Places proxy) |
GET | /print-shops/admin/places/:placeId | Place detail |
GET | /print-shops/admin/reviews/reported | Moderation queue |
POST | /print-shops/admin/reviews/:orderId/:action | hide / unhide / dismiss |
Orders — /print-orders
| Method | Path | Notes |
|---|---|---|
GET | /print-orders | The caller's orders, newest first, each with rating ({ stars, comment, updatedAt } or null) |
POST | /print-orders | Place an order. Body, strict: shopId, artifactId (yours; the order copies its mode and world), pageCount (1–10000), pickupName (1–120), optional mode (gm|player|battle|poster|atlas, must equal the artifact's mode or 400 invalid_body / mode_mismatch). 404 with reason unknown_shop or unknown_artifact. 201 with the order (id po_…, status: "pending", qrPayload: "gas-print:v1:<id>") |
GET | /print-orders/:id | Detail, with rating. Someone else's order is 404 not_found, never 403, so ids cannot be probed |
PUT | /print-orders/:id/rating | Rate the shop for this order. Body, strict: { stars: 1–5, comment?: ≤1000 chars }. Only after pickup: 409 order_not_picked_up (with status). Re-PUT edits; changing the comment text clears a moderator hide and any open report. Answers { rating: { stars, comment, updatedAt } } |
DELETE | /print-orders/:id/rating | Retract your rating; 204, or 404 when there is none |
GET | /print-orders/:id/qr | The pickup code as JSON: { id, qrPayload }. Not an image; the server ships no QR codec and the client renders qrPayload (gas-print:v1:<orderId>) itself. Someone else's order is 404 |
All /print-orders routes need a resolvable caller (403 unresolved_caller otherwise) and only ever see the caller's own orders.
There is no /print-shops/:id/rating, /print-shops/:id/qr or /print-orders/:id/reviews: ratings and the QR belong to an order (/print-orders/:id/…), and a shop's reviews are GET /print-shops/:id/reviews.