From ae81432a73a7d37ad42b74b1b2d78f08bc866a5c Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 21 Sep 2026 00:04:11 -0400 Subject: [PATCH 01/11] #741 add forceWithdrawApplication server action Admin-only path back to withdrawn from any submitted status, including accepted/rejected. Copies updateApplicationStatus's CAS-transaction shape but skips the after() email dispatch entirely, so that action's "everything here emails" invariant stays unconditional. Co-Authored-By: Claude Sonnet 4.6 --- lib/constants.ts | 7 ++++ lib/icons.ts | 4 +- prisma/actions/applications.ts | 71 +++++++++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index 428e8ddc..f349f14a 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -671,6 +671,13 @@ export function isTerminalDecisionApplicationStatus( return TERMINAL_DECISION_STATUSES.includes(status); } +// Shared by updateApplicationStatus and forceWithdrawApplication — same CAS-loss sentence. +export const APPLICATION_STATUS_CHANGED_MESSAGE = + 'This application just changed. Refresh to see its current status.'; + +export const APPLICATION_DRAFT_NOT_WITHDRAWABLE_MESSAGE = + "This application hasn't been submitted yet, so there's nothing to withdraw."; + export const RECENTLY_CLOSED_WINDOW_DAYS = 7; // Sole owners of the applicant deadline-urgency boundaries — shared by diff --git a/lib/icons.ts b/lib/icons.ts index c3a1e0bd..8b649da4 100644 --- a/lib/icons.ts +++ b/lib/icons.ts @@ -157,7 +157,8 @@ type Action = | 'expand' | 'sortAsc' | 'sortDesc' - | 'sortNone'; + | 'sortNone' + | 'forceWithdraw'; export const ACTION_ICONS: Record = { create: Plus, @@ -185,6 +186,7 @@ export const ACTION_ICONS: Record = { sortAsc: ArrowUp, sortDesc: ArrowDown, sortNone: ArrowUpDown, + forceWithdraw: CircleSlash, }; type State = diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index ee36776c..9d0e9a32 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -15,7 +15,11 @@ import type { } from '@/prisma/client'; import { getApplicationStatusHistory } from '@/prisma/data/applications'; -import { requireManagerOrAdmin, requireOwnership } from '@/lib/auth/guards'; +import { + requireAdmin, + requireManagerOrAdmin, + requireOwnership, +} from '@/lib/auth/guards'; import { buildApplicationScopeWhere, buildApplicationWhere, @@ -24,7 +28,9 @@ import { getCurrentUser } from '@/lib/auth/server'; import { ANSWER_LONG_MAX_LENGTH, ANSWER_MAX_VALUES, + APPLICATION_DRAFT_NOT_WITHDRAWABLE_MESSAGE, APPLICATION_NOT_EDITABLE_MESSAGE, + APPLICATION_STATUS_CHANGED_MESSAGE, APPLICATION_STATUS_LABELS, NON_REVIEWABLE_APPLICATION_STATUSES, REVIEWER_APPLICATION_STATUSES, @@ -589,10 +595,7 @@ export async function updateApplicationStatus( }); if (updateResult.count === 0) - return { - error: - 'This application just changed. Refresh to see its current status.', - }; + return { error: APPLICATION_STATUS_CHANGED_MESSAGE }; await tx.applicationStatusEvent.create({ data: { @@ -816,6 +819,64 @@ export async function withdrawApplication( revalidatePath('/manage/positions', 'layout'); } +// Admin override of withdrawApplication's applicant-only path — reaches every +// submitted status, including the terminal decisions withdrawApplication excludes. +export async function forceWithdrawApplication( + input: unknown, +): Promise { + const user = await requireAdmin(); + + const parsed = applicationIdSchema.safeParse(input); + if (!parsed.success) return { error: 'Invalid input' }; + + const { applicationId } = parsed.data; + + const result = await prisma.$transaction(async (tx) => { + // No status filter here (unlike buildApplicationWhere) — an ineligible + // but visible row gets an actionable sentence instead of an opaque throw. + const application = await tx.application.findFirst({ + where: { id: applicationId, ...buildApplicationScopeWhere(user) }, + select: { status: true }, + }); + + // IDOR-style miss, unreachable from the UI — throw, don't return. + if (!application) + throw new Error('Application not found or not authorized'); + + if (application.status === 'withdrawn') + return { + error: `This application is already ${APPLICATION_STATUS_LABELS.withdrawn}.`, + }; + if (application.status === 'draft') + return { error: APPLICATION_DRAFT_NOT_WITHDRAWABLE_MESSAGE }; + + // CAS on the exact status just read, so the event's `from` below is + // provably the status that was replaced. + const updateResult = await tx.application.updateMany({ + where: { id: applicationId, status: application.status }, + data: { status: 'withdrawn', updatedById: user.id }, + }); + + if (updateResult.count === 0) + return { error: APPLICATION_STATUS_CHANGED_MESSAGE }; + + await tx.applicationStatusEvent.create({ + data: { + applicationId, + from: application.status, + to: 'withdrawn', + changedById: user.id, + }, + }); + }); + + if (result && 'error' in result) return result; + + // No after() dispatch — the one write path to `withdrawn` that never emails. + revalidatePath(`/manage/applications/${applicationId}`); + revalidatePath('/manage/applications'); +} + // Soft delete: both answer tables are left untouched, so re-applying to the // same position brings the historical answers back on the same row. export async function deleteDraftApplication( From 98caa268103c93661394c4508f4d630e9eae380a Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 21 Sep 2026 00:04:22 -0400 Subject: [PATCH 02/11] #741 wire force withdraw into the status dialog and menus useForceWithdrawApplication mirrors use-application-status-move's shape (pending state, action call, toasts, confirmDialogProps). The status dialog gets a destructive Force withdraw block, admin-only and hidden for draft/withdrawn; the shared status menu gets a matching destructive item below a separator. Both table row and header-actions hosts thread isAdmin down and own their own confirm dialog for the menu entry point, separate from the dialog's own instance. Co-Authored-By: Claude Sonnet 4.6 --- .../(auth)/manage/applications/[id]/page.tsx | 1 + .../features/application-status-actions.tsx | 16 ++++ .../features/application-status-dialog.tsx | 41 ++++++++++- .../application-status-header-actions.tsx | 16 ++++ .../features/application-status-menu.tsx | 16 ++++ components/features/applications-results.tsx | 3 + components/features/applications-table.tsx | 5 +- .../use-force-withdraw-application.ts | 73 +++++++++++++++++++ 8 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 components/features/use-force-withdraw-application.ts diff --git a/app/(main)/(auth)/manage/applications/[id]/page.tsx b/app/(main)/(auth)/manage/applications/[id]/page.tsx index 26a86ae4..b737df69 100644 --- a/app/(main)/(auth)/manage/applications/[id]/page.tsx +++ b/app/(main)/(auth)/manage/applications/[id]/page.tsx @@ -69,6 +69,7 @@ export default async function ApplicationDetailPage({ applicantName={applicantName} applicantEmail={application.user.email} history={history} + isAdmin={user.isAdmin} /> } /> diff --git a/components/features/application-status-actions.tsx b/components/features/application-status-actions.tsx index d57618ae..8e1d406e 100644 --- a/components/features/application-status-actions.tsx +++ b/components/features/application-status-actions.tsx @@ -15,6 +15,7 @@ import { isError } from '@/lib/utils'; import { ApplicationStatusDialog } from '@/components/features/application-status-dialog'; import { ApplicationStatusMenu } from '@/components/features/application-status-menu'; import { useApplicationStatusMove } from '@/components/features/use-application-status-move'; +import { useForceWithdrawApplication } from '@/components/features/use-force-withdraw-application'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { @@ -28,6 +29,7 @@ interface ApplicationStatusActionsProps { currentStatus: $Enums.ApplicationStatus; applicantName?: string; applicantEmail?: string; + isAdmin: boolean; } // Table row `⋯` menu only — the detail page's header actions live in @@ -37,6 +39,7 @@ export function ApplicationStatusActions({ currentStatus, applicantName, applicantEmail, + isAdmin, }: ApplicationStatusActionsProps) { const displayName = applicantName ?? 'this application'; const { isPending, selectTarget, confirmDialogProps } = @@ -46,6 +49,13 @@ export function ApplicationStatusActions({ applicantEmail, currentStatus, }); + const forceWithdraw = useForceWithdrawApplication({ + applicationId, + applicantName, + currentStatus, + }); + const canForceWithdraw = + isAdmin && !isNonReviewableApplicationStatus(currentStatus); const [dialogOpen, setDialogOpen] = useState(false); const [history, setHistory] = useState([]); @@ -105,10 +115,15 @@ export function ApplicationStatusActions({ isPending={isPending} onSelect={selectTarget} onSeeMore={openDialog} + canForceWithdraw={canForceWithdraw} + onForceWithdraw={forceWithdraw.openConfirm} /> + {canForceWithdraw && ( + + )} diff --git a/components/features/application-status-dialog.tsx b/components/features/application-status-dialog.tsx index 6dbe33f9..a882faf6 100644 --- a/components/features/application-status-dialog.tsx +++ b/components/features/application-status-dialog.tsx @@ -17,6 +17,7 @@ import { } from '@/lib/utils'; import { useApplicationStatusMove } from '@/components/features/use-application-status-move'; +import { useForceWithdrawApplication } from '@/components/features/use-force-withdraw-application'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { @@ -47,6 +48,7 @@ interface ApplicationStatusDialogProps { // its pre-fetched history and leaves these unset. isHistoryLoading?: boolean; historyFailed?: boolean; + isAdmin: boolean; open: boolean; onOpenChange: (open: boolean) => void; } @@ -59,6 +61,7 @@ export function ApplicationStatusDialog({ history, isHistoryLoading = false, historyFailed = false, + isAdmin, open, onOpenChange, }: ApplicationStatusDialogProps) { @@ -71,8 +74,15 @@ export function ApplicationStatusDialog({ applicantEmail, currentStatus, }); + const forceWithdraw = useForceWithdrawApplication({ + applicationId, + applicantName, + currentStatus, + }); const canOverride = !isNonReviewableApplicationStatus(currentStatus); + const canForceWithdraw = + isAdmin && !isNonReviewableApplicationStatus(currentStatus); const selectingDecision = selectedStatus === 'accepted' || selectedStatus === 'rejected'; @@ -110,7 +120,7 @@ export function ApplicationStatusDialog({ onValueChange={(v) => setSelectedStatus(v as $Enums.ApplicationStatus) } - disabled={move.isPending} + disabled={move.isPending || forceWithdraw.isPending} > + + )} +

History

{isHistoryLoading ? ( @@ -204,6 +238,9 @@ export function ApplicationStatusDialog({ + {canForceWithdraw && ( + + )} ); } diff --git a/components/features/application-status-header-actions.tsx b/components/features/application-status-header-actions.tsx index a1f3c76b..716c27b6 100644 --- a/components/features/application-status-header-actions.tsx +++ b/components/features/application-status-header-actions.tsx @@ -18,6 +18,7 @@ import type { ApplicationStatusHistoryEntry } from '@/lib/types'; import { ApplicationStatusDialog } from '@/components/features/application-status-dialog'; import { ApplicationStatusMenu } from '@/components/features/application-status-menu'; import { useApplicationStatusMove } from '@/components/features/use-application-status-move'; +import { useForceWithdrawApplication } from '@/components/features/use-force-withdraw-application'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { @@ -32,6 +33,7 @@ interface ApplicationStatusHeaderActionsProps { applicantName: string; applicantEmail: string; history: ApplicationStatusHistoryEntry[]; + isAdmin: boolean; } // Unresolved gets a split button; everything else gets a standalone caret — same dropdown either way. @@ -41,6 +43,7 @@ export function ApplicationStatusHeaderActions({ applicantName, applicantEmail, history, + isAdmin, }: ApplicationStatusHeaderActionsProps) { const [dialogOpen, setDialogOpen] = useState(false); const move = useApplicationStatusMove({ @@ -49,6 +52,13 @@ export function ApplicationStatusHeaderActions({ applicantEmail, currentStatus, }); + const forceWithdraw = useForceWithdrawApplication({ + applicationId, + applicantName, + currentStatus, + }); + const canForceWithdraw = + isAdmin && !isNonReviewableApplicationStatus(currentStatus); const dialog = ( @@ -145,12 +156,17 @@ export function ApplicationStatusHeaderActions({ isPending={move.isPending} onSelect={move.selectTarget} onSeeMore={() => setDialogOpen(true)} + canForceWithdraw={canForceWithdraw} + onForceWithdraw={forceWithdraw.openConfirm} />
{confirmDialog} + {canForceWithdraw && ( + + )} {dialog} ); diff --git a/components/features/application-status-menu.tsx b/components/features/application-status-menu.tsx index 6b96345e..4ad474a7 100644 --- a/components/features/application-status-menu.tsx +++ b/components/features/application-status-menu.tsx @@ -20,6 +20,8 @@ interface ApplicationStatusMenuProps { isPending?: boolean; onSelect: (target: $Enums.ApplicationStatus) => void; onSeeMore: () => void; + canForceWithdraw?: boolean; + onForceWithdraw?: () => void; } // One menu shape for both surfaces — the only prop that differs is hoistNext. @@ -29,6 +31,8 @@ export function ApplicationStatusMenu({ isPending = false, onSelect, onSeeMore, + canForceWithdraw = false, + onForceWithdraw, }: ApplicationStatusMenuProps) { const { next, decisions } = getApplicationStatusMenu(status); const showNext = !hoistNext && next !== null; @@ -61,6 +65,18 @@ export function ApplicationStatusMenu({ ))} {(showNext || decisions.length > 0) && } See more + {canForceWithdraw && ( + <> + + + Force withdraw + + + )} ); } diff --git a/components/features/applications-results.tsx b/components/features/applications-results.tsx index d5d733f8..cd9aae38 100644 --- a/components/features/applications-results.tsx +++ b/components/features/applications-results.tsx @@ -117,6 +117,7 @@ export async function ApplicationsResults({ applications={rows} hasActiveFilters={hasActiveFilters} sort={filters.sort} + isAdmin={user.isAdmin} /> buildApplicationsHref(filters, p)} @@ -159,6 +160,7 @@ export async function ApplicationsResults({ applications={rows} hasActiveFilters={hasActiveFilters} sort={filters.sort} + isAdmin={user.isAdmin} /> buildApplicationsHref(filters, p)} @@ -182,6 +184,7 @@ export async function ApplicationsResults({ applications={rows.map((a) => ({ ...a, isDraft: false as const }))} hasActiveFilters={hasActiveFilters} sort={filters.sort} + isAdmin={user.isAdmin} /> buildApplicationsHref(filters, p)} diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index 6f459c3d..4fd47064 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -39,6 +39,7 @@ import { LocalTime } from '@/components/ui/local-time'; interface BaseApplicationsTableProps { hasActiveFilters: boolean; sort?: ApplicationSort; + isAdmin: boolean; } // Discriminated on isDraftView: true is the explicit "Draft" filter (pure @@ -58,7 +59,7 @@ function isAdminRow( } export function ApplicationsTable(props: ApplicationsTableProps) { - const { hasActiveFilters, sort } = props; + const { hasActiveFilters, sort, isAdmin } = props; const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -294,6 +295,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) { currentStatus={app.status} applicantName={displayName} applicantEmail={app.user.email} + isAdmin={isAdmin} /> ); @@ -485,6 +487,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) { currentStatus={app.status} applicantName={displayName} applicantEmail={app.user.email} + isAdmin={isAdmin} /> diff --git a/components/features/use-force-withdraw-application.ts b/components/features/use-force-withdraw-application.ts new file mode 100644 index 00000000..8a947ed8 --- /dev/null +++ b/components/features/use-force-withdraw-application.ts @@ -0,0 +1,73 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { toast } from 'sonner'; + +import { forceWithdrawApplication } from '@/prisma/actions/applications'; +import type { $Enums } from '@/prisma/client'; + +import { + APPLICATION_STATUS_LABELS, + isTerminalDecisionApplicationStatus, +} from '@/lib/constants'; + +interface UseForceWithdrawApplicationOptions { + applicationId: string; + applicantName?: string; + currentStatus: $Enums.ApplicationStatus; +} + +// Kept .ts (no JSX), same reason as use-application-status-move.ts — three +// hosts (dialog, header actions, table row) share it rather than duplicating. +export function useForceWithdrawApplication({ + applicationId, + applicantName, + currentStatus, +}: UseForceWithdrawApplicationOptions) { + const [isPending, startTransition] = useTransition(); + const [confirmOpen, setConfirmOpen] = useState(false); + + const displayName = applicantName ?? 'this application'; + + function performForceWithdraw(onSettled?: () => void) { + startTransition(async () => { + try { + const result = await forceWithdrawApplication({ applicationId }); + if (result && 'error' in result) { + toast.error(result.error); + return; + } + toast.success('Application force-withdrawn', { + description: `${displayName} was not notified.`, + }); + } catch { + toast.error('Something went wrong. Please try again.'); + } finally { + onSettled?.(); + } + }); + } + + function openConfirm() { + setConfirmOpen(true); + } + + const description = isTerminalDecisionApplicationStatus(currentStatus) + ? `This moves ${displayName}'s application from ${APPLICATION_STATUS_LABELS[currentStatus]} to Withdrawn. ${displayName} is not notified — no email is sent. They can edit and resubmit it, which puts it back in the queue as Applied. The change is recorded in the status history under your name. ${displayName} has already been told they were ${APPLICATION_STATUS_LABELS[currentStatus]}, and will not be told that changed.` + : `This moves ${displayName}'s application from ${APPLICATION_STATUS_LABELS[currentStatus]} to Withdrawn. ${displayName} is not notified — no email is sent. They can edit and resubmit it, which puts it back in the queue as Applied. The change is recorded in the status history under your name.`; + + const confirmDialogProps = { + open: confirmOpen, + onOpenChange: setConfirmOpen, + title: `Force withdraw ${displayName}'s application?`, + description, + confirmLabel: 'Force withdraw', + pendingLabel: 'Withdrawing…', + destructive: true, + isPending, + onConfirm: () => performForceWithdraw(() => setConfirmOpen(false)), + }; + + return { isPending, openConfirm, confirmDialogProps }; +} From 5bbae7cbd3d8c1f9df2f18386471d0b297224d67 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 21 Sep 2026 00:04:28 -0400 Subject: [PATCH 03/11] #741 add forceWithdrawApplication db test suite Per-status matrix over all eight statuses, a manager and the owning applicant both rejected, no EmailLog row on success, and a concurrent double-call resolving to exactly one success and one new event. Co-Authored-By: Claude Sonnet 4.6 --- tests/db/application-transitions.test.ts | 129 +++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/db/application-transitions.test.ts b/tests/db/application-transitions.test.ts index f3908af7..b7af5fb5 100644 --- a/tests/db/application-transitions.test.ts +++ b/tests/db/application-transitions.test.ts @@ -13,6 +13,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createOrUpdateApplicationAnswer, deleteDraftApplication, + forceWithdrawApplication, submitApplication, updateApplicationStatus, updateApplicationStatuses, @@ -23,6 +24,7 @@ import { getApplicationStatusHistory } from '@/prisma/data/applications'; import { APPLICANT_EDITABLE_APPLICATION_STATUSES, + APPLICATION_DRAFT_NOT_WITHDRAWABLE_MESSAGE, APPLICATION_STATUS_LABELS, APPLICATION_STATUS_VALUES, NON_REVIEWABLE_APPLICATION_STATUSES, @@ -135,6 +137,133 @@ describe('withdrawApplication', () => { } }); +describe('forceWithdrawApplication', () => { + const LEGAL_STATUSES: $Enums.ApplicationStatus[] = [ + 'applied', + 'reached_out', + 'interview_scheduled', + 'reviewing', + 'accepted', + 'rejected', + ]; + + for (const status of ALL_STATUSES) { + const isLegal = LEGAL_STATUSES.includes(status); + + it(`${isLegal ? 'allows' : 'blocks'} force withdraw from ${status}`, async () => { + const applicant = await createTestUser(); + const application = await createTestApplication(applicant, openPosition, { + status, + }); + + actAs(admin); + const result = await forceWithdrawApplication({ + applicationId: application.id, + }); + + if (isLegal) { + expect(result).toBeUndefined(); + const updated = await prisma.application.findUniqueOrThrow({ + where: { id: application.id }, + select: { status: true }, + }); + expect(updated.status).toBe('withdrawn'); + const event = await prisma.applicationStatusEvent.findFirstOrThrow({ + where: { applicationId: application.id }, + orderBy: { createdAt: 'desc' }, + }); + expect(event.from).toBe(status); + expect(event.to).toBe('withdrawn'); + expect(event.changedById).toBe(admin.id); + } else if (status === 'draft') { + expect(result).toEqual({ + error: APPLICATION_DRAFT_NOT_WITHDRAWABLE_MESSAGE, + }); + } else { + expect(result).toEqual({ + error: `This application is already ${APPLICATION_STATUS_LABELS.withdrawn}.`, + }); + } + }); + } + + it('rejects a manager of the position, not just admins', async () => { + const manager = await createTestUser({ isAdmin: false }); + const position = await createTestPosition(admin, { managers: [manager] }); + const applicant = await createTestUser(); + const application = await createTestApplication(applicant, position, { + status: 'applied', + }); + + actAs(manager); + await expect( + forceWithdrawApplication({ applicationId: application.id }), + ).rejects.toThrow(); + + const untouched = await prisma.application.findUniqueOrThrow({ + where: { id: application.id }, + select: { status: true }, + }); + expect(untouched.status).toBe('applied'); + }); + + it('rejects the owning applicant', async () => { + const applicant = await createTestUser(); + const application = await createTestApplication(applicant, openPosition, { + status: 'applied', + }); + + actAs(applicant); + await expect( + forceWithdrawApplication({ applicationId: application.id }), + ).rejects.toThrow(); + + const untouched = await prisma.application.findUniqueOrThrow({ + where: { id: application.id }, + select: { status: true }, + }); + expect(untouched.status).toBe('applied'); + }); + + it('sends no email — no EmailLog row is created', async () => { + const applicant = await createTestUser(); + const application = await createTestApplication(applicant, openPosition, { + status: 'applied', + }); + + actAs(admin); + await forceWithdrawApplication({ applicationId: application.id }); + + const logs = await prisma.emailLog.findMany({ + where: { applicationId: application.id }, + }); + expect(logs).toHaveLength(0); + }); + + it('resolves exactly one of two concurrent calls, with exactly one new event', async () => { + const applicant = await createTestUser(); + const application = await createTestApplication(applicant, openPosition, { + status: 'applied', + }); + + actAs(admin); + const results = await Promise.all([ + forceWithdrawApplication({ applicationId: application.id }), + forceWithdrawApplication({ applicationId: application.id }), + ]); + + const successes = results.filter((r) => r === undefined); + const errors = results.filter((r) => r && 'error' in r); + expect(successes).toHaveLength(1); + expect(errors).toHaveLength(1); + + const events = await prisma.applicationStatusEvent.findMany({ + where: { applicationId: application.id }, + }); + expect(events).toHaveLength(1); + }); +}); + describe('deleteDraftApplication', () => { for (const status of ALL_STATUSES) { const isLegal = status === 'draft'; From 4641a709e97d13c9a44c271e058741bfdc0d6143 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 21 Sep 2026 00:04:36 -0400 Subject: [PATCH 04/11] #741 document admin force-withdraw in permissions and workflows PERMISSIONS.md gets the new action's authorization row, the lifecycle table's admin-only force-withdraw note on the unresolved/accepted/ rejected rows, and an amendment to the terminal-withdrawal bullet clarifying the block is applicant-scoped only. WORKFLOWS.md adds AD-13 and touches AP-13, AP-14, PM-14, and XC-9 so none of them still assert a guarantee this ticket deliberately punches a hole in. Co-Authored-By: Claude Sonnet 4.6 --- docs/PERMISSIONS.md | 63 +++++++++++++++++++++++---------------------- docs/WORKFLOWS.md | 23 ++++++++++++----- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index c4be1b9d..b64eed98 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -48,29 +48,30 @@ Four principals, each derived rather than stored as a single role field: ## Server-action authorization -| Action | Guard | Scope / state check | Denial | -| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `auth.ts` — `checkSignInAllowed`, `isOtpResendAllowed` | none — deliberately pre-auth surfaces, rate-limited at the proxy | email lookup / OTP cooldown (`OTP_RESEND_COOLDOWN_SECONDS`) | `{ error: ACCOUNT_DEACTIVATED_MESSAGE }` for a deactivated email; otherwise data, never a role check | -| `auth.ts` — `signOutUser` | `getCurrentUser()` | none beyond being an active session | `{ error }` on failure | -| `auth.ts` — `signOutDeactivatedSession` | `getDeactivatedSessionUser()` | must be the deactivated session, not an active one — `signOutUser` would redirect a deactivated caller before it could sign them out | throw if no deactivated session | -| `profile.ts` — `setUserName` | `getCurrentUser()` | write scoped to `where: { id: user.id }` | n/a (success/void) | -| `profile.ts` — `updateGlobalAnswer` | `getCurrentUser()` | upsert scoped to `userId_globalQuestionId`; rejects `file_upload` questions | throw for invalid type/missing question, `{ error }` for value/format violations | -| `applications.ts` — `createDraftApplication` | `getCurrentUser()` | `isAcceptingApplications` checked before creating; an existing draft survives a closed window (only submit blocks) | `{ error }` — window closed, position gone, or a racing duplicate | -| `applications.ts` — `submitApplication`, `createOrUpdateApplicationAnswer` | `getCurrentUser()` + `requireOwnership(application, user.id)` | gated on `APPLICANT_EDITABLE_APPLICATION_STATUSES` (`draft`\|`withdrawn`); `submitApplication` **re-checks `isAcceptingApplications`**, because the window can close while a draft sits open | `{ error: APPLICATION_NOT_EDITABLE_MESSAGE }` / window-closed message | -| `applications.ts` — `withdrawApplication` | `getCurrentUser()` | `updateMany` scoped to `userId` + `status: { notIn: ['draft', 'withdrawn', ...TERMINAL_DECISION_STATUSES] }` | `{ error }` when the scoped update hits 0 rows | -| `applications.ts` — `updateApplicationStatus` | query-scoped via `buildApplicationWhere(user, 'reviewable')` (no named guard) | miss → **throw** (IDOR-style, unreachable from the UI, not `{ error }`); stale-but-visible transition → `{ error }` from `isAllowedApplicationStatusTransition`. On success, the same permission also sends the applicant a decision email on a 10-second delay, undoable via the success toast while pending (`docs/WORKFLOWS.md` XC-9) | throw on scope miss, `{ error }` on invalid transition or write race | -| `applications.ts` — `updateApplicationStatuses` (bulk) | authorization **folded into the `updateMany` where** (`buildApplicationScopeWhere(user)` + `status: { notIn: [...NON_REVIEWABLE_APPLICATION_STATUSES, status] }`) | any reviewer status but the target is eligible — forward, backward, or a final decision. When the target is `accepted`/`rejected`, the same status-change permission now also sends every affected applicant a decision email **immediately and irreversibly** — there is no email-specific gate, so a manager's bulk send behaves exactly like an admin's (`docs/WORKFLOWS.md` XC-9) | `{ updated, skipped }` — a skip count, never an error, for rows outside the caller's scope, `draft`/`withdrawn`, or already at the target | -| `question-files.ts` — file answer actions | `getCurrentUser()` + `authorizeTarget` (ownership miss throws) | gated on `APPLICANT_EDITABLE_APPLICATION_STATUSES` (`draft`\|`withdrawn`) in **both** the pre-check and the in-transaction `findFirst`, matching the text-answer path | `{ error: APPLICATION_NOT_EDITABLE_MESSAGE }` | -| `position-actions.ts` — `createPosition`, `searchUsers` | `requireManagerOrAdmin()` | creation is always `draft`, regardless of what's posted; at least one manager is required, and the caller is connected only if they're in that list | throw; `{ error: POSITION_MANAGERS_REQUIRED_ERROR }` for zero managers | -| `position-actions.ts` — `updatePositionTitle`, `updatePositionDescription`, `updatePositionSchedule` | `getCurrentUser()` → existence check → `requirePositionAccess(id)` → `checkPositionEditable` | archived positions rejected even for their own manager; each is a single-field write, scoped by the shared `authorizePositionEdit` helper | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` | -| `position-actions.ts` — `updatePositionStatus` | `getCurrentUser()` → existence check → `requirePositionAccess(id)` → `checkPositionEditable` | archived positions rejected even for their own manager; a non-admin moving the stored status **to** `open` is refused (an already-`open` position stays freely editable); `closesAtPast` is read from the stored row, never a submitted value | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` / `{ error: POSITION_OPEN_REQUIRES_ADMIN_ERROR }` | -| `position-actions.ts` — `deletePosition` | `requireAdmin()` | the "has applications" guard is **folded into the `updateMany` where** (`applications: { none: { deletedAt: null, status: { not: 'draft' } } }`), so check-and-write are atomic | `{ error: POSITION_DELETE_BLOCKED_ERROR }` or `{ error: 'no longer exists' }` | -| `position-actions.ts` — `addPositionManager`, `removePositionManager` | `getCurrentUser()` → existence check → `requirePositionAccess(positionId)` → `checkPositionEditable` | archived positions reject even their own manager (reversed from the previous policy — see [Archive](#archive)); `removePositionManager` also blocks non-admin self-removal | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` / `{ error: 'You cannot remove yourself as a manager. Ask an admin to do it.' }` | -| `position-question-actions.ts` — `createPositionQuestion`, `updatePositionQuestion`, `reorderPositionQuestions`, `deletePositionQuestion` | `requirePositionAccess(positionId)` → `checkPositionEditable` | `updatePositionQuestion` / `deletePositionQuestion` scope their write to `{ id, positionId }` to prevent cross-position IDOR | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` | -| `global-questions.ts` — all four actions | `requireAdmin()` | — | throw | -| `users.ts` — `toggleUserAdmin` | `requireAdmin()` | self-guard: cannot change own admin role | `{ error: 'You cannot change your own admin role.' }`; throw if the scoped update hits 0 rows | -| `users.ts` — `deactivateUser` | `requireAdmin()` | self-guard: cannot deactivate own account; soft-delete + session revocation run in one `$transaction` | `{ error: 'You cannot deactivate your own account.' }` | -| `users.ts` — `createUser` | `requireAdmin()` | pre-check for an existing email, `P2002` catch for the race | `{ error }` | +| Action | Guard | Scope / state check | Denial | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `auth.ts` — `checkSignInAllowed`, `isOtpResendAllowed` | none — deliberately pre-auth surfaces, rate-limited at the proxy | email lookup / OTP cooldown (`OTP_RESEND_COOLDOWN_SECONDS`) | `{ error: ACCOUNT_DEACTIVATED_MESSAGE }` for a deactivated email; otherwise data, never a role check | +| `auth.ts` — `signOutUser` | `getCurrentUser()` | none beyond being an active session | `{ error }` on failure | +| `auth.ts` — `signOutDeactivatedSession` | `getDeactivatedSessionUser()` | must be the deactivated session, not an active one — `signOutUser` would redirect a deactivated caller before it could sign them out | throw if no deactivated session | +| `profile.ts` — `setUserName` | `getCurrentUser()` | write scoped to `where: { id: user.id }` | n/a (success/void) | +| `profile.ts` — `updateGlobalAnswer` | `getCurrentUser()` | upsert scoped to `userId_globalQuestionId`; rejects `file_upload` questions | throw for invalid type/missing question, `{ error }` for value/format violations | +| `applications.ts` — `createDraftApplication` | `getCurrentUser()` | `isAcceptingApplications` checked before creating; an existing draft survives a closed window (only submit blocks) | `{ error }` — window closed, position gone, or a racing duplicate | +| `applications.ts` — `submitApplication`, `createOrUpdateApplicationAnswer` | `getCurrentUser()` + `requireOwnership(application, user.id)` | gated on `APPLICANT_EDITABLE_APPLICATION_STATUSES` (`draft`\|`withdrawn`); `submitApplication` **re-checks `isAcceptingApplications`**, because the window can close while a draft sits open | `{ error: APPLICATION_NOT_EDITABLE_MESSAGE }` / window-closed message | +| `applications.ts` — `withdrawApplication` | `getCurrentUser()` | `updateMany` scoped to `userId` + `status: { notIn: ['draft', 'withdrawn', ...TERMINAL_DECISION_STATUSES] }` | `{ error }` when the scoped update hits 0 rows | +| `applications.ts` — `updateApplicationStatus` | query-scoped via `buildApplicationWhere(user, 'reviewable')` (no named guard) | miss → **throw** (IDOR-style, unreachable from the UI, not `{ error }`); stale-but-visible transition → `{ error }` from `isAllowedApplicationStatusTransition`. On success, the same permission also sends the applicant a decision email on a 10-second delay, undoable via the success toast while pending (`docs/WORKFLOWS.md` XC-9) | throw on scope miss, `{ error }` on invalid transition or write race | +| `applications.ts` — `updateApplicationStatuses` (bulk) | authorization **folded into the `updateMany` where** (`buildApplicationScopeWhere(user)` + `status: { notIn: [...NON_REVIEWABLE_APPLICATION_STATUSES, status] }`) | any reviewer status but the target is eligible — forward, backward, or a final decision. When the target is `accepted`/`rejected`, the same status-change permission now also sends every affected applicant a decision email **immediately and irreversibly** — there is no email-specific gate, so a manager's bulk send behaves exactly like an admin's (`docs/WORKFLOWS.md` XC-9) | `{ updated, skipped }` — a skip count, never an error, for rows outside the caller's scope, `draft`/`withdrawn`, or already at the target | +| `applications.ts` — `forceWithdrawApplication` | `requireAdmin()` — **not** `requireManagerOrAdmin`; a manager throws the same as an unauthenticated caller | scoped `findFirst` via `buildApplicationScopeWhere(user)` (no status filter, unlike `reviewable`), then CAS `updateMany`; eligible from any status except `draft`/`withdrawn`, including `accepted`/`rejected` — a deliberate hole in the terminal-decision withdrawal block that only `withdrawApplication` still enforces. Sends **no** email, ever — no `after()`, no `EmailLog` row | throw on scope miss or non-admin caller; `{ error }` for `draft`, already-`withdrawn`, or a write-race CAS loss | +| `question-files.ts` — file answer actions | `getCurrentUser()` + `authorizeTarget` (ownership miss throws) | gated on `APPLICANT_EDITABLE_APPLICATION_STATUSES` (`draft`\|`withdrawn`) in **both** the pre-check and the in-transaction `findFirst`, matching the text-answer path | `{ error: APPLICATION_NOT_EDITABLE_MESSAGE }` | +| `position-actions.ts` — `createPosition`, `searchUsers` | `requireManagerOrAdmin()` | creation is always `draft`, regardless of what's posted; at least one manager is required, and the caller is connected only if they're in that list | throw; `{ error: POSITION_MANAGERS_REQUIRED_ERROR }` for zero managers | +| `position-actions.ts` — `updatePositionTitle`, `updatePositionDescription`, `updatePositionSchedule` | `getCurrentUser()` → existence check → `requirePositionAccess(id)` → `checkPositionEditable` | archived positions rejected even for their own manager; each is a single-field write, scoped by the shared `authorizePositionEdit` helper | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` | +| `position-actions.ts` — `updatePositionStatus` | `getCurrentUser()` → existence check → `requirePositionAccess(id)` → `checkPositionEditable` | archived positions rejected even for their own manager; a non-admin moving the stored status **to** `open` is refused (an already-`open` position stays freely editable); `closesAtPast` is read from the stored row, never a submitted value | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` / `{ error: POSITION_OPEN_REQUIRES_ADMIN_ERROR }` | +| `position-actions.ts` — `deletePosition` | `requireAdmin()` | the "has applications" guard is **folded into the `updateMany` where** (`applications: { none: { deletedAt: null, status: { not: 'draft' } } }`), so check-and-write are atomic | `{ error: POSITION_DELETE_BLOCKED_ERROR }` or `{ error: 'no longer exists' }` | +| `position-actions.ts` — `addPositionManager`, `removePositionManager` | `getCurrentUser()` → existence check → `requirePositionAccess(positionId)` → `checkPositionEditable` | archived positions reject even their own manager (reversed from the previous policy — see [Archive](#archive)); `removePositionManager` also blocks non-admin self-removal | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` / `{ error: 'You cannot remove yourself as a manager. Ask an admin to do it.' }` | +| `position-question-actions.ts` — `createPositionQuestion`, `updatePositionQuestion`, `reorderPositionQuestions`, `deletePositionQuestion` | `requirePositionAccess(positionId)` → `checkPositionEditable` | `updatePositionQuestion` / `deletePositionQuestion` scope their write to `{ id, positionId }` to prevent cross-position IDOR | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` | +| `global-questions.ts` — all four actions | `requireAdmin()` | — | throw | +| `users.ts` — `toggleUserAdmin` | `requireAdmin()` | self-guard: cannot change own admin role | `{ error: 'You cannot change your own admin role.' }`; throw if the scoped update hits 0 rows | +| `users.ts` — `deactivateUser` | `requireAdmin()` | self-guard: cannot deactivate own account; soft-delete + session revocation run in one `$transaction` | `{ error: 'You cannot deactivate your own account.' }` | +| `users.ts` — `createUser` | `requireAdmin()` | pre-check for an existing email, `P2002` catch for the race | `{ error }` | ## Position visibility @@ -166,16 +167,16 @@ Manager is M2M membership in `Position.managers` (`prisma/schema.prisma`), not a `ApplicationStatus` has eight values: `draft`, `applied`, `reached_out`, `interview_scheduled`, `reviewing`, `accepted`, `rejected`, `withdrawn`. -| Status | Applicant may | Reviewer sees | Reviewer may set | -| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `draft` | edit answers, submit, soft-delete (`deleteDraftApplication`); reviving a soft-deleted draft happens only by re-applying (`createDraftApplication`) | identity only — name, email, started/last-touched, via `getDraftApplications`; never answers, files or the detail page | — | -| `applied` / `reached_out` / `interview_scheduled` / `reviewing` | withdraw only | ✓ (queue) | the next step on `APPLICATION_STATUS_PATH`, plus `accepted`/`rejected` (both unresolved-only) | -| `accepted` | nothing — terminal | ✓ | nothing on the normal path — override only (dialog's any-status Select) | -| `rejected` | nothing — terminal | ✓ | nothing on the normal path — override only (dialog's any-status Select) | -| `withdrawn` | edit answers, resubmit | ✓ (`listable`) but read-only — every status move throws (`reviewable` excludes it) | — | +| Status | Applicant may | Reviewer sees | Reviewer may set | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `draft` | edit answers, submit, soft-delete (`deleteDraftApplication`); reviving a soft-deleted draft happens only by re-applying (`createDraftApplication`) | identity only — name, email, started/last-touched, via `getDraftApplications`; never answers, files or the detail page | — | +| `applied` / `reached_out` / `interview_scheduled` / `reviewing` | withdraw only | ✓ (queue) | the next step on `APPLICATION_STATUS_PATH`, plus `accepted`/`rejected` (both unresolved-only); admin only: force withdraw | +| `accepted` | nothing — terminal | ✓ | nothing on the normal path — override only (dialog's any-status Select); admin only: force withdraw | +| `rejected` | nothing — terminal | ✓ | nothing on the normal path — override only (dialog's any-status Select); admin only: force withdraw | +| `withdrawn` | edit answers, resubmit | ✓ (`listable`) but read-only — every status move throws (`reviewable` excludes it) | — | - **The path is the whole forward model** (`APPLICATION_STATUS_PATH`, `lib/constants.ts`): `draft → applied → reached_out → interview_scheduled → reviewing → accepted`, an ordered tuple with `getNextApplicationStatus(from)` reading the successor. `applied`/`reached_out`/`interview_scheduled` each have exactly one next step; `reviewing`'s next step is `accepted` — there is no dedicated "forward" status for it, since Accept is the natural next action once a candidate is under review. `getAllowedApplicationStatusTransitions(from)` is that next step plus `accepted`/`rejected` while `from` is in `UNRESOLVED_APPLICATION_STATUSES`, deduped so `reviewing` doesn't list `accepted` twice. -- **Every backward move is override-only.** There is no `back` map — `reviewing → interview_scheduled`, `accepted → reviewing`, and any other move that leaves the path going backward are rejected by `isAllowedApplicationStatusTransition` and reachable only through `updateApplicationStatus`'s `override: true`, i.e. the status dialog's any-status Select. `accepted`/`rejected` are terminal for the applicant but reversible by the reviewer this way. Withdrawal is blocked from a terminal status — `withdrawApplication`'s scoped `updateMany` excludes `TERMINAL_DECISION_STATUSES` — because a withdraw → resubmit round-trip would otherwise launder the decision. +- **Every backward move is override-only.** There is no `back` map — `reviewing → interview_scheduled`, `accepted → reviewing`, and any other move that leaves the path going backward are rejected by `isAllowedApplicationStatusTransition` and reachable only through `updateApplicationStatus`'s `override: true`, i.e. the status dialog's any-status Select. `accepted`/`rejected` are terminal for the applicant but reversible by the reviewer this way. **Withdrawal is blocked from a terminal status for the applicant's own path only** — `withdrawApplication`'s scoped `updateMany` excludes `TERMINAL_DECISION_STATUSES`, because a withdraw → resubmit round-trip would otherwise let an applicant action launder a reviewer's decision. An **admin** may deliberately bypass that block via `forceWithdrawApplication`, from any submitted status including `accepted`/`rejected` — the resulting row is `withdrawn`, which is applicant-editable, so the applicant can resubmit and land back at `applied` with the decision cleared from their current status. The decision is not erased: it survives permanently in the append-only `ApplicationStatusEvent` history, just no longer reflected in `Application.status`. - **Two same-members-different-meaning constant pairs keep getting confused:** `NON_REVIEWABLE_APPLICATION_STATUSES` (`draft`, `withdrawn` — a reviewer may not act **on** these) vs `REVIEWER_APPLICATION_STATUSES` (the six statuses a reviewer may set **to** — excludes `draft` and `withdrawn`); `APPLICANT_EDITABLE_APPLICATION_STATUSES` (`draft`, `withdrawn`) vs `NON_REVIEWABLE_APPLICATION_STATUSES` (same two members, different meaning again). `UNRESOLVED_APPLICATION_STATUSES` is now the _only_ home for "the four in-review statuses" — Accept and Reject are both offered from every one of them, so the two constants that used to express that set separately (`REJECTABLE_`/`ACCEPTABLE_APPLICATION_STATUSES`) were deleted as pure duplicates. - **Bulk moves (`updateApplicationStatuses`) accept any of the six reviewer statuses**, including a move backward or onto a final decision — the forward-only restriction was retired. Eligibility is simply "a reviewer status other than the target, not `draft`/`withdrawn`"; the confirmation states the split (forward / backward / final-decision / skipped) before anything is written, computed by `summarizeBulkStatusChange` (`lib/utils.ts`) via `getApplicationStatusRank`. - **Applicant-facing surfaces get the collapsed public status, never the internal one.** `PUBLIC_APPLICATION_STATUS` (`lib/constants.ts`) maps `reached_out`/`interview_scheduled`/`reviewing` to `applied`; `draft`/`accepted`/`rejected`/`withdrawn` map to themselves. Enforced in `prisma/data/applications.ts` for every applicant-scoped query, and in the type system — `MyApplicationListItem`, `MyApplicationDetail`, `MyPositionApplication` and `DraftApplication` type their `status` as `PublicApplicationStatus`, so passing a raw `ApplicationStatus` into an applicant-facing component is a compile error. In-group timestamps are hidden the same way: the applicant-facing list item carries `lastSavedAt` (draft only) instead of `updatedAt`, and applicant queries order on `submittedAt`, never `updatedAt`. diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index d82d17e5..9eeb2351 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -29,7 +29,7 @@ Behaviour shared by many workflows is stated once under [Cross-cutting behaviour **[Position manager (PM)](#position-manager-pm)** — [PM-1](#pm-1-see-your-dashboard) · [PM-2](#pm-2-see-the-positions-you-manage) · [PM-3](#pm-3-create-a-position) · [PM-4](#pm-4-edit-position-details) · [PM-5](#pm-5-manage-position-questions) · [PM-6](#pm-6-add-a-manager) · [PM-7](#pm-7-remove-a-manager) · [PM-8](#pm-8-work-the-application-queue) · [PM-9](#pm-9-open-an-application-for-review) · [PM-10](#pm-10-preview-or-download-an-applicants-file-answer) · [PM-11](#pm-11-move-one-application-through-the-status-path) · [PM-12](#pm-12-move-several-applications-at-once) · [PM-13](#pm-13-reorder-position-questions) · [PM-14](#pm-14-override-a-status-undo-or-review-its-history) -**[Admin (AD)](#admin-ad)** — [AD-1](#ad-1-see-every-position) · [AD-2](#ad-2-edit-an-archived-position) · [AD-3](#ad-3-delete-a-position) · [AD-4](#ad-4-create-a-global-question) · [AD-5](#ad-5-edit-a-global-question) · [AD-6](#ad-6-delete-a-global-question) · [AD-7](#ad-7-create-a-user) · [AD-8](#ad-8-grant-or-revoke-admin) · [AD-9](#ad-9-deactivate-a-user) · [AD-10](#ad-10-find-a-user) · [AD-11](#ad-11-reorder-global-questions) · [AD-12](#ad-12-look-up-an-email) +**[Admin (AD)](#admin-ad)** — [AD-1](#ad-1-see-every-position) · [AD-2](#ad-2-edit-an-archived-position) · [AD-3](#ad-3-delete-a-position) · [AD-4](#ad-4-create-a-global-question) · [AD-5](#ad-5-edit-a-global-question) · [AD-6](#ad-6-delete-a-global-question) · [AD-7](#ad-7-create-a-user) · [AD-8](#ad-8-grant-or-revoke-admin) · [AD-9](#ad-9-deactivate-a-user) · [AD-10](#ad-10-find-a-user) · [AD-11](#ad-11-reorder-global-questions) · [AD-12](#ad-12-look-up-an-email) · [AD-13](#ad-13-force-withdraw-an-application) --- @@ -88,7 +88,7 @@ Three applicant-facing email _events_, over two rendered templates, all through - **Both decisions render the identical, neutral `applicationDecisionEmail`** (`lib/email/templates.ts`) — subject names the position, never the outcome ("Update on your application for {Position}"), and the body only says an update happened and links to the applicant's own application; nothing in the rendered mail ever reveals accept vs. reject. `application_accepted` and `application_rejected` stay distinct **only** in the `EmailLog.template` column that `dispatchDecisionEmail`/`dispatchBulkDecisionEmails` write, which is why the admin email log ([AD-12](#ad-12-look-up-an-email)) and the per-application **Email history** ([PM-9](#pm-9-open-an-application-for-review)) both surface the template label instead of relying on the (now-identical) subject to tell them apart. - **Decision, bulk** (`updateApplicationStatuses`) — the same self-managed delay+undo as the single path, just one shared wait for the whole batch: every eligible row logs `scheduled` immediately, the wait elapses once, then only the rows still `scheduled` go out together through Resend's batch endpoint (chunked at `RESEND_BATCH_MAX_EMAILS` (100) with permissive validation so one bad address can't sink the rest), upgrading to `sent` with their own provider id. Bulk eligibility isn't forward-only, so a bulk move can land on a row that still holds a pending single-decision send; `dispatchBulkDecisionEmails` cancels it first, same as the single path — always a DB flip, never a provider call. Undo reverts **each** application to **its own individual** prior status rather than one shared target, since a batch can mix forward, backward, and final-decision rows — `updateApplicationStatuses` returns each updated row's prior status for the client to call `updateApplicationStatus` per row. Available to every reviewer who can bulk-change status at all — there is no email-specific permission gate, so a manager's bulk accept/reject emails exactly as an admin's does ([PM-12](#pm-12-move-several-applications-at-once)). - **One decision email ever, per application.** Once a decision email for an application has reached `sent` or later (`sent` / `delivered` / `bounced` / `complained` / `suppressed` — anything past `scheduled`), no later status change — single or bulk, any number of flips back and forth (accept → reviewing → reject → reviewing → accept, …) — schedules or sends another. The status write itself still succeeds; only the email is suppressed. This is permanent and independent of the 10-second window above. -- **No email at all** on any in-group move (`reached_out` / `interview_scheduled` / `reviewing`) or on withdrawal — only a decision or a submission ever emails the applicant. +- **No email at all** on any in-group move (`reached_out` / `interview_scheduled` / `reviewing`) or on withdrawal, including an admin's [force withdraw](#ad-13-force-withdraw-an-application) — only a decision or a submission ever emails the applicant. `forceWithdrawApplication` has no `after()` dispatch at all, not even a suppressed one, so `EmailLog` gains no row for it, unlike the one-decision-per-application suppression above which still writes a `cancelled` row. ### XC-10 Manager digests @@ -371,16 +371,16 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - **Failure / edge** - Already `draft`, already `withdrawn`, or in a terminal decision state (`accepted` / `rejected`) → **"This application can no longer be withdrawn."** The button is not rendered for those statuses, so this is the stale-tab path. - Unexpected throw → toast **"Something went wrong"**. -- **End state** — status `withdrawn`, plus a new `ApplicationStatusEvent` recording it. It drops out of the reviewable queue but stays visible to reviewers in the `listable` scope, including later edits ([AP-14](#ap-14-edit-and-resubmit-a-withdrawn-application)). Withdrawing does **not** reset a decision: `accepted` and `rejected` stay excluded from the eligible source statuses — a withdraw/resubmit round-trip must never let an applicant action launder a reviewer's decision. That holds independent of the status history `ApplicationStatusEvent` now keeps ([PM-14](#pm-14-override-a-status-undo-or-review-its-history)); reviewing that history is not itself a way back out of a decision. Withdrawal sends no email ([XC-9](#xc-9-applicant-email)). +- **End state** — status `withdrawn`, plus a new `ApplicationStatusEvent` recording it. It drops out of the reviewable queue but stays visible to reviewers in the `listable` scope, including later edits ([AP-14](#ap-14-edit-and-resubmit-a-withdrawn-application)). Withdrawing does **not** reset a decision **via this action**: `accepted` and `rejected` stay excluded from `withdrawApplication`'s eligible source statuses — an applicant-initiated withdraw/resubmit round-trip must never let the applicant themselves launder a reviewer's decision. That guarantee is scoped to this action alone — an admin may deliberately bypass it via `forceWithdrawApplication` ([AD-13](#ad-13-force-withdraw-an-application)), which is a different, privileged path with its own audit trail, not a gap in this one. That holds independent of the status history `ApplicationStatusEvent` now keeps ([PM-14](#pm-14-override-a-status-undo-or-review-its-history)); reviewing that history is not itself a way back out of a decision. Withdrawal sends no email ([XC-9](#xc-9-applicant-email)). ### AP-14 Edit and resubmit a withdrawn application - **Trigger** — **Edit & resubmit** on a withdrawn row in `/applications`, which returns to `/positions/[id]/apply`. -- **Happy path** — `withdrawn` is applicant-editable, so the stepper reopens with every answer intact and editable, files included, above an info callout: "This application is withdrawn — It's out of the review queue, but reviewers can still see your answers — including edits you make here. Resubmit to put it back in the queue." Submitting runs [AP-9](#ap-9-submit-an-application) and toasts **"Application resubmitted"**. +- **Happy path** — `withdrawn` is applicant-editable, so the stepper reopens with every answer intact and editable, files included, above an info callout: "This application is withdrawn — It's out of the review queue, but reviewers can still see your answers — including edits you make here. Resubmit to put it back in the queue." Submitting runs [AP-9](#ap-9-submit-an-application) and toasts **"Application resubmitted"**. This applies identically whether the application reached `withdrawn` through the applicant's own [AP-13](#ap-13-withdraw-an-application) or through an admin's [AD-13](#ad-13-force-withdraw-an-application) — the applicant sees no difference and is not told which happened. - **Failure / edge** - The window closed while it was withdrawn → the row shows **Edit & resubmit** disabled with its reason in a tooltip, rather than the button; the apply page (if reached directly) renders "Applications are closed" ("This position stopped accepting applications, so this application can no longer be edited or submitted."). - Every [AP-9](#ap-9-submit-an-application) failure branch applies unchanged. -- **End state** — status back to `applied` with a fresh `submittedAt` and a re-snapshotted `applicantName`. A second submission receipt emails, same as the first ([XC-9](#xc-9-applicant-email)). +- **End state** — status back to `applied` with a fresh `submittedAt` and a re-snapshotted `applicantName`. A second submission receipt emails, same as the first ([XC-9](#xc-9-applicant-email)). If the row arrived at `withdrawn` via an admin's force-withdraw of an `accepted`/`rejected` decision, this is also where that decision stops being reflected in `Application.status` — it survives only in the append-only `ApplicationStatusEvent` history ([AD-13](#ad-13-force-withdraw-an-application)). ### AP-15 Delete a draft @@ -584,7 +584,7 @@ A user who manages at least one non-deleted position. Manager status is **derive ### PM-14 Override a status, undo, or review its history - **Trigger** — on the detail page, the split button's caret **See more** item for the four unresolved statuses, or the standalone caret's **See more** item for terminal decisions and non-reviewable statuses alike; on a table row ([PM-8](#pm-8-work-the-application-queue)), **See more** at the end of the `⋯` menu, from any status including `accepted`/`rejected`/`withdrawn`/`draft`. -- **Happy path** — the dialog shows two stacked regions. **Change status** — a `Select` over every reviewer status except the current one, plus **Apply**; this is the only route to any backward move (`reviewing → interview_scheduled`, `accepted`/`rejected → reviewing`, etc.) and to any other off-path target, going through `updateApplicationStatus` with `override: true`, which bypasses `isAllowedApplicationStatusTransition` but still authenticates, scopes to the caller's reviewable positions, and CAS-writes the row plus its event in one transaction. Choosing `accepted`/`rejected` shows the same 10-second delayed-send warning as the quick actions before Apply, and on success surfaces the same toast **Undo** action described in [PM-11](#pm-11-move-one-application-through-the-status-path) ([XC-9](#xc-9-applicant-email)) — there's no separate "Undo last change" control in this dialog; reverting any move, decision or not, is just a second **Change status** pick back to the prior value. **History** — every `ApplicationStatusEvent` for the application, newest first, each row showing ` → `, the actor's name, and the time; a row with no `from` (the one-time migration backfill) reads "Status recorded as · before history tracking" instead. Opened from the detail page, history arrives pre-fetched; opened from a table row, the dialog opens immediately and shows three skeleton rows in an `aria-busy` region while `loadApplicationStatusHistory` fetches, re-fetching on every open. Accept/Reject picked from the Select still confirm through the same `ConfirmDialog` as the header's quick actions, and a resulting move to `accepted` fires the same reduced-motion-aware confetti burst as [PM-11](#pm-11-move-one-application-through-the-status-path), for the acting reviewer only, while the dialog is still open. +- **Happy path** — the dialog shows two or three stacked regions. **Change status** — a `Select` over every reviewer status except the current one, plus **Apply**; this is the only route to any backward move (`reviewing → interview_scheduled`, `accepted`/`rejected → reviewing`, etc.) and to any other off-path target, going through `updateApplicationStatus` with `override: true`, which bypasses `isAllowedApplicationStatusTransition` but still authenticates, scopes to the caller's reviewable positions, and CAS-writes the row plus its event in one transaction. Choosing `accepted`/`rejected` shows the same 10-second delayed-send warning as the quick actions before Apply, and on success surfaces the same toast **Undo** action described in [PM-11](#pm-11-move-one-application-through-the-status-path) ([XC-9](#xc-9-applicant-email)) — there's no separate "Undo last change" control in this dialog; reverting any move, decision or not, is just a second **Change status** pick back to the prior value. **Force withdraw** — admin-only, hidden entirely for a manager or for `draft`/`withdrawn`: a destructive block below Change status, above History, described in full at [AD-13](#ad-13-force-withdraw-an-application). **History** — every `ApplicationStatusEvent` for the application, newest first, each row showing ` → `, the actor's name, and the time; a row with no `from` (the one-time migration backfill) reads "Status recorded as · before history tracking" instead. Opened from the detail page, history arrives pre-fetched; opened from a table row, the dialog opens immediately and shows three skeleton rows in an `aria-busy` region while `loadApplicationStatusHistory` fetches, re-fetching on every open. Accept/Reject picked from the Select still confirm through the same `ConfirmDialog` as the header's quick actions, and a resulting move to `accepted` fires the same reduced-motion-aware confetti burst as [PM-11](#pm-11-move-one-application-through-the-status-path), for the acting reviewer only, while the dialog is still open. - **Failure / edge** - The target already matches the current status → **"This application is already