# HANDOFF — Rosewood Master Client Questionnaire

**Date:** 2026-06-18 · **Platform:** Drupal 11.3, Webform 6.3.0-rc1, DDEV
(`questionnaire.ddev.site`) · **Drush:** 13.7

Consolidates Rosewood's seven client questionnaires into one master webform served
as seven per-service variants. Shared questions are stored once, so an answer given
in one variant is pre-filled in the others for the same result. A staff page lists
seven secure links — all to the same result, each a different variant.

See `PRD.md` (architecture/decisions) and `APPENDIX_A.md` (locked field-level spec).

---

## 1. What was completed

**All four build phases are done and verified end-to-end.**

- **Webform installed:** `drupal/webform:^6.2` → 6.3.0-rc1; `webform` + `webform_ui`
  enabled.
- **Master webform** (`master_questionnaire`): 114 top-level / 207 total elements,
  generated from `scripts/build_master_questionnaire.php`. Includes scaffolded
  textareas, the competitor comparison table, two Likert grids, Yes/No gating, all
  fields optional + draft save, secure-token view/update enabled.
- **`rosewood_questionnaire` custom module:** variant subset/reorder form alter
  (`?variant=CODE`), the 7 variant definitions, and the secure-links page at
  `/questionnaire/{sid}/variants` (logged-in users only).
- **Verification:** structural test (`scripts/verify_questionnaire.php`) + live HTTP
  smoke tests all passed. phpcs (Drupal + DrupalPractice) clean; phpstan level 5 no
  errors.
- **Config exported** to `config/sync`.

**Verified behaviors:** full form renders (306 KB); `?variant=cmq` filters to 36 KB
with correct banner and inclusion/exclusion; anonymous token URL loads the *specific*
result with prior answers pre-filled; shared element pre-fills across variants;
links page emits 7 links with identical token + differing variant.

**PDF-vs-live-render order audit (post-handoff, same day):** every one of the 7
variants was checked question-by-question against its source PDF — no browser/
Playwright needed; the container *is* the web server, so verification was pure
`pdftotext` (source PDF) vs. `curl` + `data-drupal-selector="edit-{key}"` byte-offset
ordering (live render), diffed against `QuestionnaireVariants.php`. 6 of 7 passed
outright (msc, name, brand, web_client, web_guide, crm). **CMQ failed:**
`customer_reputation` and `credentials_authority` were ordered too early in the
spec — fixed in both `QuestionnaireVariants.php`'s `cmq` array and
`APPENDIX_A.md` §4, cache rebuilt, and the live render re-verified to match the
corrected 20-element order exactly.

**Demo data:** webform submission **#1** (MSC variant) was filled with realistic
sample answers — sourced from a real filled-out client PDF (Buckeye Metal Sales,
dropped ad hoc in `tmp/`, see open items) plus reasonable invented detail for gaps —
to exercise the full data path for every MSC widget type (textarea, checkboxes,
radios, number, `webform_table_sort`, `webform_likert`, fieldset-flattened
composites, and the `webform_table` competitor grid). Confirmed via live render
that all 113 saved values display correctly, including correct internal storage
shapes for multi-value elements (sequential delta-indexed arrays for checkboxes/
table-sort vs. property-keyed for Likert composites — these are *not*
interchangeable; using the wrong shape throws a DB error on save).

---

## 2. Key technical decisions

- **One webform, one submission per result.** Each unique question is one element
  with a stable key; shared questions are literally the same element across variants,
  so "answer once, pre-filled everywhere" needs no sync logic.
- **Variants via `hook_form_alter` + `?variant=`, NOT Webform's core Variants
  feature.** Core Variants can hide but not cleanly reorder; we need both subset and
  per-variant order. The alter sets `#access = FALSE` on non-members and `#weight` on
  members (source-questionnaire order). Flat element structure (no wizard pages) keeps
  reordering clean.
- **Secure links built as the canonical webform URL + token**
  (`Url::fromRoute('entity.webform.canonical', …, ['query'=>['token'=>…, 'variant'=>…]])`),
  *not* `getTokenUrl()`. `getTokenUrl()` derives from the submission's stored `uri`,
  which can be `/node` or an embed page; the explicit route is always
  `/form/master-questionnaire?token=…&variant=…`.
- **Wording:** MSC-priority (MSC's phrasing where a shared question exists in MSC,
  else the first source in build order).
- **Field types:** hybrid/structured. Sub-prompts are pre-filled *inside* the textarea
  (`Prompt: ` lines, blank line between) rather than help text, so clients append
  inline and the scaffold travels across variants.
- **§6 cluster splits (matrix grouped by theme, but questions differ):** split
  `company_goals`/`naming_goal`, contact-methods (CRM gets its own
  `first_interaction`/`most_common_contact`/`preferred_contact`), and brand-consistency
  (Branding gets `logo_usage_priorities`). Competitor table keeps all columns in
  Naming + Branding. Yes/No gating on `current_crm` and `b2b_detail`.
- **Access model:** clients edit anonymously via secure token (no accounts); links
  page open to all authenticated users; tokens don't expire.

---

## 3. Status of files touched

**New — custom module** (`web/modules/custom/rosewood_questionnaire/`):
- `rosewood_questionnaire.info.yml`
- `rosewood_questionnaire.module` — variant form alter
- `rosewood_questionnaire.routing.yml` — links-page route
- `src/QuestionnaireVariants.php` — 7 variant definitions (ordered element keys)
- `src/Controller/VariantLinksController.php` — secure-links page

**New — scripts** (`scripts/`):
- `build_master_questionnaire.php` — idempotent generator (re-run to rebuild the form)
- `verify_questionnaire.php` — test harness (creates a temp submission, asserts, deletes)

**New — docs (repo root):** `PRD.md`, `APPENDIX_A.md`, `HANDOFF.md`

**Modified:**
- `composer.json` / `composer.lock` — added `drupal/webform`
- `config/sync/` — exported config: `webform.webform.master_questionnaire.yml`
  (~43 KB), `core.extension.yml` (both modules), plus webform's own config.

**Modified (post-handoff, same day):**
- `web/modules/custom/rosewood_questionnaire/src/QuestionnaireVariants.php` — fixed
  CMQ element order (see above).
- `APPENDIX_A.md` — §4 CMQ order updated to match.
- `config/sync/system.theme.yml` — **incidental**, not part of this project: a
  pre-existing, unrelated default-theme change (`olivero` → `gin`) was already sitting
  uncommitted in the working tree before this work started, and a later
  `drush config:export -y` (run to persist the CMQ fix) swept it in. Review before
  committing — revert this one file if the theme change wasn't intentional.

**Git:** still nothing committed — all changes (original build + the CMQ fix above)
are in the working tree, on `main`. Branch + commit when ready.

**Live data:** original test submissions were deleted. Webform submission **#1**
now holds realistic MSC-variant demo data (see above) — delete or reset it before
any real client uses this form, if a clean slate is needed.

---

## 4. Failure paths / what didn't work (and the fix)

- **`poppler-utils` missing → couldn't read PDFs.** First probe of the "Marketing
  Guide" PDF reported 0 pages (compressed streams). Fix: `sudo apt-get update` then
  install `poppler-utils`; all 7 PDFs then extracted cleanly with `pdftotext`. No
  screenshots needed after all.
- **Form URL 404 on first curl.** Used `/form/master_questionnaire` (underscore); the
  canonical alias is `/form/master-questionnaire` (hyphen). The controller uses
  `Url::fromRoute`, so generated links were always correct — only the manual curl was
  wrong.
- **Verify script: "Session has not been set."** CLI requests have no session, which
  the form builder needs. Fix: attach a `Session(new MockArraySessionStorage())` to
  the simulated requests.
- **`getTokenUrl()` produced `/node?token=…`.** See decision above — switched the
  links page to the explicit canonical route.
- **phpstan: `EntityInterface::getWebform()` undefined.** `getEntity()` returns the
  base interface. Fix: narrow with `instanceof WebformSubmissionInterface` before
  calling `getWebform()` (no `@var`/cast suppression).
- **`Url::fromUri()` on a routed URL would throw.** Initial controller draft tried it;
  replaced with route-based URL construction.

### Known noise (pre-existing, not from this work)
- **`purger_varnish` errors** on every drush command — the Varnish purge module trying
  to reach a Varnish that isn't running in DDEV. Harmless; filtered from output during
  the build.
- **`composer` reports 7 security advisories across 2 packages.** Pre-existing; run
  `composer audit` to review. Unrelated to the questionnaire.

### Storage-format pitfall hit while writing demo data (post-handoff)
- **Directly setting `WebformSubmission` data via `setData()`/`resave()` requires
  matching Webform's internal storage shape per element type**, or `saveData()`
  throws `Incorrect integer value '...' for column 'delta'`. Plain multi-value
  elements (`checkboxes`, `webform_table_sort`) store as a **sequential array**
  (`['phone', 'email']`) because the array index becomes the `delta` column;
  composite elements (`webform_likert`) store as **`question_key => answer_key`**
  because the key becomes the `property` column instead. Fieldset/table children
  aren't nested under their parent key at all — `#tree` is `FALSE` on both, so
  every leaf (e.g. `gross_sales`, `comp1_name`) is just a flat top-level key.

---

## 5. Open items / next steps

1. **Decide who can originate a blank result.** Anonymous can currently create new
   submissions at `/form/master-questionnaire` (Webform default). If only staff should
   start a result (clients edit via token only), tighten the webform's "create" access
   rule.
2. **Commit to git** (branch off `main`). Now includes the CMQ order fix
   (`QuestionnaireVariants.php` + `APPENDIX_A.md`) on top of the original build —
   review `config/sync/system.theme.yml` first (see §3, incidental drift).
3. **Optional polish:** add the links page as a local task/operation on the submission,
   a "create new result" shortcut for staff, and email-the-link automation (ECA) if
   wanted later — explicitly deferred in the PRD.
4. **Webform is at `6.3.0-rc1`** (release candidate); consider pinning to a stable
   release when one lands.
5. **`tmp/01 Buckeye Trunk Marketing Questionnaire...pdf`** (a real filled-out client
   sample, used to generate the submission #1 demo data) is untracked and **not**
   gitignored — `questionnaires/` is already excluded for confidentiality
   (`.gitignore`); consider adding `tmp/` to the same rule, or just delete the file
   once it's no longer needed.
6. **Submission #1 holds demo data, not a real result** — reset/delete it before
   go-live if a blank slate is required.

---

## 6. Next phase — Company content type + per-company questionnaire links

**Status: BUILT AND VERIFIED (2026-06-19).** All locked decisions below were
implemented as specified. See "Build notes" at the end of this section for what
was done, deviations, and remaining items.

### Goal
A `company` content type holding the client list, plus a way — from each company —
to start a questionnaire submission for any of the 7 variants OR copy a link to send
to an anonymous client. Every company has exactly **one shared result**; all 7
variant links connect to that same submission (token), so answers carry across
variants (reuses the §2 token mechanism).

### Source data
`tmp/MG companies.xlsx - Address Export.csv` — 52 rows, 18 columns. (Note: `tmp/` is
untracked; same confidentiality caveat as §5.5.)

### Locked decisions
1. **Account Manager(s)** → taxonomy vocabulary `account_managers`, multi-value entity
   reference; terms auto-created on import.
2. **Geocoding** → `drupal/geocoder` with the **Nominatim (OpenStreetMap)** provider
   (free, no key; ~1 req/sec, set a descriptive User-Agent). Geocodes `field_address`
   → `field_geolocation` on save via `geocoder_field`.
3. **Import** → yes, via a `migrate_source_csv` migration (with `migrate_plus` +
   `migrate_tools`).
4. **Origination** → **auto-create** the shared result on company save
   (`hook_node_insert` for `company` creates one `master_questionnaire` submission with
   the company node as its **source entity**; guard against duplicates). A shared
   token therefore exists from day one, so every variant link is the canonical webform
   URL + that submission's token + `?variant=CODE`.
5. **Annual Budget** → plain **integer** field with a `$` prefix (all values are whole
   dollars).

### Modules to add (composer)
`drupal/address`, `drupal/geofield`, `drupal/geocoder` (+ submodules `geocoder_field`,
`geocoder_geofield`), `drupal/migrate_plus`, `drupal/migrate_tools`,
`drupal/migrate_source_csv`. Enable core **Telephone**, **Link**, **Taxonomy**,
**Migrate** (Email/Options field types already in core).

### Content type `company` — field map
| CSV column | Field (machine name) | Type |
|---|---|---|
| Company name | `title` | node title |
| Firstname | `field_contact_first_name` | string |
| Contact surname | `field_contact_last_name` | string |
| Position | `field_contact_position` | string |
| Account Manager(s) | `field_account_managers` | entity ref → taxonomy `account_managers`, unlimited |
| Website | `field_website` | link |
| Main Phone | `field_main_phone` | telephone |
| Fax | `field_fax` | telephone |
| Mobile | `field_mobile` | telephone |
| Email | `field_email` | email |
| Address1/2, City, State, Postcode, Country | `field_address` | address (one field, all six parts) |
| *(derived)* | `field_geolocation` | geofield (geocoded) |
| Annual Budget | `field_annual_budget` | integer, `$` prefix |
| Current Marketing Year | `field_marketing_year` | integer |

### New code
A new `rosewood_company` module depending on `rosewood_questionnaire` (+ address,
geofield, geocoder, webform). Contains: the `account_managers` vocab + company fields
(config), the `hook_node_insert` auto-create, the migration config, and the
company-page variant links. **Refactor:** extract the link-building logic currently
inlined in `rosewood_questionnaire`'s `VariantLinksController::links()` into a shared
service so both the existing `/questionnaire/{sid}/variants` page and the new
company-page links (rendered via `hook_entity_view` as an extra field) use it. No
webform source-entity *prepopulation* setting is needed — the token binds each link.

### Migration notes
- Trim whitespace (one address has a leading space).
- **Skip the "Test Marketing Company" row** (test record; empty website/address/contact).
- Re-runnable / rollback-able (`drush migrate:import` / `migrate:rollback`).
- Import triggers geocoding + result auto-creation per node on save (~52 Nominatim
  calls at ~1/sec).

### Data-quality flags (import as-is, just noted)
- Several Fax/Email fields hold `@rcfax.com` fax-to-email values (e.g. Stoltzfus
  Forest Products fax, Wildwood Builders email).
- Some Website values look like placeholders/wrong domains (Homecrest Roofing →
  `grandura.com`; Horning MFG / Wildwood → `rcfax.com`; Monarch Rest → `ibyfax.com`).
- Phones carry inline notes/extensions ("- Office", "x 104"); a few rows lack
  address/budget/year. All tolerated by the chosen field types.

### Done criteria
Config exported to `config/sync`; `phpcs` (Drupal + DrupalPractice) and `phpstan`
clean (per CLAUDE.md); verified that import runs, a sample company shows a populated
address + non-empty coordinates, and its 7 variant links all resolve to the one
shared result.

### Build notes (implemented 2026-06-19)

**All done criteria met.** phpcs (Drupal + DrupalPractice) and phpstan level 5 are
clean on both custom modules; config exported to `config/sync` (`drush config:status`
reports no differences).

**Files added/changed:**
- **New module `rosewood_company`** (`web/modules/custom/rosewood_company/`):
  `.info.yml`, `.module` (hook_node_insert auto-create, hook_node_delete cleanup,
  hook_entity_extra_field_info + hook_node_view for the company-page links),
  `.services.yml`, `src/CompanyResultManager.php` (get/ensure/delete the one shared
  submission per company; holds the `company` bundle constant).
- **Refactor in `rosewood_questionnaire`:** link-building extracted into
  `src/Service/VariantLinkBuilder.php` (+ `rosewood_questionnaire.services.yml`);
  `VariantLinksController` now injects it and is `final`. Both the standalone
  `/questionnaire/{sid}/variants` page and the company-page links use the one service.
- **New script** `scripts/build_company_type.php` — idempotent generator for the
  `account_managers` vocab, `company` type, all 13 fields, and form/view displays
  (mirrors the existing `build_master_questionnaire.php` pattern; config then exported
  to `config/sync`, matching how the webform was handled — no module `config/install`).
- **Migration config** `migrate_plus.migration_group.rosewood_companies` +
  `migrate_plus.migration.rosewood_company_import` (in `config/sync`).
- **`webform.webform.master_questionnaire`**: `create` access tightened to
  `authenticated` only (was anonymous + authenticated) — §5.1 decision. Token
  view/update stay enabled, so anonymous client editing via secure link is unaffected.
- **composer:** added `drupal/address`, `geofield`, `geocoder` (+ `geocoder_field`,
  `geocoder_geofield`, **`geocoder_address`** — required for the address→string
  preprocessor, easy to miss), `migrate_plus`, `migrate_tools`, `migrate_source_csv`,
  and **`geocoder-php/nominatim-provider`** (the Nominatim plugin is hidden until this
  PHP provider package is installed). Enabled core `telephone` + `migrate`.

**Verification (this environment):** 51 company nodes imported (the "Test Marketing
Company" row skipped), 12 `account_managers` terms auto-created, exactly one
`master_questionnaire` submission per company (source entity = the company node),
idempotent re-run keeps the count at 1, and deleting a company removes its
submission. Sample (Breezy Acres): populated address, non-empty coordinates, 7 links
sharing one token differing only by `?variant=`; HTTP smoke test of the `cmq` link
returns 200 with the correct banner and inclusion/exclusion.

**Deviations / things to know:**
- **Geocoding success ~22/51.** The misses are genuinely unfindable rural / PO-Box
  addresses (confirmed by querying Nominatim directly with cleaned-up formats — still
  no hit), not a throttle or format bug. Failure handling is `preserve`, so those
  nodes keep empty coordinates. Re-running `migrate:import --update` won't help; these
  need manual coords or a coarser (city/zip) fallback if ever required.
- **Migration CSV path is an absolute container path**
  (`/var/www/html/tmp/MG companies.xlsx - Address Export.csv`) in the migration config.
  `tmp/` is gitignored/confidential; the path is environment-specific — adjust it if
  the file moves or on another host before running the import.
- **Imported company nodes live in the DB, not git** (content isn't config). The
  migration is the source of truth: `drush migrate:import rosewood_company_import` /
  `drush migrate:rollback rosewood_company_import` to recreate/remove them.
- The shared variant links render on the company page via an extra field
  (`rosewood_variant_links`), toggleable under the company's "Manage display".

**Remaining / optional:** pin Webform to a stable release when one lands (still
`6.3.0-rc1`); decide whether geocode misses need a manual pass; nothing committed yet
— review and commit (see top-level git note).
