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;