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..780924b8 --- /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, + onSettled?: () => 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]}`); + } catch { + toast.error('Something went wrong. Please try again.'); + } finally { + setPendingTarget(null); + onSettled?.(); + } + }); + } + + 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} +
+ ); +} 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/components/features/applications-bulk-bar.tsx b/components/features/applications-bulk-bar.tsx index 5b77b982..e7b5880d 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, + getApplicationStatusForwardSources, 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 = getApplicationStatusForwardSources(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)}.`, }, ); } 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 66f754e5..da7afa0f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -357,6 +357,127 @@ export const REVIEWER_APPLICATION_STATUSES = [ export const REVIEWER_APPLICATION_STATUS_OPTIONS = APPLICATION_STATUS_OPTIONS.filter((o) => o.value !== 'draft'); +// 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: [] }, + 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)); +} + +// 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[] { + const forwardSources = ( + 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') + ); + }); + 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 +// 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: 'Interview scheduled', + accepted: 'Accept', + rejected: 'Reject', +}; + +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', 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 d0a0b240..daa79bf7 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -19,18 +19,23 @@ 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, + getApplicationStatusForwardSources, + getApplicationStatusSources, + isAllowedApplicationStatusTransition, matchesShortAnswerFormat, } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; import { type AnswerQuestion } from '@/lib/types'; import { type ResponseType, + formatAlternatives, isAcceptingApplications, isAnswered, isError, @@ -500,17 +505,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 +554,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. + // 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: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + status: { in: getApplicationStatusForwardSources(status) }, position: PUBLISHED_POSITION_WHERE, } : { id: { in: applicationIds }, deletedAt: null, - status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + status: { in: getApplicationStatusForwardSources(status) }, // Merge, don't overwrite — see prisma/data/applications.ts#buildBaseWhere. position: { ...PUBLISHED_POSITION_WHERE, @@ -557,8 +579,14 @@ 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) { + const sourceLabels = getApplicationStatusForwardSources(status).map( + (source) => APPLICATION_STATUS_LABELS[source], + ); + return { + 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 cd7e5663..82761084 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,93 @@ 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 — that's only reachable from Interview scheduled or Reviewing.", + }); + }); + + 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', () => { 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..774d1875 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -418,14 +418,16 @@ 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 — that's only reachable from Applied, Reached out, or Interview scheduled.", + }); }); 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..16a16cc6 --- /dev/null +++ b/tests/unit/application-transitions.test.ts @@ -0,0 +1,131 @@ +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, + getAllowedApplicationStatusTransitions, + getApplicationStatusForwardSources, + 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('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) { + 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)); + } + }); +}); + +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)), + ); + }); +}); 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;