Skip to content

Drupal taxonomy → app-facet mapping (#344)

Maps a nanawall.com taxonomy term (a classification value carried by ingested project/content — an application, building type, geography, aesthetic, … term) onto an app facet: one of the app's own attributes or factors. This makes the app's attributes/factors the single shared vocabulary ingested content is filtered and matched by — so ingested content can be routed to the same facets the guided flow already uses.

The typed source of truth is src/lib/server/admin/facet-mappings.ts; the table is drupal_facet_mappings.

Three distinct mapping models — do not conflate

This is the third Drupal mapping model. Each pairs different things; they are siblings, not extensions of one another:

ModelTablePairsOwner
Field mapping (#130)drupal_attribute_mappingsDrupal contract field → product attribute (which fields feed attributes)drupal-product-contract.md
System mapping (#121)drupal_system_mappingsproducts-vocab term namesystem (which product term is which system)
Facet mapping (#344)drupal_facet_mappingstaxonomy term → app facet (attribute or factor)this doc

The key contrast the admin surfaces call out: field mapping chooses which Drupal fields feed attributes; facet mapping maps taxonomy terms onto facets. Different source (a term vs a field), different target (a facet vs an attribute only).

The model

drupal_facet_mappings is keyed by (vocabulary, term_name) — the stable, human-authored join key, mirroring drupal_system_mappings' name-keying. term_id / term_uuid are resolved by the fetch layer on first contact and cached so later syncs address terms directly.

The facet target is exactly one of two nullable FKs:

  • attribute_idattributes.id
  • factor_idfactors.id

A table CHECK(attribute_id IS NULL) <> (factor_id IS NULL) — enforces exactly-one, so a term resolves to one facet, never both or neither. Both FKs ON DELETE CASCADE, so retiring the underlying attribute/factor drops the now-dangling mapping rather than leaving it pointing at nothing.

Source-term inventory — surfaced, never dropped

The candidate terms are not a frozen list. FACET_SOURCE_VOCABULARIES declares each source vocabulary and how to read its terms live from ingested content (config-driven, mirroring the content registry):

VocabularyLive sourceNote
projectsdrupal_projects.nameThe project classification #345 matches a buyer's project against.
productsdrupal_project_products.product_nameThe product terms content references. Distinct target from drupal_system_mappings (term → system): a product term can also be a filterable app facet here.

listSourceTerms(db) derives the inventory on every call and cross-references the mappings, so a term that appears after a future sync automatically shows up as unmapped — surfaced for attention, never silently dropped. A mapping whose term the inventory no longer carries stays visible (from the mappings table) so a stale mapping can be seen and removed. Ordered vocabulary → unmapped-first → term.

Admin surface

The Taxonomy → facet mapping panel on /admin/drupal-sync (extending the existing sync surface #112, consistent with the managed-category pattern #340):

  • lists every source term with its mapped facet (or unmapped);
  • assigns a facet via one <select> offering both sets (Attributes / Factors optgroups; the value encodes attribute:<id> / factor:<id>). A placeholder- first guard means a mapped row never defaults to a real facet, so a stray "Change" can't silently re-point it;
  • removes a mapping via a confirm step; the term reverts to unmapped.

mapFacet / unmapFacet actions delegate to setFacetMapping / removeFacetMapping (which validate the facet exists and enforce exactly-one-target) and audit each write.

Shared vocabulary — the consumer seam

getSharedFacetVocabulary(db) returns a FacetVocabularyIndex — the single seam content filtering and guided selection resolve ingested content through:

  • resolveTerm({ vocabulary, termName }) → the facet, or null when unmapped;
  • resolveContentFacets(refs) → the distinct facets a content item's terms resolve to (unmapped terms dropped, facets de-duped);
  • termsForFacet(kind, id) → the reverse "filter content by this facet" lookup.

The index is built by the pure indexFacetVocabulary(entries) over the resolved mappings, so the resolution semantics are unit-tested without a DB. #345 (real-project matching) is the first consumer: it resolves a buyer's project terms to facets and finds installed projects sharing them.

See also