Skip to content

Drupal product-term data contract (#110)

How NanaSelect reads product knowledge from the nanawalld8 site's products taxonomy vocabulary, and exactly which fields are part of the contract. The typed source of truth is src/lib/server/drupal/contract.ts; the fetch implementation is src/lib/server/drupal/fetch.ts.

Transport decision

Chosen: Drupal core JSON:API (/jsonapi/taxonomy_term/products), which is already enabled site-wide on www.nanawall.com in read-only mode.

OptionVerdict
Core JSON:API (chosen)Already live and read-only; resolves entity references via include; zero Drupal-side work; typed contract lives entirely in this app.
Reuse nanapad JSON views (app_products etc.)Display-oriented field sets tuned for the iPad app; would couple the sync to view config that changes for other reasons; no include-style reference resolution.
New export endpoint on the Drupal sideCleanest possible payload, but requires building and deploying nanawalld8 changes — not worth it while JSON:API covers the need.

Live-site quirks the fetch layer works around

  • page[offset] is rejected at the edge (400 "Input value "page" contains a non-scalar value"), so standard JSON:API pagination links are unusable. The fetch paginates with a tid cursor instead (sort=drupal_internal__tid plus filter[after][condition][...] > last seen tid). Filter/sort params with raw brackets pass through fine.
  • Short pages are not the end: entities the anonymous user cannot view consume page slots but are omitted from data. "More pages" is signalled by the presence of links.next only — its offset-based href is never followed.
  • Dangling references appear as the virtual missing resource or as references absent from included (unpublished terms); both degrade to warnings, never crashes.

Term identity

JSON:API fieldContract meaning
attributes.nameThe join key — matches systems.name (see drupal_system_mappings).
attributes.drupal_internal__tidDrupal term id, cached to drupal_system_mappings.drupal_term_id.
id (uuid)JSON:API resource id, cached to drupal_system_mappings.drupal_term_uuid.

field_kbmax is not a join key — it is a configurator query fragment like config=90&system=SL45L (#121).

Contract fields

Values arrive as DrupalFieldValue: term-reference fields resolve to the referenced terms' names ({ kind: 'terms', values: string[] }); booleans and strings pass through typed. A field with no value on a term is absent — never null.

Term-reference fields (31) — field → vocabulary → NanaSelect attribute candidate

Seed-bootstrapped mappings (in drupal_attribute_mappings, from #109) are marked bold; unmarked candidates are suggestions. An unmapped field is fetched but ignored by the sync engine (#111).

Mappings are editor-owned after the seed bootstrap (#130): the mapping editor on /admin/drupal-sync lists every contract field below with its live mapping state, and an editor can map a field to an existing attribute, re-point or remove a mapping, or include a Drupal-only field as a new attribute in one flow (name humanized from the machine name; value type prefilled from the field's kind — terms → enum, boolean → boolean, string → text). This table is therefore the starting inventory, not the current state — the live state is whatever the editor has authored, shown on the sync page itself.

Drupal fieldVocabularyNanaSelect attribute candidate
field_accessoriesaccessories— (no matching attribute yet)
field_applicationapplicationsideal-for-x-applications
field_cad_file_typesfile_typesbuild-your-own-export-exact-cad-revit-pdf-spec
field_configurationsconfigurations
field_directiondirectionsome-panels-inward-some-outward-opening
field_frame_styleframe_style
field_frame_typeframe_types
field_framedframed
field_glazing_typeglazingglazing-options
field_handle_optionshandle_options (via views handler)handle-options
field_hardware_optionshardware_options
field_interior_exteriorinterior_exteriorinterior-vs-exterior-suitability
field_locking_optionslocking_options (via views handler)concealed-locking
field_material_transparencymaterial_transparencymaterials
field_max_height_rangemax_height_rangemax-panel-height
field_max_panel_width_rangemax_panel_width_rangemax-panel-width
field_max_unit_width_rangemax_unit_width_rangemaximum-opening-width
field_mountmount
field_mountingmounting
field_panel_design_optionspanel_design_optionspanel-stacking-options
field_performance_ratingperformance_ratingsacoustic-rating-stc-oitc (needs review)
field_rail_and_stilerail_and_stile
field_sillsillminimum-sill-depth (needs review)
field_sill_optionsvia views handlersill-options
field_sill_typesill
field_stacking_directionstacking_directionpanel-stacking-options (needs review)
field_stacking_locationstackingpanels-parking-location
field_swing_doorswing_door_optionsswing-door-integration
field_track_typetrack_typefloor-track-required (needs review)
field_wall_designwall_design
field_weather_performanceweather_performancewater-penetration (needs review)

Boolean fields (2)

Drupal fieldNanaSelect attribute candidate
field_commercial_gradeideal-for-x-applications (commercial signal — needs review)
field_resources_configurator3d-configurator-available

String fields (0)

None currently. field_tagline was string attribute material until #1046 moved it to captured site wiring (below) — it feeds the systems.tagline column, not an attribute. The STRING_ATTRIBUTE_FIELDS const (and the string arm of the mapping editor) remains for the next plain-string attribute field.

Media fields (3)

Imagery fields, ingested by the media sync (#6) into the media library (media_assets + media_links) — a separate contract from the attribute sync, defined in media-contract.ts (MEDIA_TERM_FIELDS) and fetched by media-fetch.ts. Each reference resolves term → media--imagefield_media_imagefile--fileuri.url (or the direct-file shape). Assets are stamped source='drupal' with the Drupal entity uuid as source_ref for idempotent re-sync; admin uploads (source='manual') are never touched.

Drupal fieldUse
field_images, field_images_media, field_mediaEntity imagery → media library, matched to a system by term name.

Excluded fields (14)

Presentation-only / non-attribute fields, deliberately not requested and never emitted by the fetch layer (EXCLUDED_FIELDS in contract.ts):

FieldReason
field_appiPad-app display flag
field_files, field_specificationsfile attachments (presentation)
field_kbmax, field_kbmax_config, field_kbmax_systemconfigurator ids/fragments, not attribute data (#121)
field_new, field_popularmarketing badges
field_node, field_product_nodenode references (site wiring)
field_orderdisplay ordering
field_product_findersite product-finder flag
field_video_content_idvideo id (presentation)

Captured site wiring — field_url → PDP path (#414)

field_url is not attribute material, but it is captured by the sync as each system's product-detail-page path. It is exposed as pdpPath on DrupalProductTerm (PDP_PATH_FIELD in contract.ts, not in fields) and written to systems.pdp_path — the single input to the deep-link seam ($lib/deeplink.ts), which builds the per-system Product detail, Configure & Price, and Resources links for the buyer hand-off to nanawall.com. A missing/blank/non-string field_url yields no pdpPath; the sync then leaves the stored path untouched (it never deletes site wiring) and the product-detail affordance is omitted rather than rendered broken.

Captured system content — field_tagline → tagline (#1046)

field_tagline follows the same pattern: not attribute material, but captured by the sync onto systems.tagline — the short marketing line rendered on the buyer-facing recommended-system card. It is exposed as tagline on DrupalProductTerm (TAGLINE_FIELD in contract.ts, not in fields) and written reconcile-first: the planner emits a TaglineWrite only when the Drupal value differs from the stored one, so an unchanged value is a write-free re-run. The sync report counts these as taglineUpdated, surfaced alongside the PDP-path count in the one-line summary and the unified sync detail.

Overwrite policy — Drupal is the source of truth once imported. A changed Drupal tagline replaces the local value on the next sync. The admin field stays editable, but a hand-edited tagline persists only until the Drupal source value changes (there is no held-back/override machinery for system columns, unlike attribute values). A term with a missing/blank/non-string field_tagline yields no tagline and the stored value is left untouched — the sync never nulls it out; a payload missing the field entirely surfaces the standard shape-change fetch warning.

Failure semantics

fetchProductTerms returns { ok, terms, warnings } and never throws on shape drift:

  • ok: false — the collection could not be fully retrieved (network/HTTP/shape failure). terms may be partial; the sync engine must not treat absent terms as deletions.
  • warnings — deduplicated skips: a contract field missing from the payload, a wrong-typed value, an unresolvable reference, a nameless term. Each names the term/field where it occurred so drift is diagnosable from the sync report.