Command Center

🔒 Topher-only. Internal architecture + the field inventory I owe you. Key handling, write surface, intake-page opportunity. Don't share endpoint URLs externally yet — studio is the build surface, not customer-facing.

✅ What's live right now (studio env)

One edge Worker on Cloudflare Workers (the @astrojs/cloudflare adapter). Same Worker serves both the API and the server-rendered UI, single origin, single deploy.

  • API/api/inventory, /api/inventory/[id], /api/inventory/stats
  • UI/boats listing (stats card, filter form, grid), /boats/[documentId] detail (gallery, specs, sticky inquire CTA)
  • Caching — CF Cache API, edge-scoped. Lists 5 min · single records 10 min · stats 15 min. Pass ?fresh=1 to bypass (staff/admin only).
  • SecretBOATS_COM_UJ_KEY bound on the studio Worker only. Never reaches the browser. Launch + production envs still need their own bind before promotion.
  • Deploy — git push to studio branch → GH Actions runs npm run ci:deploy:studio → wrangler deploys. Verified end-to-end at 19:19 UTC.

Live URLs to click around:
ugly-johns.studio.madebyaspire.com/boats (listing)
/boats/9473572 (2009 Supra Launch 21 detail)
/api/inventory/stats (raw JSON)

Field inventory — 110-field schema, scored on 117 records

Knowing what's safe to design around versus what shows up rarely changes how the UI behaves. The Boat detail page already does this — every section is conditional on data presence so we don't render "Beam: undefined".

100% — Always populated (51 fields)

  • DocumentID
  • SalesStatus
  • MakeString
  • Model
  • ModelYear
  • SaleClassCode
  • BoatCategoryCode
  • BoatClassCode
  • BoatHullMaterialCode
  • BoatHullID
  • NominalLength
  • LengthOverall
  • BeamMeasure
  • NumberOfEngines
  • BoatLocation.{City,State}
  • Office.{Email,Phone,City,State,…}
  • GeneralBoatDescription (HTML)
  • Images (CDN URLs)
  • ItemReceivedDate
  • LastModificationDate
  • Owner.PartyId
  • CompanyName
  • CoOpIndicator
  • PriceHideInd
  • + 26 boolean / measurement flags

97–98% — Reliable (3 fields)

  • SalesRep.{PartyId,Name}
  • YachtWorldID
  • AdditionalDetailDescription

70–96% — Most listings (6 fields)

  • Engines (with Hours) — 91%
  • TotalEnginePowerQuantity — 85%
  • Price + NormPrice + OriginalPrice — 74–75%
  • TotalEngineHoursNumeric — 70%

30–45% — Half-filled (8 fields)

  • FuelTankCount + FuelTankCapacity — 44%
  • DriveTypeCode — 43%
  • DryWeightMeasure — 41%
  • EmbeddedVideo + Videos.{title,desc,thumbnailUrl,url} — 31%
  • ListingTitle — 28%

15–22% — Sparse (6 fields)

  • MaxDraft — 22%
  • WaterTank{Count,Capacity} — 21%
  • DriveUp — 21%
  • StockNumber — 18%
  • Heads/HoldingTank — 17%
  • ExternalLink (mfr URL) — 16%

≤12% — Rare (10+ fields)

  • BridgeClearance, Deadrise, MaximumPassengers, Cruising/MaxSpeed, Windlass, LengthOfDeck, TaxStatus, SalesRep.Message, BuilderName, RangeMeasure, BoatName, CabinHeadroom, Ballast, CabinsCount, Designer, Displacement
  • Most are detail-page nice-to-haves; useful when present, never assume

Dark fields — exposed in schema, never populated

PropellerCruisingSpeed · FreeBoardMeasure · BoatKeelCode · ElectricalCircuitMeasure · WaterTankMaterialCode · FuelTankMaterialCode · HoldingTankMaterialCode

Boats Group ships these fields in every record's envelope but UJ has never filled them. We render nothing for them. Safe to ignore until proven otherwise.

🎯 The intake-page opportunity (your question)

Can we add to inventory ourselves? The probe says yes — writes exist. When I POST'd to /inventory/search, the upstream returned "Bad Request: no method or ID provided" — that's their PHP framework's __call() magic-method router leaking a routing hint. The write API exists; it's keyed by a method= param + an ID= param.

What you'd build: a salesperson takes a new boat in trade, walks out to it with the iPad, snaps a few photos (or runs the Insta360 X5 once we land that), types in the basics, hits "Submit to inventory." Within minutes the boat appears on uglyjohns.com (via the new pipe) AND on boats.com / YachtWorld (via Boats Group's syndication). Mid-sized dealers normally pay $700/listing/month for the back-office tool that does this.

Where the sparse fields become the opportunity:

  • StockNumber (18% fill rate) — invisible discipline gap. An intake page can require it. Suddenly UJ has consistent internal SKUs.
  • ListingTitle (28%) — most listings fall back to "Year Make Model". An intake form with a "Hook headline" field unlocks "Babied freshwater — turnkey" copy that converts.
  • EmbeddedVideo (31%) + Image360 (0%) — intake page makes uploading a video / 360 walkaround the default step. Within 90 days UJ has the rich-media moat that nobody else has.
  • StreetableTitle: from the customer-facing side — when a salesperson types the description, they're guided ("highlights for buyers", "what makes this one different") instead of staring at a blank box. Better copy ships everywhere syndication reaches.

What I'd want to ship Phase 1 of the intake page: internal-only URL (CF Access on the route), form covers the 100%-fill fields + the 5 highest-leverage sparse ones (StockNumber, ListingTitle, primary photos, video URL, 360 URL), POST to a new API endpoint /api/inventory/intake that I implement after mapping the upstream write API (a 1-day spike). If the upstream writes don't pan out, fallback is to write into our own D1 / Airtable, run a nightly CSV sync into BoatWizard, still net positive.

API contract — the surface Jaime designs against

Three endpoints. All return JSON. All cached at the edge. All safe to call from anywhere on the same origin (and from elsewhere by enabling CORS if needed later).

GET /api/inventory cache: 5 min edge

List boats (with filters)

ParamAllowed
statusActive (default) | sale pending | Inactive | on-order
makeexact match (e.g. Nautique, Sea Ray)
categoryPower (only value currently in use)
startpagination offset, default 0
rowspage size, default 500 (full pull)
fresh=1bypass cache (admin / staff use)
/api/inventory?status=Active&make=Nautique&rows=10
GET /api/inventory/[documentId] cache: 10 min edge

Single boat by DocumentID

ParamAllowed
documentIdnumeric (e.g. 9473572)
/api/inventory/9473572
GET /api/inventory/stats cache: 15 min edge

Aggregated counts (top brands, locations, rich-media, co-op)

ParamAllowed
fresh=1bypass cache
/api/inventory/stats

Boat shape (normalized — lean projection of the 110-field raw)

{
  documentId: string,              // primary identifier
  yachtWorldId: string?,
  stockNumber: string?,
  status: "Active" | "sale pending" | ...,
  saleClass: "New" | "Used",
  title: string,                   // ListingTitle or "year make model"
  year: number?, make: string, model: string,
  boatName: string?,               // e.g. "Wine Down"
  category: string?,               // "Power" (all current)
  classes: string[],               // e.g. ["Bowrider", "Ski and Wakeboard"]
  hull: { material?, id? },        // material, HIN
  price: {                         // null if PriceHideInd=true
    display: string,               // raw "36000.00 USD"
    numeric: number,               // 36000
    original?, hidden: boolean
  } | null,
  length: { nominal?, overall?, beam?, meters? },
  engines: { count, hours?, power?, drive? },
  location: { city?, state? },     // actual boat slip (not office)
  rep: { name?, email?, phone? },
  description: { primary?, legalNotice? },     // HTML strings
  media: {
    primaryImage?: string,
    images: [{ url, caption? }],
    hasVideo: boolean, has360: boolean, hasImmersiveTour: boolean,
    embeddedVideos: [{ url, title?, thumbnail? }]
  },
  coOp: boolean,
  externalLinks: [{ url, text, type }],
  meta: { itemReceivedDate?, lastModified? },
  raw: { /* full upstream record — escape hatch */ }
}

Defined in src/lib/boats-api.ts. The raw field is an escape hatch — anything not yet projected is reachable.

Open follow-ups — what's NOT done yet

  • Write-API mapping (1-day spike). Find the upstream method= values (create/update/delete). Crawl4ai timed out on api.boats.com/docs earlier — re-try with a longer wait, or just ask Jason Smith for the docs PDF.
  • Launch + production secret binding. When Jaime's design lands and we promote to launch, I have to wrangler secret put BOATS_COM_UJ_KEY --env launch before the first launch deploy. Same for production at cutover. Documented in the deploy notes; won't forget.
  • HTML sanitization. The description.primary field is HTML from BoatWizard. XSS surface is low (UJ staff don't have motive) but I want DOMPurify (or rehype-sanitize) on the server side before public launch. Tracked.
  • Email obfuscation. Currently the sales-rep email + phone show as plain text on the detail page (matches what Boats Group already exposes publicly). Confirm with you whether we proxy it through a Worker form to fight scrapers, or leave it (UJ may want the white-glove "call our team" signal).
  • SEO depth. Detail pages get OG image (primary boat photo) and meta description (first 200 chars of description). JSON-LD Product schema is next — boats.com may have a Google rich-result advantage UJ doesn't because the syndication strips it.
  • Image lazy-loading already on; image optimization not yet. Boats Group's CDN serves 2MB XLARGE images. Pages Worker can pipe through @astrojs/cloudflare's Image binding (already configured) for resize + WebP — cuts page weight ~70%. Phase 2.
  • Filter UX. Currently a basic GET-submit form. Once Jaime's design is in, this becomes a left-rail facet panel with brand + length + price + location + new/used.

⌛ What I'm waiting on you for

  1. Reply to Jason Smith. Draft is on the 6/10 page. Send when you're ready.
  2. Intake page — green light? Asking before I burn the 1-day spike on the write-API mapping. If yes, I'll also need to know whether the intake page lives at /staff/intake (CF Access-gated) or somewhere else.
  3. Sales-rep email/phone — public or proxied? Default is matching what Boats Group already exposes (public).

Everything else I'll handle. Continuing with the next layer of polish (sanitization, JSON-LD, image optimization, image fallbacks) while Jaime's design takes shape.