Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions app/(main)/(auth)/positions/[id]/apply/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { getProfileData } from '@/prisma/data/profile';
import { withRedirectTo } from '@/lib/auth/redirect';
import { getCurrentUser } from '@/lib/auth/server';
import {
APPLICANT_EDITABLE_APPLICATION_STATUSES,
APPLICATION_STATUS_LABELS,
UNRESOLVED_APPLICATION_STATUSES,
isApplicantEditableApplicationStatus,
} from '@/lib/constants';
import {
isAcceptingApplications,
Expand Down Expand Up @@ -72,10 +72,7 @@ export default async function ApplyPage({ params }: ApplyPageProps) {
.every((d) => isAnswered(d.question, toStringArray(d.answer?.value)));

const isEditable =
application &&
APPLICANT_EDITABLE_APPLICATION_STATUSES.includes(
application.status as (typeof APPLICANT_EDITABLE_APPLICATION_STATUSES)[number],
);
application && isApplicantEditableApplicationStatus(application.status);

const isResubmit = application?.status === 'withdrawn';

Expand Down
3 changes: 1 addition & 2 deletions docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Four principals, each derived rather than stored as a single role field:
| `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` | throw on scope miss, `{ error }` on invalid transition or write race |
| `applications.ts` — `updateApplicationStatuses` (bulk) | authorization **folded into the `updateMany` where** (`buildApplicationScopeWhere(user)` + `status: { in: getApplicationStatusForwardSources(status) }`) | ineligible rows are skipped, not walked backward | `{ updated, skipped }` — a skip count, never an error, for rows outside the caller's scope or transition graph |
| `question-files.ts` — file answer actions | `authorizeTarget` gates on the application being `draft` | draft-scoped lookup (`tx.application.findFirst({ id, status: 'draft' })`) | `{ error: SUBMITTED_ERROR }` once submitted |
| `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 }` |
| `question-files.ts` — `cleanupOrphanedBlob` | **none** — exported from this `'use server'` file with no auth guard | reference-counts the URL across all three answer tables; deletes only at zero references, swallows errors | see [Known-open deviations](#known-open-deviations) |
| `position-actions.ts` — `createPosition`, `searchUsers` | `requireManagerOrAdmin()` | — | throw |
| `position-actions.ts` — `updatePosition` | `getCurrentUser()` → existence check → `requirePositionAccess(id)` → `checkPositionEditable` | archived positions rejected even for their own manager | `{ error: ARCHIVED_POSITION_EDIT_ERROR }` |
Expand Down Expand Up @@ -179,6 +179,5 @@ The policy constrains future transitions only, so nothing here needs a data migr
| `updatePosition` accepts any status → any status: a manager can unpublish a position with live applications, or "reopen" one past its `closesAt` and get a silent no-op | `prisma/actions/position-actions.ts` | #526 |
| `createPositionSchema` accepts `status: 'closed'` | `prisma/actions/position-actions.ts` | #526 |
| No `closesAt` ≥ `opensAt` validation anywhere; pre-existing rows may already violate it, so the error must name both fields | `createPositionSchema`, `updatePositionSchema`, `positionFormSchema` | #527 |
| File answers are draft-only (`authorizeTarget` returns `SUBMITTED_ERROR` for any non-`draft` status) while text answers follow `APPLICANT_EDITABLE_APPLICATION_STATUSES` (`draft`\|`withdrawn`) — a withdrawn application can edit its text answers but not replace a file | `prisma/actions/question-files.ts` | #562 |
| `cleanupOrphanedBlob` is an unauthenticated server action taking an arbitrary URL. Impact is bounded (deletes only at zero references, swallows errors), but it should not be exported from a `'use server'` module | `prisma/actions/question-files.ts` | #563 |
| `WORKFLOWS.md` XC-3 describes a profile-completeness redirect gate in `app/(main)/(auth)/layout.tsx` that no longer exists — that layout now only does `getCurrentUser` + `requireName`; the only completeness check left is the apply page's "Complete your profile first" card | `docs/WORKFLOWS.md` | #567 |
4 changes: 2 additions & 2 deletions docs/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a
- **Failure / edge**
- Not PDF/PNG/JPG, or the sniffed type disagrees with the extension → **"Only PDF, PNG and JPG files are allowed."**
- Over `FILE_UPLOAD_MAX_BYTES` (4 MB) → **"File must be 4MB or smaller."**; empty file → **"Select a file to upload."** The helper text reads "PDF, PNG or JPG · up to 4MB".
- The application was submitted in another tab → **"This application has already been submitted."** and the freshly uploaded blob is deleted rather than orphaned.
- The application left an applicant-editable status in another tab, or was decided → **"This application has already been submitted. Withdraw it to make changes."** and the freshly uploaded blob is deleted rather than orphaned.
- Ownership miss on the application, or a question that isn't a `file_upload` on this position → the action throws → generic toast.
- Blob storage failure → throws → generic toast; nothing is written.
- **End state** — the answer's `value` holds exactly one blob URL (or none after a remove). Orphaned blobs are swept by `cleanupOrphanedBlob`, which is best-effort and never surfaces an error.
Expand Down Expand Up @@ -314,7 +314,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a
### AP-14 Edit and resubmit a withdrawn application

- **Trigger** — **Edit & resubmit** on a withdrawn row in `/my-applications`, which returns to `/positions/[id]/apply`.
- **Happy path** — `withdrawn` is applicant-editable, so the stepper reopens with every answer intact, 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"**.
- **Failure / edge**
- The window closed while it was withdrawn → the row shows "Position closed" instead of the button, and the apply page 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.
Expand Down
13 changes: 13 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,19 @@ export const APPLICANT_EDITABLE_APPLICATION_STATUSES = [
'withdrawn',
] as const satisfies $Enums.ApplicationStatus[];

// Narrows a status without an unsafe cast at each call site.
export function isApplicantEditableApplicationStatus(
status: $Enums.ApplicationStatus,
): status is (typeof APPLICANT_EDITABLE_APPLICATION_STATUSES)[number] {
return (
APPLICANT_EDITABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[]
).includes(status);
}

// Shared by the text- and file-answer edit paths so the sentence can't drift.
export const APPLICATION_NOT_EDITABLE_MESSAGE =
'This application has already been submitted. Withdraw it to make changes.';

// Includes 'draft' (unlike UNRESOLVED): a draft-only applicant still needs attention.
export const NON_TERMINAL_APPLICATION_STATUSES = [
'draft',
Expand Down
19 changes: 4 additions & 15 deletions prisma/actions/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ import { getCurrentUser } from '@/lib/auth/server';
import {
ANSWER_LONG_MAX_LENGTH,
ANSWER_MAX_VALUES,
APPLICANT_EDITABLE_APPLICATION_STATUSES,
APPLICATION_NOT_EDITABLE_MESSAGE,
APPLICATION_STATUS_LABELS,
REVIEWER_APPLICATION_STATUSES,
SHORT_ANSWER_FORMAT_ERROR_MESSAGES,
TERMINAL_DECISION_STATUSES,
getAnswerValueError,
getApplicationStatusForwardSources,
isAllowedApplicationStatusTransition,
isApplicantEditableApplicationStatus,
matchesShortAnswerFormat,
} from '@/lib/constants';
import { prisma } from '@/lib/prisma';
Expand Down Expand Up @@ -85,10 +86,6 @@ const createOrUpdateApplicationAnswerSchema = z.object({

const submitApplicationSchema = z.object({ applicationId: z.string().min(1) });

// Shared so callers don't distinguish which action rejected the write.
const APPLICATION_NOT_EDITABLE_MESSAGE =
'This application has already been submitted. Withdraw it to make changes.';

// Interaction-time only — never called during render (see apply/page.tsx).
export async function createDraftApplication(
input: unknown,
Expand Down Expand Up @@ -198,11 +195,7 @@ export async function createOrUpdateApplicationAnswer(params: {

requireOwnership(application, currentUser.id);

if (
!APPLICANT_EDITABLE_APPLICATION_STATUSES.includes(
application.status as (typeof APPLICANT_EDITABLE_APPLICATION_STATUSES)[number],
)
)
if (!isApplicantEditableApplicationStatus(application.status))
return { error: APPLICATION_NOT_EDITABLE_MESSAGE };

// Label and scope must come from the DB, not the client — the label is the
Expand Down Expand Up @@ -351,11 +344,7 @@ export async function submitApplication(
requireOwnership(application, currentUser.id);

// Status check first — wins over the window/required-answer checks below.
if (
!APPLICANT_EDITABLE_APPLICATION_STATUSES.includes(
application.status as (typeof APPLICANT_EDITABLE_APPLICATION_STATUSES)[number],
)
)
if (!isApplicantEditableApplicationStatus(application.status))
return { error: APPLICATION_NOT_EDITABLE_MESSAGE };

// A draft's position can be soft-deleted after creation, before submit.
Expand Down
28 changes: 17 additions & 11 deletions prisma/actions/question-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import type { Prisma } from '@/prisma/client';

import { getCurrentUser } from '@/lib/auth/server';
import {
APPLICANT_EDITABLE_APPLICATION_STATUSES,
APPLICATION_NOT_EDITABLE_MESSAGE,
FILE_UPLOAD_MAX_BYTES,
FILE_UPLOAD_MIME_EXTENSIONS,
FILE_UPLOAD_MIME_TYPES,
isApplicantEditableApplicationStatus,
questionFileTargetSchema,
} from '@/lib/constants';
import {
Expand All @@ -24,11 +27,10 @@ import type { QuestionFileDownload, QuestionFileTarget } from '@/lib/types';
import { type ResponseType } from '@/lib/utils';

const GENERIC_TYPE_ERROR = 'Only PDF, PNG and JPG files are allowed.';
const SUBMITTED_ERROR = 'This application has already been submitted.';
const NOT_AVAILABLE_ERROR = 'This file is no longer available.';

// Signals the caller to surface SUBMITTED_ERROR rather than rethrow as unexpected.
class ApplicationSubmittedError extends Error {}
// Signals the caller to surface APPLICATION_NOT_EDITABLE_MESSAGE, not rethrow as unexpected.
class ApplicationNotEditableError extends Error {}

// Returned verbatim as user-facing copy; question-file-field.tsx mirrors them.
const fileSchema = z
Expand All @@ -54,7 +56,7 @@ type ResolvedTarget =
| { scope: 'profile' }
| { scope: 'application'; positionId: string };

// An ownership miss is IDOR-style and throws; a stale tab's submit returns.
// An ownership miss is IDOR-style and throws; a status change returns.
async function authorizeTarget(
userId: string,
target: QuestionFileTarget,
Expand Down Expand Up @@ -93,7 +95,8 @@ async function authorizeTarget(
if (!question) throw new Error('Question not found or not authorized');
}

if (application.status !== 'draft') return { error: SUBMITTED_ERROR };
if (!isApplicantEditableApplicationStatus(application.status))
return { error: APPLICATION_NOT_EDITABLE_MESSAGE };

return { scope: 'application', positionId: application.positionId };
}
Expand Down Expand Up @@ -133,10 +136,13 @@ async function readAndWriteAnswerValue(
}

const application = await tx.application.findFirst({
where: { id: target.applicationId, status: 'draft' },
where: {
id: target.applicationId,
status: { in: [...APPLICANT_EDITABLE_APPLICATION_STATUSES] },
},
select: { id: true },
});
if (!application) throw new ApplicationSubmittedError();
if (!application) throw new ApplicationNotEditableError();

if (target.isGlobal) {
const where = {
Expand Down Expand Up @@ -256,8 +262,8 @@ export async function uploadQuestionFileAnswer(
} catch (error) {
// A failed write must not orphan the blob just uploaded.
await del(blob.url).catch(() => {});
if (error instanceof ApplicationSubmittedError)
return { error: SUBMITTED_ERROR };
if (error instanceof ApplicationNotEditableError)
return { error: APPLICATION_NOT_EDITABLE_MESSAGE };
throw error;
}

Expand Down Expand Up @@ -286,8 +292,8 @@ export async function removeQuestionFileAnswer(
readAndWriteAnswerValue(tx, target, [], user.id),
);
} catch (error) {
if (error instanceof ApplicationSubmittedError)
return { error: SUBMITTED_ERROR };
if (error instanceof ApplicationNotEditableError)
return { error: APPLICATION_NOT_EDITABLE_MESSAGE };
throw error;
}

Expand Down
3 changes: 1 addition & 2 deletions tests/db/application-transitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ import {
import { prisma } from '@/lib/prisma';
import { isError } from '@/lib/utils';

// Mirrors the (deliberately unexported) copy in prisma/actions/applications.ts —
// asserting the exact sentence is the point of this suite.
// Local literal, not the lib/constants.ts export — asserting the exact sentence is the point.
const APPLICATION_NOT_EDITABLE_MESSAGE =
'This application has already been submitted. Withdraw it to make changes.';
const WITHDRAW_NOT_ALLOWED_MESSAGE =
Expand Down
Loading