Skip to content

#354 Preserve Stored Answers When A Question Type Or Options Change - #427

Merged
b-at-neu merged 6 commits into
devfrom
354-preserve-stored-answers-on-question-shape-change
Aug 14, 2026
Merged

#354 Preserve Stored Answers When A Question Type Or Options Change#427
b-at-neu merged 6 commits into
devfrom
354-preserve-stored-answers-on-question-shape-change

Conversation

@b-at-neu

Copy link
Copy Markdown
Collaborator

Closes #354

Summary

  • A submitted answer used to render (and then silently overwrite) as blank/truncated whenever an admin later changed that question's type or removed a chosen option, because the applicant editors rendered controls straight from the live question shape instead of the stored value.
  • This introduces one pure helper, partitionAnswerValue (+ isAnswered), that splits a stored answer into the part the question's current shape can render/round-trip (fitted) and the part it can't (orphaned). Both applicant editors now render from fitted only, show orphaned in a new read-only warning notice, and build every write from fitted — so a truncated render can never become a write.
  • "Answered" is redefined via isAnswered everywhere a required-answer check happens (submitApplication, getProfileCompleteness, the apply-page gate), so a mismatched required answer is treated as unanswered client- and server-side alike.
  • No unification of the two editors — that's Unify the Two Answer Editors #394 (already noted as blocking this ticket), which will absorb this logic; kept additive and mirrored in both files.

Changes

  • lib/types.tsAnswerQuestion (replaces the duplicate local QuestionShape/NarrowQuestion types) and AnswerPartition.
  • lib/utils.tspartitionAnswerValue() (exhaustive switch over QuestionType, never default) and isAnswered().
  • components/features/answer-mismatch-notice.tsx — new read-only warning notice (chips for multiple_choice, plain text otherwise), used in three places.
  • components/features/application-question.tsx — renders from fitted, derives "Other" state from fitted gated on allowOther, builds every write from fitted, renders the notice, wires aria-describedby/role="group".
  • components/features/profile-question.tsx — same treatment for the edit view; the read-only view keeps showing the full stored value (safe — it's not writable) with the notice added above it.
  • components/features/application-stepper.tsx — drops the local NarrowQuestion type in favor of AnswerQuestion; required-field validation and the read-only missingGlobalIds check use isAnswered; ReadOnlyQuestionCard renders the notice.
  • prisma/actions/applications.tssubmitApplication/reopenApplication's shared findMissingRequiredAnswers uses isAnswered instead of a bare non-empty check.
  • prisma/data/profile.tsgetProfileCompleteness uses isAnswered; widened the requiredQuestions select to include type/options/allowOther.
  • app/(main)/(auth)/positions/[id]/apply/page.tsx — the profile-complete gate uses isAnswered to match submitApplication.

Testing plan

Setup: as an admin, create a position question and a global profile question; as an applicant, answer them and start a draft application.

  • Option removal (single choice) — answer a single-choice question, then as admin delete the chosen option. Reopen the application: the notice shows the old answer, the radios are unselected, and the answer is still in the DB. Reload again — still intact.
  • No write on mount/blur — with that mismatch on screen, click into and out of the card (and switch steps) without answering, reload: the stored answer is unchanged.
  • Re-answer — pick a valid option: the notice disappears, the new value persists after reload, and the reviewer view shows only the new answer.
  • Type change (multi → short answer) — answer a multiple-choice question with 3 selections, switch the question to short_answer: the input shows the first value, the notice lists the other two, and blurring without typing preserves all three.
  • Multi → single narrowing — same with a switch to single_choice: only a value still in options stays selected; the rest appear in the notice.
  • Required gate (happy/error path) — with a required question mismatched, Submit is blocked with "Please answer this question again"; forcing the submit (e.g. a second tab) is refused by the server with the existing message.
  • Profile parity — repeat option removal on a required profile question: /profile shows the notice, the completeness banner counts it missing, and /positions/<id>/apply shows the "Complete your profile first" gate until it is re-answered.
  • "Other" unaffected — a question with allowOther keeps rendering a free-text answer as "Other" with no notice; turning allowOther off moves that text into the notice.
  • Empty state — a question with no stored answer at all still renders its normal empty control (no notice, no error).
  • Reviewer view unchanged — a submitted application still renders its snapshot verbatim regardless of later question changes.
  • Both themes at 375px and 1280px; keyboard-only pass over a mismatched question (radios/checkboxes reachable, notice announced via aria-describedby).

Automated checks

  • npm run prettier:check
  • npm run eslint:check
  • npm run tsc:check

No test runner exists in this repo today (noted as a pre-existing gap in the plan); partitionAnswerValue is deliberately pure so it becomes a trivial unit-test target once one exists.

Notes

  • Deliberate: with allowOther: true, a value whose option was removed keeps rendering as the applicant's "Other" text (not flagged) — nothing distinguishes the two cases in storage, and this is already the behavior Allow Admins to Enable an "Other" Free-Text Option on Choice Questions #322 established.
  • Soft-deleted questions are not fetched, so their stored answers are neither shown nor touched in the editor; they remain visible in the reviewer snapshot. Unchanged by this PR.
  • The shared AnswerField/useAnswerAutosave extraction and unifying the two editors stays with Unify the Two Answer Editors #394; this PR only prepares the logic it will absorb.

@b-at-neu b-at-neu self-assigned this Aug 13, 2026
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aplio Ready Ready Preview Aug 14, 2026 3:49pm

@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent labels Aug 13, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 1 · needs revision

1 open — 1 ⚪ Nit (see inline)

Comment thread components/features/answer-mismatch-notice.tsx Outdated
@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed reviewing Review agent working (in-flight) needs revision Review found issues that need fixing labels Aug 13, 2026
@b-at-neu
b-at-neu force-pushed the 354-preserve-stored-answers-on-question-shape-change branch from 3cc8e8b to 4a252c2 Compare August 13, 2026 03:04
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 1

fixed R1-N1 · 4a252c2

@b-at-neu b-at-neu added ready for review PR ready for review agent and removed revising Revise agent working (in-flight) labels Aug 13, 2026
@b-at-neu b-at-neu added reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent labels Aug 13, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 2 · approved

0 open — clean

@b-at-neu b-at-neu added approved Review passed, ready to merge needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed reviewing Review agent working (in-flight) approved Review passed, ready to merge needs revision Review found issues that need fixing labels Aug 13, 2026
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Pipeline Escalation

Human-directed rebase of 354-preserve-stored-answers-on-question-shape-change onto origin/dev was attempted and aborted. The conflicts are semantic overlaps between this PR's answer-partitioning feature and the already-merged short-answer format-validation feature (PR #333/#325), not structurally independent edits.

Conflicting files

  • lib/types.ts — one hunk is a trivial adjacent-comment collision (auto-resolvable on its own), but it sits directly beside the shared AnswerQuestion type this PR introduces, which is missing the format: ShortAnswerFormat | null field that dev's QuestionShape type carries. Adding it here is what cascades the conflict into every consumer below.
  • components/features/application-question.tsx — four hunks:
    • Import lines: dual independent additions (QuestionType/ShortAnswerFormat/matchesShortAnswerFormat vs AnswerQuestion/partitionAnswerValue) — mechanically combinable on their own.
    • dev defines a local QuestionShape type (with format) and FORMAT_INPUT_TYPES map; this PR's side removes that block entirely in favor of the shared AnswerQuestion type (no format field). Taking either side outright drops the other feature — dev's prop type has no partitioning support, this PR's has no format field.
    • initialOtherValue: dev derives it from raw field.value; this PR derives it from fitted, gated on question.allowOther. Both are real logic, not whitespace.
    • The short_answer <Input>: dev sets type={FORMAT_INPUT_TYPES[...]} and reads field.value[0]; this PR reads fitted[0] with no format-aware type. Correct merge needs both: type from dev, value from fitted.
  • components/features/profile-question.tsx — same pattern, plus handleBlur/formatError state (dev) needs to coexist with the viewFitted/viewOrphaned partitioning and mismatch notice (this PR) in the same render paths for short_answer and file_upload.
  • components/features/application-stepper.tsx — the Controller rules.validate function is the clearest case: dev's validator checks required then short-answer format; this PR's validator checks required via isAnswered and distinguishes "never answered" from "orphaned" for the required-message. These need to be composed (required → format → orphaned) — order and interaction is a design decision, not a mechanical pick. Also a NarrowQuestion type (with format) vs the shared AnswerQuestion type (without), same tension as lib/types.ts.
  • prisma/data/profile.ts — one hunk, getProfileCompleteness's "required answered" count: dev counts rows with non-empty value; this PR counts via isAnswered (shape-fit check). Lower risk in isolation, but its correctness depends on how isAnswered/AnswerQuestion end up shaped by the resolution above, so it's included in the same escalation rather than resolved independently.

Why this isn't auto-resolvable

Every non-trivial hunk is both sides modifying the same function body / same prop type on overlapping lines, where accepting either side outright would silently drop the other feature's behavior (format validation or answer-shape preservation). Correctly composing them requires deciding shared type shape (AnswerQuestion needs format added) and validation ordering/precedence across three components — domain judgment beyond "merge these two edits," per the Rebase conflict protocol's escalation criteria.

No commits were pushed; the branch is unchanged from its pre-rebase state (still mergeStateStatus: DIRTY / mergeable: CONFLICTING against dev).

@b-at-neu b-at-neu added needs human Pipeline escalation: 3 review cycles without convergence needs revision Review found issues that need fixing and removed revising Revise agent working (in-flight) needs human Pipeline escalation: 3 review cycles without convergence labels Aug 14, 2026
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Pipeline Escalation

Rebasing 354-preserve-stored-answers-on-question-shape-change onto the current origin/dev tip (after PR #420 / "355 keep drafts submittable after global question" merged) produces two conflicts that are not structurally separable — both PRs modified the same functions' logic, not just adjacent lines.

Note on sequencing: this PR was first rebased onto origin/dev as it stood at the start of this cycle (clean, auto-resolved two independent-addition conflicts in lib/types.ts/lib/utils.ts, pushed as dbd41d0) and comment cleanup was applied and pushed. origin/dev then advanced further (PR #420 merged mid-cycle), and a second rebase attempt onto the new tip is what surfaces the conflicts below. dbd41d0 (currently on the branch) is unaffected by this — the conflict only appears when re-rebasing onto the newer dev.

1. prisma/actions/applications.ts

Both sides restructured the same required-answer-validation logic:

  • origin/dev (PR Keep In-Progress Drafts Submittable After a Global Question Is Added #355) reverted the previous shared findMissingRequiredAnswers/ApplicationForRequiredAnswers helper back into a standalone hasUnansweredRequiredPosition (position-only), and introduced a new syncGlobalAnswersFromProfile + formatMissingQuestions mechanism that auto-backfills a draft's missing global answers from the user's profile inside a prisma.$transaction, changing submitApplication's and reopenApplication's error messages and control flow substantially. reopenApplication's query also now selects (not includes) position/positionAnswers — position questions aren't refetched at all, and only the global snapshot is re-validated on reopen ("position questions were already passed once").
  • This PR (Preserve Stored Answers When a Question Type or Options Change #354) changed the equivalent (then-shared) function to swap every toStringArray(value).length > 0 check for isAnswered(question, toStringArray(value)), so a stored answer whose value no longer fits the question's current shape (type/options changed) counts as unanswered rather than answered.

Merging requires a product decision, not a mechanical merge: should syncGlobalAnswersFromProfile's backfill-eligibility check also become isAnswered-aware? Should reopenApplication's now-position-question-free re-validation still special-case position questions whose shape changed since the original submission? Taking either side whole drops the other's logic; hand-writing new merged logic here is exactly the kind of same function body/same lines conflict that needs the original author's judgment.

2. components/features/application-stepper.tsx

Reconciling these means deciding how the orphaned-answer message and the new checkGlobalReadiness/isGlobal split compose for global fields — again a semantic call, not a structural one.

Given both files fail the "same function body / same lines" bar for auto-resolution, and accepting either side would silently drop the other PR's logic, the rebase was aborted rather than partially resolved.

@b-at-neu b-at-neu added needs human Pipeline escalation: 3 review cycles without convergence and removed ready for review PR ready for review agent labels Aug 14, 2026
@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed needs human Pipeline escalation: 3 review cycles without convergence needs revision Review found issues that need fixing labels Aug 14, 2026
b-at-neu and others added 4 commits August 14, 2026 11:24
Introduces partitionAnswerValue/isAnswered (lib/utils.ts) so a stored
answer that no longer fits its question's current type/options is
split into the part still renderable ("fitted") and the rest
("orphaned"), instead of one silently overwriting the other on the
next blur. Both applicant editors (ApplicationQuestion, ProfileQuestion)
and the stepper's read-only card now render controls from `fitted` only,
surface `orphaned` via a new read-only AnswerMismatchNotice, and build
every write from `fitted` so a render-time mismatch can never become a
write. "Answered" is redefined via isAnswered everywhere required-answer
checks happen (submitApplication, getProfileCompleteness, the apply-page
gate) so client and server agree on what counts as answered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consolidating QuestionShape into AnswerQuestion dropped
FORMAT_INPUT_TYPES and the type= prop, so email/phone/url/zip
answers always rendered a plain text input. Relocated the map to
lib/constants.ts alongside the other format constants so
ProfileQuestion can adopt it later.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drops the ENGINEERING §7 citation from the partitionAnswerValue JSDoc
and condenses every other comment this PR introduced (issue refs,
narration, multi-paragraph JSDoc) to the one/two-line terse-fragment
bar, without losing the invariants they document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@b-at-neu
b-at-neu force-pushed the 354-preserve-stored-answers-on-question-shape-change branch from dbd41d0 to 9bd2e53 Compare August 14, 2026 15:29
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 4

No new review findings to address (Cycle 4 was already approved); this cycle re-rebased onto origin/dev now that #420 and #411 have merged, per human-approved conflict resolutions · 9bd2e53

· rebase: app/(main)/(auth)/positions/[id]/apply/page.tsx (dual independent import additions, mechanically merged)
· rebase: prisma/actions/applications.ts (kept dev's transaction/status-gate/backfill structure; made hasUnansweredRequiredPosition and syncGlobalAnswersFromProfile's missing-labels check isAnswered-aware; backfill eligibility stays value-based; reopenApplication unchanged)
· rebase: components/features/application-stepper.tsx (kept dev's isGlobal split; made both the position-field inline validate rule and checkGlobalReadiness isAnswered-aware)

@b-at-neu b-at-neu added ready for review PR ready for review agent and removed revising Revise agent working (in-flight) labels Aug 14, 2026
@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed ready for review PR ready for review agent needs revision Review found issues that need fixing labels Aug 14, 2026
Condenses multi-line/duplicated comments this PR introduced to one-line
terse fragments and drops a couple that naming already carried, without
losing the invariants they documented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes a cross-function reference comment in application-stepper.tsx
and condenses the last two-line comment in profile.ts, completing the
ENGINEERING §7 comment-discipline pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 4

comment-discipline cleanup (ENGINEERING §7), no review findings outstanding · 2b1e872

@b-at-neu b-at-neu added ready for review PR ready for review agent and removed revising Revise agent working (in-flight) labels Aug 14, 2026
@b-at-neu b-at-neu removed the ready for review PR ready for review agent label Aug 14, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 5 · approved

0 open — clean

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Review passed, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preserve Stored Answers When a Question Type or Options Change

1 participant