Appearance
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.
| Option | Verdict |
|---|---|
| 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 side | Cleanest 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__tidplusfilter[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 oflinks.nextonly — its offset-based href is never followed. - Dangling references appear as the virtual
missingresource or as references absent fromincluded(unpublished terms); both degrade to warnings, never crashes.
Term identity
| JSON:API field | Contract meaning |
|---|---|
attributes.name | The join key — matches systems.name (see drupal_system_mappings). |
attributes.drupal_internal__tid | Drupal 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 field | Vocabulary | NanaSelect attribute candidate |
|---|---|---|
field_accessories | accessories | — (no matching attribute yet) |
field_application | applications | ideal-for-x-applications |
field_cad_file_types | file_types | build-your-own-export-exact-cad-revit-pdf-spec |
field_configurations | configurations | — |
field_direction | direction | some-panels-inward-some-outward-opening |
field_frame_style | frame_style | — |
field_frame_type | frame_types | — |
field_framed | framed | — |
field_glazing_type | glazing | glazing-options |
field_handle_options | handle_options (via views handler) | handle-options |
field_hardware_options | hardware_options | — |
field_interior_exterior | interior_exterior | interior-vs-exterior-suitability |
field_locking_options | locking_options (via views handler) | concealed-locking |
field_material_transparency | material_transparency | materials |
field_max_height_range | max_height_range | max-panel-height |
field_max_panel_width_range | max_panel_width_range | max-panel-width |
field_max_unit_width_range | max_unit_width_range | maximum-opening-width |
field_mount | mount | — |
field_mounting | mounting | — |
field_panel_design_options | panel_design_options | panel-stacking-options |
field_performance_rating | performance_ratings | acoustic-rating-stc-oitc (needs review) |
field_rail_and_stile | rail_and_stile | — |
field_sill | sill | minimum-sill-depth (needs review) |
field_sill_options | via views handler | sill-options |
field_sill_type | sill | — |
field_stacking_direction | stacking_direction | panel-stacking-options (needs review) |
field_stacking_location | stacking | panels-parking-location |
field_swing_door | swing_door_options | swing-door-integration |
field_track_type | track_type | floor-track-required (needs review) |
field_wall_design | wall_design | — |
field_weather_performance | weather_performance | water-penetration (needs review) |
Boolean fields (2)
| Drupal field | NanaSelect attribute candidate |
|---|---|
field_commercial_grade | ideal-for-x-applications (commercial signal — needs review) |
field_resources_configurator | 3d-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--image → field_media_image → file--file → uri.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 field | Use |
|---|---|
field_images, field_images_media, field_media | Entity 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):
| Field | Reason |
|---|---|
field_app | iPad-app display flag |
field_files, field_specifications | file attachments (presentation) |
field_kbmax, field_kbmax_config, field_kbmax_system | configurator ids/fragments, not attribute data (#121) |
field_new, field_popular | marketing badges |
field_node, field_product_node | node references (site wiring) |
field_order | display ordering |
field_product_finder | site product-finder flag |
field_video_content_id | video 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).termsmay 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.