From 913674076be38f4467fcc9ecc88a0ee63ff1efec Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:09:29 -0400 Subject: [PATCH 01/11] #364 add the application status transition graph to constants Single source of truth for the six-status pipeline direction, so the server guard and the rendered quick actions can't drift apart. Co-Authored-By: Claude Sonnet 4.6 --- .../features/application-status-control.tsx | 117 ------------------ lib/constants.ts | 91 ++++++++++++++ 2 files changed, 91 insertions(+), 117 deletions(-) delete mode 100644 components/features/application-status-control.tsx diff --git a/components/features/application-status-control.tsx b/components/features/application-status-control.tsx deleted file mode 100644 index 368aadc3..00000000 --- a/components/features/application-status-control.tsx +++ /dev/null @@ -1,117 +0,0 @@ -'use client'; - -import { useId, useTransition } from 'react'; - -import { Loader2 } from 'lucide-react'; -import { toast } from 'sonner'; - -import { updateApplicationStatus } from '@/prisma/actions/applications'; -import { type $Enums } from '@/prisma/client'; - -import { - APPLICATION_STATUS_LABELS, - NON_REVIEWABLE_APPLICATION_STATUS_NOTES, - REVIEWER_APPLICATION_STATUS_OPTIONS, - isNonReviewableApplicationStatus, -} from '@/lib/constants'; -import { cn } from '@/lib/utils'; - -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; - -interface ApplicationStatusControlProps { - applicationId: string; - currentStatus: $Enums.ApplicationStatus; - labelText?: string; - // Visually hides the label — kept in the a11y tree and still focuses the select on click. - hideLabel?: boolean; -} - -export function ApplicationStatusControl({ - applicationId, - currentStatus, - labelText = 'Status', - hideLabel, -}: ApplicationStatusControlProps) { - const [isPending, startTransition] = useTransition(); - const fieldId = useId(); - const noteId = useId(); - - // Reviewer-selectable options — 'draft' is already excluded from this constant. - const options = REVIEWER_APPLICATION_STATUS_OPTIONS; - - // Derived from the shared constant (not a hand-written check) so this can - // never drift from the action's own non-reviewable guard. - const isReadOnly = isNonReviewableApplicationStatus(currentStatus); - - function handleValueChange(value: string) { - startTransition(async () => { - try { - const result = await updateApplicationStatus({ - applicationId, - status: value, - }); - if (result && 'error' in result) { - toast.error(result.error); - } else { - toast.success('Status updated'); - } - } catch { - toast.error('Something went wrong. Please try again.'); - } - }); - } - - return ( -
- -
- {isPending && ( - - )} - -
- {isReadOnly && ( -

- {NON_REVIEWABLE_APPLICATION_STATUS_NOTES[currentStatus]} -

- )} -
- ); -} diff --git a/lib/constants.ts b/lib/constants.ts index 66f754e5..ed8abd58 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -357,6 +357,97 @@ export const REVIEWER_APPLICATION_STATUSES = [ export const REVIEWER_APPLICATION_STATUS_OPTIONS = APPLICATION_STATUS_OPTIONS.filter((o) => o.value !== 'draft'); +// Single source of truth for both the server guard and the rendered quick +// actions. Array order is display order — the first `forward` entry is the +// primary button. `draft`/`withdrawn` have no reviewer-initiated moves. +export const APPLICATION_STATUS_TRANSITIONS = { + draft: { forward: [], back: [] }, + applied: { forward: ['reached_out', 'reviewing'], back: [] }, + reached_out: { + forward: ['interview_scheduled', 'reviewing'], + back: ['applied'], + }, + interview_scheduled: { + forward: ['reviewing', 'accepted'], + back: ['reached_out'], + }, + reviewing: { + forward: ['interview_scheduled', 'accepted'], + back: ['reached_out'], + }, + accepted: { forward: [], back: ['reviewing', 'interview_scheduled'] }, + rejected: { forward: [], back: ['reviewing', 'interview_scheduled'] }, + withdrawn: { forward: [], back: [] }, +} as const satisfies Record< + $Enums.ApplicationStatus, + { + forward: readonly $Enums.ApplicationStatus[]; + back: readonly $Enums.ApplicationStatus[]; + } +>; + +// Same members as UNRESOLVED_APPLICATION_STATUSES, but a different meaning — +// 'rejected' is reachable from every one of these. +export const REJECTABLE_APPLICATION_STATUSES = [ + 'applied', + 'reached_out', + 'interview_scheduled', + 'reviewing', +] as const satisfies $Enums.ApplicationStatus[]; + +export function getAllowedApplicationStatusTransitions( + from: $Enums.ApplicationStatus, +): $Enums.ApplicationStatus[] { + const { forward, back } = APPLICATION_STATUS_TRANSITIONS[from]; + const isRejectable = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(from); + return [ + ...forward, + ...(isRejectable ? (['rejected'] as const) : []), + ...back, + ]; +} + +export function isAllowedApplicationStatusTransition( + from: $Enums.ApplicationStatus, + to: $Enums.ApplicationStatus, +): boolean { + return getAllowedApplicationStatusTransitions(from).includes(to); +} + +// Inverts the graph rather than hand-listing sources, so the two can't drift. +export function getApplicationStatusSources( + to: $Enums.ApplicationStatus, +): $Enums.ApplicationStatus[] { + return ( + Object.keys(APPLICATION_STATUS_TRANSITIONS) as $Enums.ApplicationStatus[] + ).filter((from) => isAllowedApplicationStatusTransition(from, to)); +} + +// Imperative copy for forward/decision quick-action buttons. 'applied' is a +// back-target only — a reviewer never moves an application forward into it. +export const APPLICATION_STATUS_ACTION_LABELS: Record< + (typeof REVIEWER_APPLICATION_STATUSES)[number], + string +> = { + applied: 'Move to applied', + reached_out: 'Mark reached out', + reviewing: 'Move to reviewing', + interview_scheduled: 'Schedule interview', + accepted: 'Accept', + rejected: 'Reject', +}; + +// The one line explaining why the panel has no forward actions from here. +export const TERMINAL_DECISION_STATUS_NOTES: Record< + 'accepted' | 'rejected', + string +> = { + accepted: 'Accepted. The applicant can no longer withdraw this application.', + rejected: 'Rejected. The applicant can no longer withdraw this application.', +}; + // States a reviewer may not act *on*, unlike REVIEWER_APPLICATION_STATUSES (may set *to*). export const NON_REVIEWABLE_APPLICATION_STATUSES = [ 'draft', From 32e5866b3e92d6e98663efb024284d146eaa01d9 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:09:43 -0400 Subject: [PATCH 02/11] #364 enforce the status transition graph in application actions updateApplicationStatus and updateApplicationStatuses now scope their writes to the graph's legal source states, so an illegal move fails even from a forged request or a stale tab. Bulk failures and concurrent-change misses now return a user-facing error naming the target instead of throwing. Co-Authored-By: Claude Sonnet 4.6 --- prisma/actions/applications.ts | 38 +++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index d0a0b240..f1546dbd 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -19,12 +19,15 @@ import { ANSWER_LONG_MAX_LENGTH, ANSWER_MAX_VALUES, APPLICANT_EDITABLE_APPLICATION_STATUSES, + APPLICATION_STATUS_LABELS, NON_REVIEWABLE_APPLICATION_STATUSES, PUBLISHED_POSITION_WHERE, REVIEWER_APPLICATION_STATUSES, SHORT_ANSWER_FORMAT_ERROR_MESSAGES, TERMINAL_DECISION_STATUSES, getAnswerValueError, + getApplicationStatusSources, + isAllowedApplicationStatusTransition, matchesShortAnswerFormat, } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; @@ -500,17 +503,33 @@ export async function updateApplicationStatus( const application = await prisma.application.findFirst({ where, - select: { id: true }, + select: { id: true, status: true }, }); // IDOR-style miss, unreachable from the UI — throw, don't return. if (!application) throw new Error('Application not found or not authorized'); - await prisma.application.update({ - where: { id: applicationId }, + if (!isAllowedApplicationStatusTransition(application.status, status)) + return { + error: `This application is now ${APPLICATION_STATUS_LABELS[application.status]}, so that move is no longer available. Refresh to see the current options.`, + }; + + // Narrower than the `where` above: sources never include draft/withdrawn, + // which that `notIn` already excludes. + const updateResult = await prisma.application.updateMany({ + where: { + id: applicationId, + status: { in: getApplicationStatusSources(status) }, + }, data: { status, updatedById: user.id }, }); + if (updateResult.count === 0) + return { + error: + 'This application just changed. Refresh to see its current status.', + }; + revalidatePath(`/applications/${applicationId}`); revalidatePath('/applications'); } @@ -533,18 +552,19 @@ export async function updateApplicationStatuses( const applicationIds = Array.from(new Set(parsed.data.applicationIds)); const { status } = parsed.data; - // The scoped where silently excludes forged and out-of-scope ids. + // The scoped where silently excludes forged, out-of-scope, and + // graph-illegal ids — ineligible rows are skipped, not a batch failure. const where = user.isAdmin ? { id: { in: applicationIds }, deletedAt: null, - status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + status: { in: getApplicationStatusSources(status) }, position: PUBLISHED_POSITION_WHERE, } : { id: { in: applicationIds }, deletedAt: null, - status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + status: { in: getApplicationStatusSources(status) }, // Merge, don't overwrite — see prisma/data/applications.ts#buildBaseWhere. position: { ...PUBLISHED_POSITION_WHERE, @@ -557,8 +577,10 @@ export async function updateApplicationStatuses( data: { status, updatedById: user.id }, }); - // IDOR-style miss, unreachable from the UI — throw, don't return. - if (result.count === 0) throw new Error('No applications were updated'); + if (result.count === 0) + return { + error: `None of the selected applications can move to ${APPLICATION_STATUS_LABELS[status]}.`, + }; revalidatePath('/applications'); // Wildcard segment: a bulk update has no individual positionIds to hand. From 9f4d9af261408e458e767d65bae1be32cb41f417 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:09:50 -0400 Subject: [PATCH 03/11] #364 replace the status dropdown with graph-driven quick actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplicationStatusActions renders only the legal next moves for the current status — primary/secondary forward buttons, a destructive Reject, and explicit move-back controls — as a roomy panel on the review detail page and a constrained menu in the per-position table. Accept and Reject route through the shared confirm dialog. Co-Authored-By: Claude Sonnet 4.6 --- app/(main)/(auth)/applications/[id]/page.tsx | 5 +- .../features/application-status-actions.tsx | 266 ++++++++++++++++++ 2 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 components/features/application-status-actions.tsx diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index fc8a2720..434c29eb 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -7,7 +7,7 @@ import { getCurrentUser } from '@/lib/auth/server'; import { getRenamedTo } from '@/lib/utils'; import { ApplicationAnswersList } from '@/components/features/application-answers-list'; -import { ApplicationStatusControl } from '@/components/features/application-status-control'; +import { ApplicationStatusActions } from '@/components/features/application-status-actions'; import { ApplicationStatusBadge } from '@/components/features/status-badge'; import { PageHeader } from '@/components/layouts/page-header'; import { @@ -111,9 +111,10 @@ export default async function ApplicationDetailPage({ - diff --git a/components/features/application-status-actions.tsx b/components/features/application-status-actions.tsx new file mode 100644 index 00000000..82a738b5 --- /dev/null +++ b/components/features/application-status-actions.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { Loader2, MoreHorizontal } from 'lucide-react'; +import { toast } from 'sonner'; + +import { updateApplicationStatus } from '@/prisma/actions/applications'; +import type { $Enums } from '@/prisma/client'; + +import { + APPLICATION_STATUS_ACTION_LABELS, + APPLICATION_STATUS_LABELS, + APPLICATION_STATUS_TRANSITIONS, + NON_REVIEWABLE_APPLICATION_STATUS_NOTES, + REJECTABLE_APPLICATION_STATUSES, + TERMINAL_DECISION_STATUS_NOTES, + isNonReviewableApplicationStatus, +} from '@/lib/constants'; + +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface ApplicationStatusActionsProps { + applicationId: string; + currentStatus: $Enums.ApplicationStatus; + applicantName?: string; + compact?: boolean; +} + +const CONFIRM_COPY = { + accepted: { + title: (name: string) => `Accept ${name}?`, + description: + "They'll see Accepted on their application and can no longer withdraw it. You can move this back later.", + confirmLabel: 'Accept', + pendingLabel: 'Accepting…', + }, + rejected: { + title: (name: string) => `Reject ${name}?`, + description: + "They'll see Rejected on their application and can no longer withdraw it. You can move this back later.", + confirmLabel: 'Reject', + pendingLabel: 'Rejecting…', + }, +} as const; + +export function ApplicationStatusActions({ + applicationId, + currentStatus, + applicantName, + compact, +}: ApplicationStatusActionsProps) { + const [isPending, startTransition] = useTransition(); + const [pendingTarget, setPendingTarget] = + useState<$Enums.ApplicationStatus | null>(null); + const [menuOpen, setMenuOpen] = useState(false); + const [confirmTarget, setConfirmTarget] = useState<'accepted' | 'rejected'>( + 'accepted', + ); + const [confirmOpen, setConfirmOpen] = useState(false); + + const displayName = applicantName ?? 'this application'; + + function performMove( + target: $Enums.ApplicationStatus, + onSuccess?: () => void, + ) { + setPendingTarget(target); + startTransition(async () => { + try { + const result = await updateApplicationStatus({ + applicationId, + status: target, + }); + if (result && 'error' in result) { + toast.error(result.error); + return; + } + toast.success(`Moved to ${APPLICATION_STATUS_LABELS[target]}`); + onSuccess?.(); + } catch { + toast.error('Something went wrong. Please try again.'); + } finally { + setPendingTarget(null); + } + }); + } + + function openConfirm(target: 'accepted' | 'rejected') { + setConfirmTarget(target); + setConfirmOpen(true); + } + + function handleForwardSelect(target: $Enums.ApplicationStatus) { + if (target === 'accepted') openConfirm('accepted'); + else performMove(target); + } + + if (isNonReviewableApplicationStatus(currentStatus)) { + if (compact) return null; + return ( +

+ {NON_REVIEWABLE_APPLICATION_STATUS_NOTES[currentStatus]} +

+ ); + } + + const isTerminalDecision = + currentStatus === 'accepted' || currentStatus === 'rejected'; + const { forward, back } = APPLICATION_STATUS_TRANSITIONS[currentStatus]; + const isRejectable = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(currentStatus); + + const confirmCopy = CONFIRM_COPY[confirmTarget]; + + const confirmDialog = ( + performMove(confirmTarget, () => setConfirmOpen(false))} + /> + ); + + if (compact) { + return ( + <> + + + + + + {forward.map((target) => ( + { + e.preventDefault(); + setMenuOpen(false); + handleForwardSelect(target); + }} + > + {APPLICATION_STATUS_ACTION_LABELS[target]} + + ))} + {isRejectable && ( + <> + + { + e.preventDefault(); + setMenuOpen(false); + openConfirm('rejected'); + }} + > + {APPLICATION_STATUS_ACTION_LABELS.rejected} + + + )} + {back.length > 0 && ( + <> + + Move back + {back.map((target) => ( + { + e.preventDefault(); + setMenuOpen(false); + performMove(target); + }} + > + Move back to {APPLICATION_STATUS_LABELS[target]} + + ))} + + )} + + + {confirmDialog} + + ); + } + + return ( +
+ {isTerminalDecision && ( +

+ {TERMINAL_DECISION_STATUS_NOTES[currentStatus]} +

+ )} + {!isTerminalDecision && ( +
+ {forward.map((target, i) => ( + + ))} + {isRejectable && ( + + )} +
+ )} + {back.length > 0 && ( +
+ {back.map((target) => ( + + ))} +
+ )} + {confirmDialog} +
+ ); +} From 6e800b2baa8d18a14bd58b39b2d26ab99b1c00e7 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:09:57 -0400 Subject: [PATCH 04/11] #364 add tests for the application status transition graph Unit tests assert the graph invariants (totality, no self-loops, rejected reachability, source/target inversion). DB tests replace the old "every target reachable from applied" loop with the full 6x6 source x target matrix and add mixed-selection bulk cases. Co-Authored-By: Claude Sonnet 4.6 --- tests/db/application-transitions.test.ts | 143 +++++++++++++++------ tests/db/authorization.test.ts | 15 ++- tests/unit/application-transitions.test.ts | 72 +++++++++++ 3 files changed, 185 insertions(+), 45 deletions(-) create mode 100644 tests/unit/application-transitions.test.ts diff --git a/tests/db/application-transitions.test.ts b/tests/db/application-transitions.test.ts index cd7e5663..c2bb6a12 100644 --- a/tests/db/application-transitions.test.ts +++ b/tests/db/application-transitions.test.ts @@ -15,14 +15,18 @@ import { deleteDraftApplication, submitApplication, updateApplicationStatus, + updateApplicationStatuses, withdrawApplication, } from '@/prisma/actions/applications'; import type { $Enums, GlobalQuestion, Position, User } from '@/prisma/client'; import { APPLICANT_EDITABLE_APPLICATION_STATUSES, + APPLICATION_STATUS_LABELS, APPLICATION_STATUS_VALUES, + NON_REVIEWABLE_APPLICATION_STATUSES, REVIEWER_APPLICATION_STATUSES, + isAllowedApplicationStatusTransition, } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; import { isError } from '@/lib/utils'; @@ -151,47 +155,58 @@ describe('deleteDraftApplication', () => { }); describe('updateApplicationStatus', () => { - for (const target of REVIEWER_APPLICATION_STATUSES) { - it(`allows updating an in-scope application into ${target}`, async () => { - const applicant = await createTestUser(); - const managingAdmin = admin; - const application = await createTestApplication(applicant, openPosition, { - status: 'applied', - }); - - actAs(managingAdmin); - const result = await updateApplicationStatus({ - applicationId: application.id, - status: target, - }); - expect(result).toBeUndefined(); - - const updated = await prisma.application.findUniqueOrThrow({ - where: { id: application.id }, - select: { status: true }, + // Full source x target matrix, driven off isAllowedApplicationStatusTransition + // so this suite can't drift from the graph it's asserting against. + for (const from of ALL_STATUSES) { + const isNonReviewable = ( + NON_REVIEWABLE_APPLICATION_STATUSES as readonly string[] + ).includes(from); + + for (const to of REVIEWER_APPLICATION_STATUSES) { + const isLegal = + !isNonReviewable && isAllowedApplicationStatusTransition(from, to); + + it(`${isNonReviewable ? 'throws' : isLegal ? 'allows' : 'blocks'} ${from} -> ${to}`, async () => { + const applicant = await createTestUser(); + const application = await createTestApplication( + applicant, + openPosition, + { status: from }, + ); + + actAs(admin); + + if (isNonReviewable) { + await expect( + updateApplicationStatus({ + applicationId: application.id, + status: to, + }), + ).rejects.toThrow('Application not found or not authorized'); + return; + } + + const result = await updateApplicationStatus({ + applicationId: application.id, + status: to, + }); + + if (isLegal) { + expect(result).toBeUndefined(); + const updated = await prisma.application.findUniqueOrThrow({ + where: { id: application.id }, + select: { status: true }, + }); + expect(updated.status).toBe(to); + } else { + expect(result).toEqual({ + error: `This application is now ${APPLICATION_STATUS_LABELS[from]}, so that move is no longer available. Refresh to see the current options.`, + }); + } }); - expect(updated.status).toBe(target); - }); + } } - it.each(['draft', 'withdrawn'] as const)( - 'throws when the current status is %s', - async (currentStatus) => { - const applicant = await createTestUser(); - const application = await createTestApplication(applicant, openPosition, { - status: currentStatus, - }); - - actAs(admin); - await expect( - updateApplicationStatus({ - applicationId: application.id, - status: 'applied', - }), - ).rejects.toThrow('Application not found or not authorized'); - }, - ); - it('returns a zod error when the target status is draft', async () => { const applicant = await createTestUser(); const application = await createTestApplication(applicant, openPosition, { @@ -207,6 +222,58 @@ describe('updateApplicationStatus', () => { }); }); +describe('updateApplicationStatuses bulk mixed selections', () => { + it('updates only the legal-source rows in a mixed selection and skips the rest', async () => { + const appliedApplicant = await createTestUser(); + const appliedApp = await createTestApplication( + appliedApplicant, + openPosition, + { status: 'applied' }, + ); + const acceptedApplicant = await createTestUser(); + const acceptedApp = await createTestApplication( + acceptedApplicant, + openPosition, + { status: 'accepted' }, + ); + + actAs(admin); + const result = await updateApplicationStatuses({ + applicationIds: [appliedApp.id, acceptedApp.id], + status: 'reviewing', + }); + expect(result).toEqual({ updated: 1, skipped: 1 }); + + const updatedApplied = await prisma.application.findUniqueOrThrow({ + where: { id: appliedApp.id }, + select: { status: true }, + }); + expect(updatedApplied.status).toBe('reviewing'); + + const untouchedAccepted = await prisma.application.findUniqueOrThrow({ + where: { id: acceptedApp.id }, + select: { status: true }, + }); + expect(untouchedAccepted.status).toBe('accepted'); + }); + + it('returns an error naming the target when no selected row can legally move there', async () => { + const applicant = await createTestUser(); + const application = await createTestApplication(applicant, openPosition, { + status: 'applied', + }); + + actAs(admin); + const result = await updateApplicationStatuses({ + applicationIds: [application.id], + status: 'accepted', + }); + expect(result).toEqual({ + error: 'None of the selected applications can move to Accepted.', + }); + }); +}); + describe('precedence', () => { it('an already-submitted application on a soft-deleted position returns the status error', async () => { const applicant = await createTestUser(); diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index a0d091e7..372f99c1 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -418,14 +418,15 @@ describe('updateApplicationStatuses', () => { expect(untouched.status).toBe('applied'); }); - it('throws when every id is out of scope', async () => { + it('returns an error when every id is out of scope', async () => { actAs(managerA); - await expect( - updateApplicationStatuses({ - applicationIds: [applicationB1.id], - status: 'reviewing', - }), - ).rejects.toThrow('No applications were updated'); + const result = await updateApplicationStatuses({ + applicationIds: [applicationB1.id], + status: 'reviewing', + }); + expect(result).toEqual({ + error: 'None of the selected applications can move to Reviewing.', + }); }); it('skips a withdrawn row while updating the rest', async () => { diff --git a/tests/unit/application-transitions.test.ts b/tests/unit/application-transitions.test.ts new file mode 100644 index 00000000..fe4b07e1 --- /dev/null +++ b/tests/unit/application-transitions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import type { $Enums } from '@/prisma/client'; + +import { + APPLICATION_STATUS_TRANSITIONS, + APPLICATION_STATUS_VALUES, + REJECTABLE_APPLICATION_STATUSES, + getAllowedApplicationStatusTransitions, + getApplicationStatusSources, + isAllowedApplicationStatusTransition, +} from '@/lib/constants'; + +const ALL_STATUSES: $Enums.ApplicationStatus[] = [ + ...APPLICATION_STATUS_VALUES, + 'withdrawn', +]; + +describe('APPLICATION_STATUS_TRANSITIONS', () => { + it('is total over every ApplicationStatus', () => { + for (const status of ALL_STATUSES) + expect(APPLICATION_STATUS_TRANSITIONS[status]).toBeDefined(); + }); + + it('never lists a state as its own forward or back target', () => { + for (const status of ALL_STATUSES) { + const { forward, back } = APPLICATION_STATUS_TRANSITIONS[status]; + expect(forward).not.toContain(status); + expect(back).not.toContain(status); + } + }); + + it('gives draft and withdrawn no moves at all', () => { + for (const status of ['draft', 'withdrawn'] as const) + expect(getAllowedApplicationStatusTransitions(status)).toEqual([]); + }); + + it('never allows a move into draft or withdrawn', () => { + for (const from of ALL_STATUSES) + for (const to of getAllowedApplicationStatusTransitions(from)) { + expect(to).not.toBe('draft'); + expect(to).not.toBe('withdrawn'); + } + }); +}); + +describe('rejected reachability', () => { + it('allows rejected from exactly REJECTABLE_APPLICATION_STATUSES', () => { + for (const status of ALL_STATUSES) { + const canReject = isAllowedApplicationStatusTransition( + status, + 'rejected', + ); + const expected = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(status); + expect(canReject).toBe(expected); + } + }); +}); + +describe('getApplicationStatusSources', () => { + it('is the exact inverse of getAllowedApplicationStatusTransitions', () => { + for (const to of ALL_STATUSES) { + const sources = getApplicationStatusSources(to); + const expected = ALL_STATUSES.filter((from) => + isAllowedApplicationStatusTransition(from, to), + ); + expect(new Set(sources)).toEqual(new Set(expected)); + } + }); +}); From a3200a2fda4e57e40f959cd743540196b949f71c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:57:20 -0400 Subject: [PATCH 05/11] #364 address review feedback Scope bulk status updates to forward-only sources so a batch move can't silently walk an already-decided row backward, matching the PR's stated bulk behavior. Also closes the confirm dialog on a failed move and trims two comments to the one/two-line rule. Co-Authored-By: Claude Sonnet 4.6 --- .../features/application-status-actions.tsx | 4 +-- lib/constants.ts | 25 ++++++++++++++++--- prisma/actions/applications.ts | 9 ++++--- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/components/features/application-status-actions.tsx b/components/features/application-status-actions.tsx index 82a738b5..780924b8 100644 --- a/components/features/application-status-actions.tsx +++ b/components/features/application-status-actions.tsx @@ -72,7 +72,7 @@ export function ApplicationStatusActions({ function performMove( target: $Enums.ApplicationStatus, - onSuccess?: () => void, + onSettled?: () => void, ) { setPendingTarget(target); startTransition(async () => { @@ -86,11 +86,11 @@ export function ApplicationStatusActions({ return; } toast.success(`Moved to ${APPLICATION_STATUS_LABELS[target]}`); - onSuccess?.(); } catch { toast.error('Something went wrong. Please try again.'); } finally { setPendingTarget(null); + onSettled?.(); } }); } diff --git a/lib/constants.ts b/lib/constants.ts index ed8abd58..7623ee77 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -357,9 +357,8 @@ export const REVIEWER_APPLICATION_STATUSES = [ export const REVIEWER_APPLICATION_STATUS_OPTIONS = APPLICATION_STATUS_OPTIONS.filter((o) => o.value !== 'draft'); -// Single source of truth for both the server guard and the rendered quick -// actions. Array order is display order — the first `forward` entry is the -// primary button. `draft`/`withdrawn` have no reviewer-initiated moves. +// Single source of truth for the server guard and the rendered quick actions. +// Array order is display order; `draft`/`withdrawn` have no reviewer moves. export const APPLICATION_STATUS_TRANSITIONS = { draft: { forward: [], back: [] }, applied: { forward: ['reached_out', 'reviewing'], back: [] }, @@ -425,6 +424,25 @@ export function getApplicationStatusSources( ).filter((from) => isAllowedApplicationStatusTransition(from, to)); } +// Bulk targets exclude move-back sources: a batch moving several applications +// toward a target should skip a row already past it, not walk it backward. +export function getApplicationStatusForwardSources( + to: $Enums.ApplicationStatus, +): $Enums.ApplicationStatus[] { + return ( + Object.keys(APPLICATION_STATUS_TRANSITIONS) as $Enums.ApplicationStatus[] + ).filter((from) => { + const { forward } = APPLICATION_STATUS_TRANSITIONS[from]; + const isRejectable = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(from); + return ( + (forward as readonly $Enums.ApplicationStatus[]).includes(to) || + (isRejectable && to === 'rejected') + ); + }); +} + // Imperative copy for forward/decision quick-action buttons. 'applied' is a // back-target only — a reviewer never moves an application forward into it. export const APPLICATION_STATUS_ACTION_LABELS: Record< @@ -439,7 +457,6 @@ export const APPLICATION_STATUS_ACTION_LABELS: Record< rejected: 'Reject', }; -// The one line explaining why the panel has no forward actions from here. export const TERMINAL_DECISION_STATUS_NOTES: Record< 'accepted' | 'rejected', string diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index f1546dbd..d861bd41 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -26,6 +26,7 @@ import { SHORT_ANSWER_FORMAT_ERROR_MESSAGES, TERMINAL_DECISION_STATUSES, getAnswerValueError, + getApplicationStatusForwardSources, getApplicationStatusSources, isAllowedApplicationStatusTransition, matchesShortAnswerFormat, @@ -552,19 +553,19 @@ export async function updateApplicationStatuses( const applicationIds = Array.from(new Set(parsed.data.applicationIds)); const { status } = parsed.data; - // The scoped where silently excludes forged, out-of-scope, and - // graph-illegal ids — ineligible rows are skipped, not a batch failure. + // Forward-only sources: a bulk move-back would silently walk an already- + // decided row backward, so it's skipped like any other ineligible id. const where = user.isAdmin ? { id: { in: applicationIds }, deletedAt: null, - status: { in: getApplicationStatusSources(status) }, + status: { in: getApplicationStatusForwardSources(status) }, position: PUBLISHED_POSITION_WHERE, } : { id: { in: applicationIds }, deletedAt: null, - status: { in: getApplicationStatusSources(status) }, + status: { in: getApplicationStatusForwardSources(status) }, // Merge, don't overwrite — see prisma/data/applications.ts#buildBaseWhere. position: { ...PUBLISHED_POSITION_WHERE, From 75af2009480bc698db0100a60e05e6d605a80870 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:06:45 -0400 Subject: [PATCH 06/11] #364 fix bulk moves to back-only targets like applied getApplicationStatusForwardSources returned [] for 'applied' since no state's forward list ever contains it, making bulk moves to Applied always no-op. Fall back to back-sources when a target has no forward source at all. Co-Authored-By: Claude Sonnet 4.6 --- lib/constants.ts | 15 ++++++++++- tests/db/application-transitions.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/constants.ts b/lib/constants.ts index 7623ee77..218a6192 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -426,10 +426,13 @@ export function getApplicationStatusSources( // Bulk targets exclude move-back sources: a batch moving several applications // toward a target should skip a row already past it, not walk it backward. +// A target with no forward source at all (e.g. 'applied') is back-only, so +// there's no "already past it" row to protect — fall back to its back +// sources rather than making that target permanently unreachable in bulk. export function getApplicationStatusForwardSources( to: $Enums.ApplicationStatus, ): $Enums.ApplicationStatus[] { - return ( + const forwardSources = ( Object.keys(APPLICATION_STATUS_TRANSITIONS) as $Enums.ApplicationStatus[] ).filter((from) => { const { forward } = APPLICATION_STATUS_TRANSITIONS[from]; @@ -441,6 +444,16 @@ export function getApplicationStatusForwardSources( (isRejectable && to === 'rejected') ); }); + if (forwardSources.length > 0) return forwardSources; + + return ( + Object.keys(APPLICATION_STATUS_TRANSITIONS) as $Enums.ApplicationStatus[] + ).filter((from) => + ( + APPLICATION_STATUS_TRANSITIONS[from] + .back as readonly $Enums.ApplicationStatus[] + ).includes(to), + ); } // Imperative copy for forward/decision quick-action buttons. 'applied' is a diff --git a/tests/db/application-transitions.test.ts b/tests/db/application-transitions.test.ts index c2bb6a12..442d5e88 100644 --- a/tests/db/application-transitions.test.ts +++ b/tests/db/application-transitions.test.ts @@ -272,6 +272,40 @@ describe('updateApplicationStatuses bulk mixed selections', () => { error: 'None of the selected applications can move to Accepted.', }); }); + + it('bulk-moves a back-only target (applied) since it has no forward source', async () => { + const reachedOutApplicant = await createTestUser(); + const reachedOutApp = await createTestApplication( + reachedOutApplicant, + openPosition, + { status: 'reached_out' }, + ); + const reviewingApplicant = await createTestUser(); + const reviewingApp = await createTestApplication( + reviewingApplicant, + openPosition, + { status: 'reviewing' }, + ); + + actAs(admin); + const result = await updateApplicationStatuses({ + applicationIds: [reachedOutApp.id, reviewingApp.id], + status: 'applied', + }); + expect(result).toEqual({ updated: 1, skipped: 1 }); + + const updatedReachedOut = await prisma.application.findUniqueOrThrow({ + where: { id: reachedOutApp.id }, + select: { status: true }, + }); + expect(updatedReachedOut.status).toBe('applied'); + + const untouchedReviewing = await prisma.application.findUniqueOrThrow({ + where: { id: reviewingApp.id }, + select: { status: true }, + }); + expect(untouchedReviewing.status).toBe('reviewing'); + }); }); describe('precedence', () => { From 71f8cd9a1b097da76ebef2d8406f39d5d4bf0f1d Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 10:33:31 -0400 Subject: [PATCH 07/11] #364 address review feedback Adds direct unit invariants for getApplicationStatusForwardSources' back-only fallback, per R3-L1. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/application-transitions.test.ts | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/unit/application-transitions.test.ts b/tests/unit/application-transitions.test.ts index fe4b07e1..743d7246 100644 --- a/tests/unit/application-transitions.test.ts +++ b/tests/unit/application-transitions.test.ts @@ -7,6 +7,7 @@ import { APPLICATION_STATUS_VALUES, REJECTABLE_APPLICATION_STATUSES, getAllowedApplicationStatusTransitions, + getApplicationStatusForwardSources, getApplicationStatusSources, isAllowedApplicationStatusTransition, } from '@/lib/constants'; @@ -70,3 +71,52 @@ describe('getApplicationStatusSources', () => { } }); }); + +describe('getApplicationStatusForwardSources', () => { + const forwardOrRejectSources = ( + to: $Enums.ApplicationStatus, + ): $Enums.ApplicationStatus[] => + ALL_STATUSES.filter((from) => { + const { forward } = APPLICATION_STATUS_TRANSITIONS[from]; + const isRejectable = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(from); + return ( + (forward as readonly $Enums.ApplicationStatus[]).includes(to) || + (isRejectable && to === 'rejected') + ); + }); + + const backSources = ( + to: $Enums.ApplicationStatus, + ): $Enums.ApplicationStatus[] => + ALL_STATUSES.filter((from) => + ( + APPLICATION_STATUS_TRANSITIONS[from] + .back as readonly $Enums.ApplicationStatus[] + ).includes(to), + ); + + it('prefers forward/reject sources whenever any exist', () => { + for (const to of ALL_STATUSES) { + const forward = forwardOrRejectSources(to); + if (forward.length === 0) continue; + expect(new Set(getApplicationStatusForwardSources(to))).toEqual( + new Set(forward), + ); + } + }); + + it('falls back to back-sources when a target has no forward source', () => { + const backOnlyTargets = ALL_STATUSES.filter( + (to) => forwardOrRejectSources(to).length === 0, + ); + // Guards against this loop vacuously passing if the graph ever changes. + expect(backOnlyTargets).not.toHaveLength(0); + + for (const to of backOnlyTargets) + expect(new Set(getApplicationStatusForwardSources(to))).toEqual( + new Set(backSources(to)), + ); + }); +}); From ce630b9ddafef1c83683460f9efdcfc59d65126e Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:02:32 -0400 Subject: [PATCH 08/11] #364 apply human-directed status label, quick-action, and bulk copy fixes Reword interview_scheduled to a status label, wire the compact quick- actions menu into the live applications table, and name reachable source statuses in bulk-move error/toast copy. Co-Authored-By: Claude Sonnet 4.6 --- components/features/applications-bulk-bar.tsx | 9 +++++--- components/features/applications-table.tsx | 21 +++++++++++++++-- lib/constants.ts | 2 +- lib/utils.ts | 7 ++++++ prisma/actions/applications.ts | 9 ++++++-- tests/db/application-transitions.test.ts | 3 ++- tests/unit/application-transitions.test.ts | 9 ++++++++ tests/unit/utils.test.ts | 23 +++++++++++++++++++ 8 files changed, 74 insertions(+), 9 deletions(-) diff --git a/components/features/applications-bulk-bar.tsx b/components/features/applications-bulk-bar.tsx index 5b77b982..9e1ac6d6 100644 --- a/components/features/applications-bulk-bar.tsx +++ b/components/features/applications-bulk-bar.tsx @@ -11,10 +11,11 @@ import type { $Enums } from '@/prisma/client'; import { APPLICATION_STATUS_LABELS, REVIEWER_APPLICATION_STATUS_OPTIONS, + getApplicationStatusSources, isNonReviewableApplicationStatus, } from '@/lib/constants'; import type { ApplicationListRow } from '@/lib/types'; -import { summarizeBulkStatusChange } from '@/lib/utils'; +import { formatAlternatives, summarizeBulkStatusChange } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -73,11 +74,13 @@ export function ApplicationsBulkBar({ if (skipped === 0) { toast.success(`Updated ${updated} ${applicationNoun(updated)}`); } else { + const sourceLabels = getApplicationStatusSources(status).map( + (source) => APPLICATION_STATUS_LABELS[source], + ); toast.success( `Updated ${updated} of ${updated + skipped} applications`, { - description: - "Withdrawn applications can't be updated. The skipped rows are still selected.", + description: `${statusLabel} is only reachable from ${formatAlternatives(sourceLabels)}. The skipped rows are still selected.`, }, ); } diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index 35b9cf5e..d91ce9a8 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -16,6 +16,7 @@ import type { } from '@/lib/types'; import { getRenamedTo } from '@/lib/utils'; +import { ApplicationStatusActions } from '@/components/features/application-status-actions'; import { ApplicationsBulkBar } from '@/components/features/applications-bulk-bar'; // Server-side sort; its param format stays decoupled from useSortableTable's. import { SortableHeader } from '@/components/features/sortable-header'; @@ -255,7 +256,15 @@ export function ApplicationsTable({ {app.position.title} - +
+ + +
@@ -297,7 +306,15 @@ export function ApplicationsTable({ )} - +
+ + +
{(app.applicantName ?? app.user.name) && ( diff --git a/lib/constants.ts b/lib/constants.ts index 218a6192..da7afa0f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -465,7 +465,7 @@ export const APPLICATION_STATUS_ACTION_LABELS: Record< applied: 'Move to applied', reached_out: 'Mark reached out', reviewing: 'Move to reviewing', - interview_scheduled: 'Schedule interview', + interview_scheduled: 'Interview scheduled', accepted: 'Accept', rejected: 'Reject', }; diff --git a/lib/utils.ts b/lib/utils.ts index 4208ac88..628248b9 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -67,6 +67,13 @@ export function toStringArray(v: unknown): string[] { return []; } +/** Alternatives ("reachable from X or Y"), not a conjunction — "or" throughout. */ +export function formatAlternatives(labels: string[]): string { + if (labels.length <= 1) return labels.join(''); + if (labels.length === 2) return labels.join(' or '); + return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}`; +} + /** Joins truthy ids for `aria-describedby`; `undefined` when none apply. */ export function composeDescribedBy( ...ids: Array diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index d861bd41..f21cf63b 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -35,6 +35,7 @@ import { prisma } from '@/lib/prisma'; import { type AnswerQuestion } from '@/lib/types'; import { type ResponseType, + formatAlternatives, isAcceptingApplications, isAnswered, isError, @@ -578,10 +579,14 @@ export async function updateApplicationStatuses( data: { status, updatedById: user.id }, }); - if (result.count === 0) + if (result.count === 0) { + const sourceLabels = getApplicationStatusSources(status).map( + (source) => APPLICATION_STATUS_LABELS[source], + ); return { - error: `None of the selected applications can move to ${APPLICATION_STATUS_LABELS[status]}.`, + error: `None of the selected applications can move to ${APPLICATION_STATUS_LABELS[status]} — that's only reachable from ${formatAlternatives(sourceLabels)}.`, }; + } revalidatePath('/applications'); // Wildcard segment: a bulk update has no individual positionIds to hand. diff --git a/tests/db/application-transitions.test.ts b/tests/db/application-transitions.test.ts index 442d5e88..82761084 100644 --- a/tests/db/application-transitions.test.ts +++ b/tests/db/application-transitions.test.ts @@ -269,7 +269,8 @@ describe('updateApplicationStatuses bulk mixed selections', () => { status: 'accepted', }); expect(result).toEqual({ - error: 'None of the selected applications can move to Accepted.', + error: + "None of the selected applications can move to Accepted — that's only reachable from Interview scheduled or Reviewing.", }); }); diff --git a/tests/unit/application-transitions.test.ts b/tests/unit/application-transitions.test.ts index 743d7246..16a16cc6 100644 --- a/tests/unit/application-transitions.test.ts +++ b/tests/unit/application-transitions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { $Enums } from '@/prisma/client'; import { + APPLICATION_STATUS_ACTION_LABELS, APPLICATION_STATUS_TRANSITIONS, APPLICATION_STATUS_VALUES, REJECTABLE_APPLICATION_STATUSES, @@ -45,6 +46,14 @@ describe('APPLICATION_STATUS_TRANSITIONS', () => { }); }); +describe('APPLICATION_STATUS_ACTION_LABELS', () => { + it('describes interview_scheduled as a status, not an imperative action', () => { + expect(APPLICATION_STATUS_ACTION_LABELS.interview_scheduled).toBe( + 'Interview scheduled', + ); + }); +}); + describe('rejected reachability', () => { it('allows rejected from exactly REJECTABLE_APPLICATION_STATUSES', () => { for (const status of ALL_STATUSES) { diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 721ff2b7..93da9696 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { MANAGED_POSITIONS_WINDOW_DAYS } from '@/lib/constants'; import type { AnswerQuestion, PositionActivity } from '@/lib/types'; import { + formatAlternatives, formatTableCount, getPositionAvailability, isAnswered, @@ -399,6 +400,28 @@ describe('summarizeBulkStatusChange', () => { }); }); +describe('formatAlternatives', () => { + it('returns a single label as-is', () => { + expect(formatAlternatives(['Reviewing'])).toBe('Reviewing'); + }); + + it('joins two labels with "or"', () => { + expect(formatAlternatives(['Reached out', 'Reviewing'])).toBe( + 'Reached out or Reviewing', + ); + }); + + it('joins three or more labels with a comma list and a trailing "or"', () => { + expect(formatAlternatives(['Applied', 'Reached out', 'Reviewing'])).toBe( + 'Applied, Reached out, or Reviewing', + ); + }); + + it('returns an empty string for no labels', () => { + expect(formatAlternatives([])).toBe(''); + }); +}); + describe('isBypassAllowed', () => { afterEach(() => { delete process.env.VERCEL_ENV; From c4a5cc48d1515e3f595940b21dfc0734c750f562 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:40:51 -0400 Subject: [PATCH 09/11] #364 fix bulk skip messaging to use forward-only sources The bulk "unreachable target" copy computed sources with the full source set instead of the forward-only set that actually scopes the updateMany where-clause, so it could list back-only statuses as valid bulk targets. Match the message to the query and update the stale test assertion. Co-Authored-By: Claude Sonnet 4.6 --- components/features/applications-bulk-bar.tsx | 4 ++-- prisma/actions/applications.ts | 2 +- tests/db/authorization.test.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/components/features/applications-bulk-bar.tsx b/components/features/applications-bulk-bar.tsx index 9e1ac6d6..1c6cb73c 100644 --- a/components/features/applications-bulk-bar.tsx +++ b/components/features/applications-bulk-bar.tsx @@ -11,7 +11,7 @@ import type { $Enums } from '@/prisma/client'; import { APPLICATION_STATUS_LABELS, REVIEWER_APPLICATION_STATUS_OPTIONS, - getApplicationStatusSources, + getApplicationStatusForwardSources, isNonReviewableApplicationStatus, } from '@/lib/constants'; import type { ApplicationListRow } from '@/lib/types'; @@ -74,7 +74,7 @@ export function ApplicationsBulkBar({ if (skipped === 0) { toast.success(`Updated ${updated} ${applicationNoun(updated)}`); } else { - const sourceLabels = getApplicationStatusSources(status).map( + const sourceLabels = getApplicationStatusForwardSources(status).map( (source) => APPLICATION_STATUS_LABELS[source], ); toast.success( diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index f21cf63b..daa79bf7 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -580,7 +580,7 @@ export async function updateApplicationStatuses( }); if (result.count === 0) { - const sourceLabels = getApplicationStatusSources(status).map( + const sourceLabels = getApplicationStatusForwardSources(status).map( (source) => APPLICATION_STATUS_LABELS[source], ); return { diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 372f99c1..54c0593c 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -425,7 +425,8 @@ describe('updateApplicationStatuses', () => { status: 'reviewing', }); expect(result).toEqual({ - error: 'None of the selected applications can move to Reviewing.', + error: + "None of the selected applications can move to Reviewing — that's only reachable from Applied or Reached out.", }); }); From 6971dad5b76067b83b8e06cc0380e51aacc6333b Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 16:08:57 -0400 Subject: [PATCH 10/11] #364 fix stale bulk-skip message assertion getApplicationStatusForwardSources('reviewing') resolves to [applied, reached_out, interview_scheduled] now that the action uses forward-only sources; the test still expected the old two-source text. Co-Authored-By: Claude Sonnet 5 --- tests/db/authorization.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 54c0593c..774d1875 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -426,7 +426,7 @@ describe('updateApplicationStatuses', () => { }); expect(result).toEqual({ error: - "None of the selected applications can move to Reviewing — that's only reachable from Applied or Reached out.", + "None of the selected applications can move to Reviewing — that's only reachable from Applied, Reached out, or Interview scheduled.", }); }); From 06531be2beb61e3ff5d5ad78e4e662831b4fcb76 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 18:14:39 -0400 Subject: [PATCH 11/11] #364 stop claiming skipped rows stay selected on a source mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk-status toast said skipped rows were "still selected", but onApplied only retains ids skipped for a non-reviewable status — rows skipped for a forward-source mismatch (e.g. applied -> interview_scheduled) are deselected, making the claim false. Co-Authored-By: Claude Sonnet 4.6 --- components/features/applications-bulk-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/features/applications-bulk-bar.tsx b/components/features/applications-bulk-bar.tsx index 1c6cb73c..e7b5880d 100644 --- a/components/features/applications-bulk-bar.tsx +++ b/components/features/applications-bulk-bar.tsx @@ -80,7 +80,7 @@ export function ApplicationsBulkBar({ toast.success( `Updated ${updated} of ${updated + skipped} applications`, { - description: `${statusLabel} is only reachable from ${formatAlternatives(sourceLabels)}. The skipped rows are still selected.`, + description: `${statusLabel} is only reachable from ${formatAlternatives(sourceLabels)}.`, }, ); }