Skip to content

#355 Keep In-Progress Drafts Submittable After a Global Question Is Added - #420

Merged
b-at-neu merged 4 commits into
devfrom
355-keep-drafts-submittable-after-global-question
Aug 14, 2026
Merged

#355 Keep In-Progress Drafts Submittable After a Global Question Is Added#420
b-at-neu merged 4 commits into
devfrom
355-keep-drafts-submittable-after-global-question

Conversation

@b-at-neu

Copy link
Copy Markdown
Collaborator

Closes #355

Summary

  • Makes GlobalApplicationAnswer (the snapshot) the single source of truth on both the client and server, so a required global question added after a draft was created no longer causes the client to say "you're done" while the server rejects the submit.
  • submitApplication and reopenApplication now backfill any snapshot row missing from the caller's current profile inside a transaction before validating — an answer the applicant can already see in their profile is never treated as "unanswered", but a deliberately cleared application-only answer is never resurrected.
  • The apply page now always opens an existing draft, skipping the profile-completeness gate entirely (that gate protects creating a new application, not resuming work already in progress).
  • The stepper's readiness check (checkGlobalReadiness) is now shared by both Next and Submit, closes a gap where a position with no position-specific questions could submit from step 1 with no global check at all, and auto-enters Customize mode with the new question rendered as an editable empty required field.

Changes

  • prisma/actions/applications.ts — adds module-private syncGlobalAnswersFromProfile (backfill + missing-required-label report) and formatMissingQuestions; rewrites submitApplication around it inside a transaction (now throws 'Application not found for caller' on an IDOR-style miss instead of returning { error: 'Unauthorized' }); reopenApplication reuses the same backfill (position questions are intentionally not re-validated there — that's Design the Position Edit Policy #398's problem).
  • prisma/data/applications.ts — adds getDraftApplication(userId, positionId), scoped to status: 'draft', deletedAt: null.
  • app/(main)/(auth)/positions/[id]/apply/page.tsx — fetches the existing draft first; only applies the profile-completeness gate when there is no draft yet.
  • components/features/application-stepper.tsx — one readiness check (checkGlobalReadiness) driven by form values, shared by Next and Submit; auto-enters Customize when a required global answer is empty (hasNewRequiredGlobals, derived once from initial values, no effect); flushes in-flight autosaves and catches unexpected throws on submit; root error uses role="alert".

Testing plan

  • As an applicant with a complete profile, start a draft for an open position and leave it in progress.
  • As an admin, add a new required global question.
  • Reopen the draft: it opens directly (no "Complete your profile first" card), starts in Customize mode with the new question empty and marked required, and shows the "New required profile questions were added…" banner.
  • Press Next/Submit without answering: the field highlights, the alert appears, nothing is submitted.
  • Answer it and submit: succeeds; the application detail page shows the new answer; /profile still shows the question unanswered (application-only answer).
  • Repeat, but answer the new question at /profile instead: return to the draft — the value is prefilled — and submit without touching any field. It succeeds, and a GlobalApplicationAnswer row now exists for the new question.
  • Customize a global answer, clear it to empty, and submit: still blocked (the backfill must not resurrect the profile value over a deliberate clear).
  • Type into a global field and click Submit immediately without clicking elsewhere: the typed value is saved and submitted.
  • Withdraw a submitted application, add another required global question, then re-open: with the question answered in the profile it re-opens silently; with it unanswered, the toast names the question and points at the profile.
  • Position with no position-specific questions: submitting from step 1 enforces the same global checks.
  • Two tabs: open the same draft in both, backfill via submit in one — no unique-constraint error from the other (race safety of skipDuplicates).
  • A non-owner cannot submit or reopen another user's application (still throws / scoped by userId).
  • Keyboard-only pass over step 1 in Customize mode: every field reachable, visible focus, the root alert announced.

Automated checks

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

Notes

  • Existing drafts now bypass the profile-completeness gate by design — the gate protects creating an application, not reaching work already in progress. An applicant can submit with an application-only answer while their profile stays incomplete; that's already the documented meaning of Customize.
  • submitApplication is now a read-then-write, so it runs in a transaction; skipDuplicates on the backfill guards a race between two tabs.
  • Soft-Delete Draft Applications via Resurrect and Re-Snapshot #369 (soft-delete resurrect) will reuse syncGlobalAnswersFromProfile on resurrect; it stays transaction-scoped and side-effect-free beyond the backfill.

@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:42am

@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

3 open — 1 🔴 Critical, 1 🟠 Medium, 1 🟡 Low

🔴 R1-C1 — Required check Vercel is failing: https://vercel.com/sga-operational-affairs-projects/aplio/ET6FahE8s5wrVxZseaVFTAoZNA1m — resolve the deployment failure before merge (prettier/eslint/tsc checks are green).

Other findings inline.

Comment thread components/features/application-stepper.tsx Outdated
Comment thread components/features/application-stepper.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 355-keep-drafts-submittable-after-global-question branch from fa9137b to 50cce97 Compare August 13, 2026 01:38
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 1

fixed R1-M1, R1-L1 · skipped R1-C1 (preexisting: Neon branch-limit build failure tracked in #412, in-flight fix #419 — confirmed via matching failure on unrelated concurrent PRs and a clean local next build) · 50cce97

@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed revising Revise agent working (in-flight) 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 · needs revision

1 open — 1 🔴 Critical

🔴 R2-C1 — Required check Vercel is failing on the latest commit: https://vercel.com/sga-operational-affairs-projects/aplio/57Mwupuag9WQRQAwDHFsFi3yyquD (deployment build/runtime error — npx vercel inspect dpl_57Mwupuag9WQRQAwDHFsFi3yyquD --logs). Fix: resolve the deployment failure before merge; prettier/eslint/tsc are green.

@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

Copy link
Copy Markdown
Collaborator Author

Pipeline Escalation

Rebasing 355-keep-drafts-submittable-after-global-question onto origin/dev produced two conflicts that are ambiguous/semantic — both sides modified the same function body, and in one case accepting either side outright would drop or misrepresent the other's logic. Per the rebase conflict protocol this needs the original author's judgment rather than an autonomous merge, so the rebase was aborted and no code was changed.

1. prisma/actions/applications.tssubmitApplication (security-relevant)

Both branches independently fixed the same pre-existing bug (a hand-rolled if (!application || application.userId !== currentUser.id) return { error: 'Unauthorized' } — a denial returned as a user-facing { error } instead of thrown, per ENGINEERING.md §3) via two different, incompatible restructurings:

  • origin/dev (from PR Add Shared Authorization Helpers and Standardize Denial Handling #356, "shared authorization helpers and denial handling"): keeps the original prisma.application.findUnique({ where: { id } }) (no transaction, no userId in the query), then calls the new centralized guard:
    requireOwnership(application, currentUser.id);
  • This PR (Keep In-Progress Drafts Submittable After a Global Question Is Added #355): wraps the whole read-then-write in prisma.$transaction, scopes the ownership check into the query itself (where: { id, userId: currentUser.id }) inside the transaction, and throws directly if nothing matches:
    const result = await prisma.$transaction(async (tx) => {
      const application = await tx.application.findFirst({
        where: { id: parsed.data.applicationId, userId: currentUser.id },
        include: { positionAnswers: true, position: { select: { ... } } },
      });
      if (!application) throw new Error('Application not found for caller');
      ...
      return tx.application.update({ ... });
    });
    and afterward needs:
    if (isError(result)) return result;
    before the revalidatePath calls, since the transaction now funnels every early-return ({ error: ... } for closed position/window/missing answers) and the success value through one result.

Why this can't be auto-resolved: the application variable dev's side calls requireOwnership on only exists inside our transaction's callback scope now — it isn't reachable at the point dev's hunk puts the call. Naively keeping "ours" drops the explicit requireOwnership guard call the #356 refactor introduced (even though ownership is still enforced via the where: { id, userId } filter + throw, which is the same pattern ENGINEERING.md's own withdrawApplication example uses) — but that stylistic choice conflicts with ENGINEERING.md §3's "never hand-roll a role check — use lib/auth/guards.ts" instruction that #356 was specifically written to enforce codebase-wide. Naively keeping "theirs" drops the isError(result) check entirely, which would call revalidatePath and return a raw { error } result on every failure path (closed position, closed window, missing required answers) instead of short-circuiting — a functional regression, not just a style one. Reconciling this correctly requires deciding whether requireOwnership should be called on a pre-transaction ownership-scoped read (restructuring the transaction boundary) or whether the query-scoped where pattern is the intended equivalent here — a call best made by whoever owns this function's shape.

2. components/features/application-stepper.tsxonSubmit (client submit handler)

  • origin/dev: added generic error handling around the existing handleSubmit(...)-wrapped callback:
    const onSubmit = handleSubmit(async () => {
      try {
        const result = await submitApplication(application.id);
        if (isError(result)) {
          setError('root', { message: result.error });
          toast.error(result.error);
        } else {
          toast.success('Application submitted');
          router.push('/applications');
        }
      } catch (error) {
        console.error(error);
        toast.error('Something went wrong. Please try again.');
      }
    });
  • This PR (Keep In-Progress Drafts Submittable After a Global Question Is Added #355): replaced the handleSubmit wrapper entirely with a hand-written function that also validates position questions via trigger(...), calls the new checkGlobalReadiness() gate, and awaits any in-flight autosaves before reading the snapshot:
    async function onSubmit() {
      const validPosition = await trigger(positionQuestions.map((q) => `p_${q.id}`));
      if (!validPosition) return;
      if (!checkGlobalReadiness()) return;
      setIsSubmitting(true);
      try {
        await Promise.allSettled([...pendingSavesRef.current.values()]);
        const result = await submitApplication(application.id);
        if (isError(result)) {
          setError('root', { message: result.error });
          toast.error(result.error);
        } else {
          toast.success('Application submitted');
          router.push('/applications');
        }
      } catch {
        toast.error('Something went wrong');
      } finally {
        setIsSubmitting(false);
      }
    }

Why this can't be auto-resolved: handleSubmit isn't even destructured from useForm anymore on this branch (replaced by manual trigger/getValues-based validation earlier in this same commit), so dev's hunk can't be dropped in as-is. Both sides also independently touched the catch block's copy and behavior (dev added console.error + the more specific "Something went wrong. Please try again." message; this PR's catch/finally manages isSubmitting state that dev's version gets from formState.isSubmitting instead, which this PR removed from the destructure). Picking either side's exact wording/logging wholesale risks silently dropping the other's intent rather than a mechanical merge.

Files with conflict markers left (rebase was aborted before resolving; listed for reference only — repo is currently clean, no markers present)

  • prisma/actions/applications.ts
  • components/features/application-stepper.tsx

No commits were changed; origin/355-keep-drafts-submittable-after-global-question is untouched. Cycle 2 review's only other finding (R2-C1, the failing Vercel check) was not investigated further since the rebase couldn't complete.

@b-at-neu b-at-neu added needs human Pipeline escalation: 3 review cycles without convergence needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed revising Revise agent working (in-flight) needs human Pipeline escalation: 3 review cycles without convergence needs revision Review found issues that need fixing labels Aug 13, 2026
@b-at-neu
b-at-neu force-pushed the 355-keep-drafts-submittable-after-global-question branch from 50cce97 to 5acf6bd Compare August 13, 2026 02:54
@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed revising Revise agent working (in-flight) ready for review PR ready for review agent labels 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 — R4-C1 (Critical) resolved, checks green

@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 14, 2026
b-at-neu and others added 4 commits August 13, 2026 23:37
the client's readiness check now reads snapshot ?? profile (same as the
server), and submitApplication/reopenApplication backfill any missing
snapshot rows from the profile inside a transaction before validating,
so a question added after draft creation surfaces as an empty required
field instead of an unexplained submit failure. Existing drafts also
bypass the profile-completeness gate on the apply page, since that gate
protects creating an application, not resuming one already in progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix client/server mismatch for deliberately-cleared global answers:
initialGlobalValues and hasNewRequiredGlobals now branch on snapshot
row presence, not value length, so a cleared (but existing) answer
row no longer falls back to the profile value. Also memoize the
per-render derived values (initialGlobalValues, initialPositionValues,
hasGlobalRow, hasNewRequiredGlobals) since they only depend on props
that don't change after mount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Condense this PR's comments to the one-line-by-default bar from
ENGINEERING.md §7 and drop the #398 provenance citation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@b-at-neu
b-at-neu force-pushed the 355-keep-drafts-submittable-after-global-question branch from 2195acb to db01637 Compare August 14, 2026 03:41
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — comment-discipline cleanup

Human-directed pass per ENGINEERING.md §7 (post-#421/#418), not a new review cycle — condensed 16 comments across the 4 touched files to one line, kept 1 as a rare 2-line exception (the initialGlobalValues row-presence-vs-value-length invariant in application-stepper.tsx — the fix for the R1-M1 finding and genuinely can't compress to one line without losing either half of the rule), and removed the #398 citation in prisma/actions/applications.ts (reopenApplication). No comments were deleted outright — each held a non-obvious fact worth keeping in shorter form. db01637.

@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 reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent labels 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 6 · approved

0 open — comment-discipline-only revision since the last approval, no regressions

@b-at-neu b-at-neu added approved Review passed, ready to merge and removed reviewing Review agent working (in-flight) labels Aug 14, 2026
@b-at-neu
b-at-neu merged commit 7d30047 into dev Aug 14, 2026
5 of 6 checks passed
@b-at-neu
b-at-neu deleted the 355-keep-drafts-submittable-after-global-question branch August 14, 2026 03:47
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.

Keep In-Progress Drafts Submittable After a Global Question Is Added

1 participant