Skip to content

[fix] Facilitators can only drop out, not defer#2564

Merged
Will-Howard merged 1 commit into
masterfrom
wh-2551-facilitator-drop-not-defer-2026-05
May 27, 2026
Merged

[fix] Facilitators can only drop out, not defer#2564
Will-Howard merged 1 commit into
masterfrom
wh-2551-facilitator-drop-not-defer-2026-05

Conversation

@Will-Howard

@Will-Howard Will-Howard commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Description

Facilitators could previously defer a course as well as drop out, through the shared "Drop or defer" flow on the Facilitated Courses page. The agreed rule (per Li-Lian in Slack) is that facilitators can only drop out, not defer.

Incidental fix: createMockCourseRegistration was missing the facilitator-application columns added in #2563, which had left tsc --noEmit red on master. Added them (all nullable) so typecheck passes.

Issue

Fixes #2551

Developer checklist

Screenshot

Screenshot 2026-05-27 at 20 08 42 Screenshot 2026-05-27 at 20 08 54

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR prevents facilitators from deferring courses while keeping deferral available to participants. It adds a server-side guard rejecting facilitator deferral requests, updates DropoutModal to query registrations and disable the Deferral option for facilitators, changes the course-row action label to "Drop out" for facilitators, and updates mocks, tests, and Storybook accordingly.

Changes

Facilitator deferral restriction

Layer / File(s) Summary
Test/mock utility
apps/website/src/__tests__/testUtils.ts
createMockCourseRegistration now includes seven optional fields (formFeedback, impressiveProject, motivationToFacilitate, numGroupsToFacilitate, prevEngagement, prevFacilitationExperience, skills) defaulting to null.
Server-side authorization & tests
apps/website/src/server/routers/dropout.ts, apps/website/src/server/routers/dropout.test.ts
dropoutOrDeferral throws FORBIDDEN when a facilitator attempts Deferral. Adds Vitest tests covering unauthenticated, forbidden facilitator deferral, facilitator drop-out, and participant deferral scenarios.
DropoutModal UI and tests
apps/website/src/components/courses/DropoutModal.tsx, apps/website/src/components/courses/DropoutModal.test.tsx
DropoutModal queries courseRegistrations.getAll to compute allowDeferral; the "Deferral" Select option is disabled for facilitators. Adds a test asserting the Deferral option is disabled for facilitator registrations and that submissions send type === 'Drop out'.
Storybook: DropoutModal stories
apps/website/src/components/courses/DropoutModal.stories.tsx
Adds participantRegistrationsHandler to mock courseRegistrations.getAll (empty by default), wires it into multiple stories, and uses createMockCourseRegistration for the Facilitator story.
Course list labeling and tests
apps/website/src/components/my-courses/useCourseListRow.tsx, apps/website/src/components/my-courses/CourseListRow.test.tsx
Facilitator overflow action label changed to "Drop out"; tests updated to reflect new label visibility across facilitator states.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant DropoutModal
  participant courseRegistrations
  participant dropoutMutation
  participant dropoutOrDeferral
  participant courseRegistrationTable
  User->>DropoutModal: open modal
  DropoutModal->>courseRegistrations: getAll()
  courseRegistrations-->>DropoutModal: [{ role: "Facilitator" } | ...]
  DropoutModal->>DropoutModal: compute allowDeferral (false if Facilitator)
  User->>DropoutModal: submit form (type "Deferral" or "Drop out")
  DropoutModal->>dropoutMutation: mutate(input)
  dropoutMutation->>dropoutOrDeferral: invoke backend mutation
  dropoutOrDeferral->>courseRegistrationTable: fetch registration
  courseRegistrationTable-->>dropoutOrDeferral: registration{ role }
  dropoutOrDeferral->>dropoutOrDeferral: if role === "Facilitator" && type === "Deferral" -> throw FORBIDDEN
  alt allowed
    dropoutOrDeferral->>dropoutMutation: success
    dropoutMutation-->>DropoutModal: success
  else forbidden
    dropoutOrDeferral-->>dropoutMutation: TRPCError(FORBIDDEN)
    dropoutMutation-->>DropoutModal: error
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • marn-in-prod

"I hopped through code and tests with a twitchy nose,
Found deferrals hidden where a facilitator goes.
I nibbled mocks, nudged stories, and guarded the gate,
Now only drop-outs remain — tidy and straight.
🥕🐇"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title '[fix] Facilitators can only drop out, not defer' directly and precisely summarizes the main change in the PR.
Description check ✅ Passed The PR description follows the template with populated sections: Description (explaining the change and incidental fix), Issue (linking #2551), completed developer checklist, and screenshots showing before/after states.
Linked Issues check ✅ Passed The PR fully addresses all requirements from issue #2551: UI label changed to 'Drop out' [useCourseListRow.tsx], deferral option hidden from facilitators [DropoutModal.tsx], server-side enforcement added [dropout.ts], and participant behavior unchanged.
Out of Scope Changes check ✅ Passed All changes are within scope: UI restrictions for facilitators, server-side enforcement, test coverage for new behavior, and incidental schema alignment fix for typecheck.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wh-2551-facilitator-drop-not-defer-2026-05

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/website/src/components/courses/DropoutModal.tsx`:
- Around line 77-82: When courseRegistrations.getAll is still loading
registrations, allowDeferral currently defaults to true and exposes the Deferral
option; change allowDeferral to be false while registrations is undefined and
compute effectiveType only once the registrations are loaded. Concretely, update
allowDeferral to something like: const allowDeferral = registrations ?
registrations.find(r => r.id === applicantId)?.role !== 'Facilitator' : false;
and set effectiveType to undefined while registrations is undefined (e.g., const
effectiveType: DropoutType | undefined = registrations ? (allowDeferral ?
dropoutType : 'Drop out') : undefined) so isDeferral (isDeferral = effectiveType
=== 'Deferral') and the UI won't show/select Deferral until the applicant role
is resolved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 23af9eed-1134-45f1-9191-b223e8394620

📥 Commits

Reviewing files that changed from the base of the PR and between 5249e79 and 161edb0.

📒 Files selected for processing (8)
  • apps/website/src/__tests__/testUtils.ts
  • apps/website/src/components/courses/DropoutModal.stories.tsx
  • apps/website/src/components/courses/DropoutModal.test.tsx
  • apps/website/src/components/courses/DropoutModal.tsx
  • apps/website/src/components/my-courses/CourseListRow.test.tsx
  • apps/website/src/components/my-courses/useCourseListRow.tsx
  • apps/website/src/server/routers/dropout.test.ts
  • apps/website/src/server/routers/dropout.ts

Comment thread apps/website/src/components/courses/DropoutModal.tsx
@greptile-apps

greptile-apps Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR restricts facilitators to drop-out only (no deferral), enforcing the business rule both server-side (a new FORBIDDEN guard in dropout.ts) and in the UI (the Deferral select option is disabled when the logged-in user is a facilitator). The incidental fix adds the missing facilitator-application columns to createMockCourseRegistration so TypeScript passes on master.

  • Server-side enforcement: dropout.ts now throws FORBIDDEN when a facilitator submits a Deferral request, making the restriction tamper-proof regardless of UI state.
  • UI enforcement: DropoutModal.tsx queries courseRegistrations.getAll, derives allowDeferral, and passes it as a disabled flag on the Deferral option; the facilitator overflow item is also relabelled from "Drop or defer course" to "Drop out".
  • Test coverage: A new dropout.test.ts integration test and an updated DropoutModal.test.tsx unit test cover the facilitator-deferral rejection path end-to-end.

Confidence Score: 5/5

Safe to merge; the deferral restriction is correctly enforced at both the UI layer and the server, and the incidental TypeScript fix is mechanical.

All changed paths are well-tested, the server-side guard makes the restriction tamper-proof independent of client state, and the only notable gap is a cosmetic banner that remains visible to facilitators with text that no longer applies to them.

DropoutModal.tsx — the InformationBanner that promotes deferral is still shown unconditionally to facilitators.

Important Files Changed

Filename Overview
apps/website/src/server/routers/dropout.ts Added server-side FORBIDDEN guard that prevents facilitators from deferring; correct and well-placed after the NOT_FOUND check.
apps/website/src/components/courses/DropoutModal.tsx Adds allowDeferral flag from registrations query and disables the Deferral option for facilitators; InformationBanner still unconditionally tells all users to consider deferring even when deferral is unavailable.
apps/website/src/server/routers/dropout.test.ts New integration test file covering unauthenticated access, facilitator deferral rejection, facilitator drop-out, and participant deferral — good coverage of the new guard.
apps/website/src/components/courses/DropoutModal.test.tsx Added a test verifying the Deferral option is disabled for facilitators and that drop-out still submits correctly.
apps/website/src/components/courses/DropoutModal.stories.tsx Added Facilitator story and wired up the missing participantRegistrationsHandler in all existing stories.
apps/website/src/components/my-courses/useCourseListRow.tsx Renamed overflow action label from 'Drop or defer course' to 'Drop out' for the facilitator action list.
apps/website/src/components/my-courses/CourseListRow.test.tsx Updated test assertions to match the new 'Drop out' label throughout facilitator test cases.
apps/website/src/tests/testUtils.ts Added seven nullable facilitator-application columns to createMockCourseRegistration to fix the TypeScript error introduced in #2563.

Sequence Diagram

sequenceDiagram
    participant U as User (Facilitator)
    participant M as DropoutModal
    participant TR as tRPC: courseRegistrations.getAll
    participant TD as tRPC: dropout.dropoutOrDeferral

    U->>M: Opens "Drop out" modal
    M->>TR: getAll()
    TR-->>M: registrations[]
    M->>M: "allowDeferral = role !== 'Facilitator' → false"
    M->>U: Renders form (Deferral option disabled)

    U->>M: Selects "Drop out of the course"
    U->>M: Clicks Submit
    M->>TD: "{ type: "Drop out", applicantId }"
    TD->>TD: Fetch courseRegistration by id+email
    TD->>TD: "role === 'Facilitator' && type === 'Deferral'? → No, proceed"
    TD-->>M: Dropout record created
    M-->>U: Success message

    Note over TD: If type === 'Deferral' and role === 'Facilitator'
    TD-->>M: FORBIDDEN error
Loading

Reviews (2): Last reviewed commit: "[fix] Facilitators can only drop out, no..." | Re-trigger Greptile

Comment thread apps/website/src/components/courses/DropoutModal.tsx Outdated
@Will-Howard Will-Howard force-pushed the wh-2551-facilitator-drop-not-defer-2026-05 branch from 161edb0 to 2c364c1 Compare May 27, 2026 19:13
@Will-Howard Will-Howard temporarily deployed to wh-2551-facilitator-drop-not-defer-2026-05 - bluedot-preview PR #2564 May 27, 2026 19:13 — with Render Destroyed
@Will-Howard Will-Howard temporarily deployed to wh-2551-facilitator-drop-not-defer-2026-05 - bluedot-storybook-preview PR #2564 May 27, 2026 19:13 — with Render Destroyed
@Will-Howard Will-Howard marked this pull request as ready for review May 27, 2026 19:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
apps/website/src/components/courses/DropoutModal.tsx (1)

76-78: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve role before enabling/submitting deferral.

Deferral is still enabled while registrations are unresolved, and submission uses dropoutType directly, so facilitator flows can still hit a forbidden deferral path from client state.

Suggested patch
-  const { data: registrations } = trpc.courseRegistrations.getAll.useQuery();
-  const allowDeferral = registrations?.find((r) => r.id === applicantId)?.role !== 'Facilitator';
-
-  const isDeferral = dropoutType === 'Deferral';
+  const { data: registrations } = trpc.courseRegistrations.getAll.useQuery();
+  const registration = registrations?.find((r) => r.id === applicantId);
+  const registrationsLoaded = registrations !== undefined;
+  const allowDeferral = registrationsLoaded
+    ? registration?.role !== 'Facilitator'
+    : false;
+  const effectiveType: DropoutType | undefined = registrationsLoaded
+    ? (allowDeferral ? dropoutType : 'Drop out')
+    : undefined;
+
+  const isDeferral = effectiveType === 'Deferral';
@@
-  const submitDisabled = !dropoutType
+  const submitDisabled = !effectiveType
     || dropoutMutation.isPending
     || (isDeferral && !effectiveTargetRoundId);
@@
-    if (!dropoutType) return;
+    if (!effectiveType) return;
     if (isDeferral && !effectiveTargetRoundId) return;
@@
-      type: dropoutType,
+      type: effectiveType,
       newRoundId: isDeferral ? effectiveTargetRoundId : undefined,
@@
-          value={dropoutType}
+          value={effectiveType}

Also applies to: 208-212

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/website/src/components/courses/DropoutModal.tsx` around lines 76 - 78,
The component enables and submits deferral before registrations are loaded
causing facilitator users to bypass checks; update the logic around
trpc.courseRegistrations.getAll.useQuery, registrations, allowDeferral and any
submission handlers that reference dropoutType so they first ensure
registrations is defined and the applicant's role is resolved (e.g., compute
allowDeferral only when registrations !== undefined and applicantId matches a
resolved registration), disable the deferral UI while registrations are loading,
and additionally re-check the resolved role inside the submit handler
(reject/abort submission or ignore dropoutType when the found registration.role
=== 'Facilitator') to prevent client-side forbidden flows.
🧹 Nitpick comments (1)
apps/website/src/components/courses/DropoutModal.test.tsx (1)

139-141: ⚡ Quick win

Assert disabled behavior instead of a styling class.

This assertion is coupled to presentational CSS and can fail on harmless style changes; verifying non-selectability is more stable.

Suggested patch
-    await waitFor(() => {
-      expect(within(listbox).getByRole('option', { name: 'Defer to a future round' })).toHaveClass('cursor-not-allowed');
-    });
+    const deferralOption = await within(listbox).findByRole('option', { name: 'Defer to a future round' });
+    await user.click(deferralOption);
+    expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/website/src/components/courses/DropoutModal.test.tsx` around lines 139 -
141, The test currently asserts a presentational CSS class on the option fetched
via within(listbox).getByRole('option', { name: 'Defer to a future round' })
which is brittle; change the assertion to verify non-selectability instead
(e.g., assert the option is disabled or has aria-disabled="true" or is not
enabled) so the test checks behavior not styling—update the assertion in
DropoutModal.test.tsx to use toBeDisabled() or
expect(...).toHaveAttribute('aria-disabled','true') against the same role
lookup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@apps/website/src/components/courses/DropoutModal.tsx`:
- Around line 76-78: The component enables and submits deferral before
registrations are loaded causing facilitator users to bypass checks; update the
logic around trpc.courseRegistrations.getAll.useQuery, registrations,
allowDeferral and any submission handlers that reference dropoutType so they
first ensure registrations is defined and the applicant's role is resolved
(e.g., compute allowDeferral only when registrations !== undefined and
applicantId matches a resolved registration), disable the deferral UI while
registrations are loading, and additionally re-check the resolved role inside
the submit handler (reject/abort submission or ignore dropoutType when the found
registration.role === 'Facilitator') to prevent client-side forbidden flows.

---

Nitpick comments:
In `@apps/website/src/components/courses/DropoutModal.test.tsx`:
- Around line 139-141: The test currently asserts a presentational CSS class on
the option fetched via within(listbox).getByRole('option', { name: 'Defer to a
future round' }) which is brittle; change the assertion to verify
non-selectability instead (e.g., assert the option is disabled or has
aria-disabled="true" or is not enabled) so the test checks behavior not
styling—update the assertion in DropoutModal.test.tsx to use toBeDisabled() or
expect(...).toHaveAttribute('aria-disabled','true') against the same role
lookup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: be63325b-1a23-4611-9cf1-e907b943f05a

📥 Commits

Reviewing files that changed from the base of the PR and between 161edb0 and 2c364c1.

📒 Files selected for processing (8)
  • apps/website/src/__tests__/testUtils.ts
  • apps/website/src/components/courses/DropoutModal.stories.tsx
  • apps/website/src/components/courses/DropoutModal.test.tsx
  • apps/website/src/components/courses/DropoutModal.tsx
  • apps/website/src/components/my-courses/CourseListRow.test.tsx
  • apps/website/src/components/my-courses/useCourseListRow.tsx
  • apps/website/src/server/routers/dropout.test.ts
  • apps/website/src/server/routers/dropout.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/website/src/components/my-courses/useCourseListRow.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • apps/website/src/server/routers/dropout.ts
  • apps/website/src/tests/testUtils.ts
  • apps/website/src/server/routers/dropout.test.ts
  • apps/website/src/components/courses/DropoutModal.stories.tsx
  • apps/website/src/components/my-courses/CourseListRow.test.tsx

@Will-Howard Will-Howard merged commit f54730b into master May 27, 2026
8 checks passed
@Will-Howard Will-Howard deleted the wh-2551-facilitator-drop-not-defer-2026-05 branch May 27, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Facilitators: Only allow dropping out, not deferring

1 participant