# W3-BE-D report — link-row mirror, nutrition intake profile fields, dental intake dedupe

Date 2026-10-23 · BE only · nothing committed.

## 1. Link-row mirror (LEDGER Decision 4)

**New table `history_answer_values`.** Migration `Modules/Clinic/database/migrations/2026_11_20_400001_create_history_answer_values_table.php`. Columns: id, company_id, **history_answer_id** (FK → patient_history_answers, cascadeOnDelete), patient_id, encounter_id, **record_type** + record_id, **repeat_index**, question_id, question_code, lookup_key, value_code, lookup_id, created_at.
- Indexes: unique (history_answer_id, value_code); (company_id, question_code, value_code); (patient_id); (question_id, value_code).
- **Deviation from the brief:** I added `history_answer_id`, `repeat_index` and `record_type`.
  - Without the answer id the sync can't be a plain delete-and-reinsert, and the backfill isn't trivially idempotent.
  - Without repeat_index, a multi_select inside a repeat group would collapse into one row.
  - record_type + record_id are the answer's actual record-scope columns.
- Ran on dev using `migrate --force --path=<this file>` only. Reason: W2-FS-3's `2026_11_20_330001_seed_vitals_pack_definitions` was still pending, and I did not want to run it for them.

**Model.** `Modules/Clinic/app/Models/HistoryAnswerValue.php` is a plain Eloquent model with no updated_at. Its docblock carries the privacy note: no endpoint reads this table yet. Any future reader must apply the same answer read guard:
- drop is_private questions unless the user has `clinic.fields.private`;
- apply `FieldReadAccess::hiddenQuestionIds()`;
- keep record-scope rows out of encounter and patient readers.

**Sync logic.** It lives in `Modules/Clinic/app/Services/History/AnswerValueMirror.php`. It is a separate class so the command can reuse it. HistoryService delegates to it through two small private methods:
- `mirrorValues()`, called after every save in `saveEncounterItems`, `savePatientItems`, `saveRecordAnswers`, `recomputeDerived` and `recomputeRows`.
- `removeAnswers()`, which runs on the clear paths of all three scopes: it collects the ids, deletes their mirror rows, then forceDeletes. The derived-row removal in `recomputeRows` also clears its mirror rows.
- Everything runs inside the existing transactions.

**What gets mirrored:**
- multi_select answers: one row per selected code;
- single_select answers when the question has a `lookup_key`.
- Every other type does nothing. That includes derived computed/score answers: the call is there, but those types return before any query runs, so the hot save path isn't slowed down. If a question stops being a choice type, its leftover rows are removed by the command's prune step.
- `lookup_id` is resolved from `clinical_lookups`, including inactive rows, plus legacy `c<id>` codes.

**Encryption (SEC3).** An answer is skipped if it is flagged `is_encrypted`, or if its question is private while `security.pii.encrypt` is on. Any rows it already had are deleted. Private answers that are not encrypted are mirrored.

**Command** `php artisan clinic:history:mirror-values {--company=}` (`Modules/Clinic/app/Console/MirrorHistoryAnswerValues.php`, registered in `ClinicServiceProvider`):
- It is a reconcile, not just a backfill. It re-syncs every answer that qualifies for mirroring, in all scopes, in chunks of 500 per transaction.
- It prunes rows whose answer is gone, soft-deleted, encrypted, or no longer a choice type.
- It is idempotent.
- **Dev result:** "Mirrored 133 answer(s): 226 value row(s) now, 0 stale row(s) pruned." That is 121 multi_select plus 12 lookup single_select answers. A second run gave the same numbers. 220 of the 226 rows have a lookup_id.

**Writers outside HistoryService (not changed, outside my scope):**
- `DentalMedicalService:88` (forceDelete) and any other hard delete are covered by the FK cascade.
- `GynObstetricHooks:119`, `PregnancyService:635`, `VitalsService:285` (numeric, so not relevant) and imports/factories that create answers directly will not be mirrored until the command runs.
- `Sec3PiiEncryptCommand` encrypts rows in place, so plaintext codes already mirrored for those rows stay until the command runs. **Recommendation:** run `clinic:history:mirror-values` after `security:pii-encrypt` (or call it from that command), and/or schedule it nightly.

## 2. Nutrition intake: goal, goal weight, activity level (N16)

The intake wizard can only render history questions, keyed by question_id and saved through profile-fields. So I added a typed **profile** field kind, as the brief allows:
- `IntakeRegistry::forType()` now has a `profile_fields` key. It defaults to `[]`, so the existing shape still works for the Gyn registration, and a registered field with the same key replaces the built-in one.
- Nutrition declares three fields, all stored in `nutrition_profile`:
  - `nut_goal` (goal): single_select, lose / maintain / gain;
  - `nut_goal_weight` (goal_weight): numeric, kg, 1–400;
  - `nut_activity_level` (activity_level): single_select, sedentary … very_active.
- Labels follow the prototype and the existing NUTRITION i18n.
- Option codes are written out literally so Clinic doesn't load Nutrition. A test pins them to `PatientProfile::GOALS` and `ACTIVITY_FACTORS`.
- These are **not** history codes and no answer rows are written. The single source is `nutrition_patient_profiles`, the same row the nutrition board edits.

**New service.** `Modules/Clinic/app/Services/Intake/IntakeProfileFields.php` provides `fieldsFor`, `values` and `save`:
- The store is only offered when the Nutrition class exists, the module is enabled, and its table exists.
- Values are validated against the options and the min/max. null or '' clears a field. A bad value or an unknown key returns 422.
- On save it uses `firstOrNew(company, patient)` and sets `updated_by`; other profile columns are left alone.

**Payload and endpoint** (`PatientIntakeController`, a shared file):
- The specialty step now also returns `profile_fields` (key, kind:'profile', store, input_type, labels, options, unit/min/max, sort_order, can_write) and `profile_values` (key → value). They are kept apart from `fields`, which is keyed by question_id.
- New route `PUT api/clinic/patients/{patient}/intake/profile {clinical_type, values:{key:value|null}}`. Permission is `clinic.history.record|clinic.patients.update`, plus the stations=split nurse guard. It returns the refreshed payload.
- Route added in `Modules/Clinic/routes/api.php` (shared file, one line).
- Lang keys `profile_field_unknown` / `profile_field_invalid` added in `Modules/Clinic/lang/{ar,en}/intake.php`.
- These fields are optional and never count towards completeness.

**FE handoff (not done, FE is out of scope):** the intake wizard (`features/clinic/intake/intake-wizard.component.ts`) needs to render `step.profile_fields` with `profile_values`, and save them by PUT to `intake/profile` (debounced, like its other fields).

**Dev notes:**
- Company 1 has no nutrition-pack history questions (the nutrition pack was never applied; only the `nut_*` vitals definitions exist). So the existing nutrition `optional_codes` are dropped on dev, while the three profile fields do show.
- On dev, `fieldsFor('nutrition')` returns 3 fields and reads an existing profile correctly (`lose / 72 / light`).
- Naming drift between the prototype and the registry remains as noted in review N16 (for example `nut_activity` vs `nut_activity_minutes_week`, `nut_sweets` vs `nut_sweets_craving`). I did not change it.

## 3. Dental intake dedupe (D12)

I checked first that both questions are already covered by the shared step:
- `dent_anticoagulant` routes to condition `anticoagulant`. That is `clinical_lookups` id 1, key `condition`, which is the shared conditions card's catalog.
- `dent_allergy_penicillin` routes to allergy alert `penicillin`, which is in the shared allergen catalog.
- Both are `in_intake=0` on dev. Removing them from the dental `optional_codes` therefore takes them out of the dental intake step.

Their definitions, the dental sheet and `DentalMedicalService` are unchanged.

## Tests (Pest, SQLite, all run)

- **New** `Modules/Clinic/tests/Feature/W3HistoryAnswerValuesTest.php`, 5 tests, passing:
  - patient create / update / clear;
  - encounter scope, lookup single_select with lookup_id, a text field not mirrored, clearing one answer;
  - SEC3 encrypted private answer not mirrored, and mirrored when encryption is off;
  - backfill idempotent plus pruning of a soft-deleted answer;
  - FK cascade on an outside forceDelete.
- **New** `Modules/Clinic/tests/Feature/W3IntakeProfileFieldsTest.php`, 4 tests, passing:
  - registry shape, and options pinned to the PatientProfile constants;
  - GET values plus PUT save (other columns untouched, no answer rows, null clears);
  - profile created when missing, plus 422 for a bad option, out-of-range weight, unknown key, wrong type and missing type;
  - dental registry and stepDefinitions no longer include the two codes.
- **Regression**, existing files run because I touched their code paths, all passing: D3IntakeTest + D2aFactsTest (35), E1eAnswersTest + C1aEntitiesTest + Gyn G2aGynCoreModulesTest (33).
- Pint passes on my files. I ran `dump-autoload` and `optimize:clear` with the ea-php82 CLI and chowned everything to moonui3.

## Not verified

- The record-scope mirror path (`saveRecordAnswers`) has no test of its own. It uses the same sync call, and C1aEntitiesTest still passes.
- No live HTTP call against dev for `intake/profile`; it is covered by tests plus a tinker read.
- No FE.

## Files

- **New:** the migration; `HistoryAnswerValue.php`; `Services/History/AnswerValueMirror.php`; `Console/MirrorHistoryAnswerValues.php`; `Services/Intake/IntakeProfileFields.php`; the 2 test files.
- **Changed:** `Services/HistoryService.php`; `Clinical/IntakeRegistry.php`.
- **Shared files touched (minimal):**
  - `Http/Controllers/PatientIntakeController.php` (profile step + `profile()` action);
  - `routes/api.php` (1 route);
  - `Providers/ClinicServiceProvider.php` (1 command line);
  - `lang/{ar,en}/intake.php` (2 keys each).
- **CHANGELOG:** not edited. Suggested `[Unreleased]` bullet for the orchestrator: nutrition intake now asks goal / goal weight / activity level, saved to the nutrition profile; the dental intake no longer repeats the blood-thinner / penicillin questions; and a new reporting table plus the `clinic:history:mirror-values` command for multi-select answers.
