You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
getPositionApplicationStats — status: { notIn: ['draft','withdrawn'] }, so the manager card's stat cluster excludes drafts too.
getApplicationForReview — built on listable, so a draft detail page 404s.
This ticket relaxes the first deliberately and leaves the third untouched. That distinction is the whole ticket.
The trap — read this before implementing
The obvious implementation is to loosen buildApplicationWhere so drafts pass the scope filter. Do not do that.getApplicationForReview shares the same helper, so widening it silently grants managers the ability to open /applications/[id] for a draft and read every answer the applicant has typed so far — the exact thing this ticket forbids.
Drafts must reach the list through a separate, purpose-built query that selects only identity and timestamps, never answers. getApplicationForReview and both existing scopes stay exactly as they are.
What may be exposed
That a draft exists for a given position.
The applicant's identity — name and email.
When it was started and last touched.
What must never be exposed
Any answer value, global or position-specific, in any form — no preview, no count of answered questions, no completion percentage. A "3 of 8 answered" indicator leaks progress and is out of scope.
Any file the applicant uploaded.
The draft detail page. /applications/[id] must continue to 404 for a draft.
Decision needed at the plan gate
Are drafts in the queue by default, or only when filtered for?
(a) Excluded by default, revealed by a "Draft" status filter, with a count shown separately(recommended). /applications stays a queue of actionable work; a manager's totals and pagination don't shift underneath them; drafts are one click away when wanted.
(b) Inline by default, visually distinguished. More discoverable, but every existing count, filter total and page boundary changes meaning, and the queue fills with rows nobody can act on.
Scope
1. A dedicated draft query in prisma/data/applications.ts, scoped by buildApplicationScopeWhere(user) (position ownership) plus status: 'draft' and deletedAt: null. Selects the applicant's name and email, createdAt, updatedAt, and the position — and nothing else. Soft-deleted drafts stay excluded, consistent with #611.
2. Surface it on /applications. Add draft to the toolbar's status filter, which currently sources REVIEWER_APPLICATION_STATUS_OPTIONS (that list exists specifically to exclude draft — introduce a filter-only option list rather than mutating it, since REVIEWER_APPLICATION_STATUSES also backs the write-side zod enum and must not gain draft).
3. Make draft rows inert. They are not links, not selectable by the bulk checkbox, and carry no status actions. updateApplicationStatuses already can't move a draft — draft appears in no forward/back array — but the UI shouldn't offer the affordance in the first place.
4. Use the right identity and date.applicantName is null until submission, so drafts must display user.name / user.email — via the shared display helper from #604, since a blank name would otherwise render a ghost row. submittedAt is also meaningless for a draft (it defaults at creation and is overwritten at submission), so show updatedAt and label the column so it doesn't read as an applied date.
5. Show the count. Whatever (a)/(b) is chosen, "how many" should be legible without hunting — a count on /applications and, if cheap, in the position card's stat cluster. Note getPositionApplicationStats and POSITION_CARD_STAT_STATUSES both deliberately exclude draft today, so that's a second, separate decision rather than a free change.
Privacy
This tells a reviewer that someone intends to apply before they have committed to it. Someone may start a draft, think better of it, and never submit — and a manager will have seen their name. That is a real disclosure and a defensible one for an internal SGA tool, but it should be a recorded decision rather than an implementation detail:
Add it to docs/PERMISSIONS.md as deliberate policy, replacing the "never" entry in the draft row of the state table.
Decide whether applicants are told. Saying so plainly on the apply page ("your name is visible to this position's managers once you start") is cheap and avoids a surprise.
Non-goals
No reviewer access to draft answers, ever.
No draft detail page.
No notifications about drafts, and no nudging applicants to finish.
No change to listable or reviewable, or to getApplicationForReview.
No change to #550's other-applications list, which excludes drafts on purpose and should keep doing so.
Acceptance criteria
A manager sees drafts for positions they manage, and only those.
An admin sees drafts across all published positions.
Each draft row shows the applicant's name and email and when it was last touched.
/applications/[id] still 404s for a draft, for managers and admins alike.
No answer value, file, or completion indicator appears anywhere for a draft.
Draft rows are not clickable and cannot be bulk-selected.
Soft-deleted drafts never appear.
A draft whose applicant has a blank name still shows their email.
The count of drafts is visible without applying a filter.
REVIEWER_APPLICATION_STATUSES still excludes draft, so no write path can target one.
docs/PERMISSIONS.md state table and docs/WORKFLOWS.md PM-8 updated.
npm run prettier:check, eslint:check, tsc:check, test all pass.
Tests
tests/db/ — the draft query returns drafts only for managed positions; an unrelated manager gets none; soft-deleted drafts are excluded; the selected fields contain no answer relations; getApplicationForReview still returns null for a draft after the change; buildApplicationWhere output is unchanged for both scopes.
Implementation Plan
Overview
Drafts reach the reviewer through a second, purpose-built read path — getDraftApplications — that selects identity and timestamps and nothing else. The shared scope helpers, getApplicationForReview and REVIEWER_APPLICATION_STATUSES are all untouched, so the draft detail page keeps 404ing. On /manage/applications, draft becomes a status filter value; picking it swaps the results region for an inert, identity-only drafts table. A count strip under the page header keeps "how many" visible without filtering.
Decisions taken at this gate (say so at plan review if any is wrong):
(a) — drafts stay out of the default queue. Existing counts, pagination, bulk selection and the listable scope keep their exact current meaning; drafts are one click away.
Position-card stat cluster: unchanged. Not cheap — a Draft tile breaks the 2×2 grid and changes what PositionApplicationStats.total means for every consumer of getPositionApplicationStats. The count strip covers "how many" for this ticket.
Applicants are not told (settled at plan review). The apply page and every other applicant-facing surface keep their current copy exactly — no new disclosure sentence anywhere. The policy change is recorded for reviewers in docs/PERMISSIONS.md only. This ticket must not touch components/features/start-application-card.tsx or docs/WORKFLOWS.md AP-5.
Changes
prisma/data/applications.ts — add getDraftApplications / getDraftApplicationsCount plus private buildDraftListWhere / buildDraftListOrderBy; guard buildApplicationListWhere so a draft status filter can never overwrite listable's status: { not: 'draft' }.
lib/types.ts — add DraftApplicationListItem (no status, no applicantName, no answer relations) and ApplicationStatusFilter; widen ApplicationFilters['status'] to it and update the "so 'draft' can never be filtered for" comment.
lib/constants.ts — no new status constant: APPLICATION_STATUS_VALUES / APPLICATION_STATUS_OPTIONS already are exactly "the filterable statuses" (draft in, withdrawn out). Only their comment changes, to say they are the queue's filter list. REVIEWER_APPLICATION_STATUSES / _OPTIONS stay draft-free.
lib/utils.ts — extract buildApplicationsHref(filters, page?) from applications-pagination.tsx; two call sites now (pagination + count strip).
app/(main)/(auth)/manage/applications/page.tsx — widen the status searchParam enum, fetch the draft count alongside positions/applicants, branch the results region on filters.status === 'draft'.
components/features/draft-applications-table.tsx — new, client: DataTable with no checkbox column, no row links, no status actions.
components/features/drafts-in-progress-notice.tsx — new, server: the count strip.
components/features/applications-pagination.tsx — optional noun prop (default 'application'); use the shared href builder.
components/features/applications-toolbar.tsx — status Select maps APPLICATION_STATUS_OPTIONS.
components/features/applications-table-skeleton.tsx — showSelection prop so the drafts skeleton doesn't render a checkbox column that never arrives.
prisma/actions/applications.ts — revalidatePath('/manage/applications') in createDraftApplication (success path) and deleteDraftApplication; a draft appearing/disappearing is now reviewer-visible.
docs/PERMISSIONS.md — replace the draft row's never cell; add the bullet that states the split.
docs/WORKFLOWS.md — PM-8 only (drafts filter, strip, inert rows; also correct "scoped by reviewable" → listable). AP-5 is deliberately untouched.
lib/types.ts: DraftApplicationListItem via Prisma.ApplicationGetPayload selecting only id, createdAt, updatedAt, position { id, title }, user { id, name, email }. ApplicationStatusFilter = (typeof APPLICATION_STATUS_VALUES)[number]; ApplicationFilters['status'] uses it.
prisma/data/applications.ts: buildDraftListWhere(user, filters) = buildApplicationScopeWhere(user) + status: 'draft' + optional positionId / userId + a q OR over applicant name/email and position title (no date branch — a draft has no meaningful submittedAt).
getDraftApplications(user, filters, page) — the DraftApplicationListItem select, take/skip on APPLICATIONS_PAGE_SIZE; getDraftApplicationsCount(user, filters) built from the same where (a divergent count would leak out-of-scope existence). buildDraftListOrderBy: name → user name/email; anything else → [{ updatedAt: 'desc' }, { id: 'desc' }].
Same file: in buildApplicationListWhere, only apply the status filter when it isn't draft, so the widened filter type can never overwrite the base status: { not: 'draft' }. One-line comment naming getDraftApplications as draft's only path.
lib/utils.ts: move buildHref out of applications-pagination.tsx as buildApplicationsHref; pagination imports it.
Page: status: z.enum(APPLICATION_STATUS_VALUES).optional().catch(undefined); add getDraftApplicationsCount(user, { ...filters, status: undefined }) to the existing Promise.all; render DraftsInProgressNotice under PageHeader when filters.status !== 'draft' and the count > 0; inside the Suspense, render DraftApplicationsResults when filters.status === 'draft', else ApplicationsResults. Keep the Suspensekey as-is so a filter change remounts the skeleton.
DraftsInProgressNotice: muted bordered strip — APPLICATION_STATUS_ICONS.draft + "N applications in progress" (singular at 1) + an outline Button asChild "View drafts" linking to buildApplicationsHref({ ...filters, status: 'draft' }).
DraftApplicationsResults: fetch count + rows in parallel, clamp a stale ?page= past the last page exactly like ApplicationsResults, render the privacy line, the table, then ApplicationsPagination with noun="draft".
DraftApplicationsTable (client): columns Applicant (sortable, displayUserName(app.user) + email beneath only when app.user.name is set) · Position (links /positions/[id]) · Started (createdAt, not sortable) · Last updated (updatedAt, sortable, sort key date). No select column, no ApplicationStatusActions, no ApplicationsBulkBar, no link on the applicant cell. Controlled sort pushed to the URL, same as ApplicationsTable; pass sort through only for name/date.
Mobile card: display name, email (same condition), position link, "Started · Updated " — no checkbox, no actions.
Toolbar: swap REVIEWER_APPLICATION_STATUS_OPTIONS for APPLICATION_STATUS_OPTIONS in the status Select only. The other two option lists (bulk bar, status dialog) keep the reviewer list — they are write paths.
prisma/actions/applications.ts: the two revalidatePath('/manage/applications') lines.
Docs: PERMISSIONS.md draft row → "identity only — name, email, started/last-touched, via getDraftApplications; never answers, files or the detail page", plus a bullet stating that buildApplicationWhere/getApplicationForReview are deliberately unchanged and why, and that the change is reviewer-facing only — applicants are not notified. WORKFLOWS.md PM-8 only; leave AP-5 as it is.
Tests below; then npm run prettier:check, eslint:check, tsc:check, test.
Data & contracts
No schema change, no migration, no new index — the draft query has the same shape as the existing queue queries (position.managers join + status + deletedAt), so it warrants no index the queue doesn't already live without.
No new server action. The feature is read-only; the only edits to prisma/actions/applications.ts are the two revalidatePath calls, so no new zod schema, error copy or toast. Existing actions' auth, validation and { error } copy are unchanged.
getDraftApplications / getDraftApplicationsCount scoping — buildApplicationScopeWhere(user) (⇒ deletedAt: null + PUBLISHED_POSITION_WHERE, plus managers: { some: { id: user.id } } for a non-admin) andstatus: 'draft'. Both are cross-user identity: reviewer-gated callers only, marked with the same comment style as getApplications. The page's requireManagerOrAdminOr404 is the gate.
The select is the privacy contract.DraftApplicationListItem has no globalAnswers/positionAnswers/applicantName/status, so no answer, file or completion signal is reachable from the component — a compile error, not a review catch.
Input boundary — the page's existing searchParams zod, with status widened to APPLICATION_STATUS_VALUES. Per-field .catch(undefined) still drops junk silently rather than 500ing.
The one-way valve:REVIEWER_APPLICATION_STATUSES (the write-side enum for updateApplicationStatus/updateApplicationStatuses) must keep excluding draft; the filter list is a separate constant and stays read-only.
UX states
Default queue — unchanged, plus the strip under the header when drafts exist: "3 applications in progress" ("1 application in progress" at one) · View drafts. Hidden at zero and while the draft filter is on. It respects the active position/applicant/search filters, and its link carries them.
Draft view — Status filter reads "Draft"; above the table, a muted line with STATE_ICONS.hidden: "You can see who started an application, not what they've written. Draft answers stay private until the applicant submits." Rows are plain text — nothing about them suggests they can be opened or actioned.
Loading — the existing Suspense + ApplicationsTableSkeleton with showSelection={false}, so the skeleton matches the checkbox-less table. loading.tsx deliberately gains no strip placeholder: the strip is conditional, and a skeleton that often resolves to nothing shifts more than it prevents.
Empty (draft view, no other filters) — EmptyState, icon APPLICATION_STATUS_ICONS.draft: "No drafts in progress" · "You'll see them here as soon as someone starts an application."
Empty (draft view, other filters active) — "No drafts match these filters" · "Try adjusting or clearing your filters." · Clear filters → /manage/applications?status=draft.
Applicant-facing surfaces — no change. The apply page, the Start-application card and the applicant dashboard keep their current copy verbatim; nothing tells an applicant that managers can see their draft.
Errors — read-only page; a query failure hits the global boundary. No toasts anywhere in this feature.
A11y — the table keeps DataTable's caption/aria-sort semantics ("Applications" → "Drafts"); the strip's action is a real link with a visible label, not an icon-only control; LocalTime handles both dates; no colour-only signal.
Testing
Run against the Vercel preview (local dev 500s on auth).
As a manager of one position: start drafts as two applicants on that position and one on a position you don't manage. /manage/applications shows "2 applications in progress" and none of the drafts in the default queue.
Click View drafts — both your drafts appear with name, email, started and last-updated dates; the third does not.
Confirm a draft row is inert: no checkbox, no ⋯, applicant name is not a link, and the bulk bar never appears.
Copy a draft's id and open /manage/applications/<id> — 404, as a manager and as an admin.
As an admin: the drafts view lists drafts across all published positions.
Edit a draft's answers as the applicant, reload the drafts view — Last updated moves, no answer text appears anywhere.
Delete the draft as the applicant — it leaves the drafts view and the count on reload; submit another — it leaves the drafts view and appears in the normal queue.
Filter by position, then by Draft: the strip's count and the drafts list both respect the position filter; Clear filters returns to the unfiltered queue.
Sort the drafts table by Applicant and by Last updated, both directions; page past 50 drafts if seeded, and confirm the summary reads "… drafts".
A draft whose applicant has no name shows their email in the Applicant cell.
As an applicant, start an application and walk the apply flow: the copy is identical to before this change — no mention of manager visibility anywhere.
375px / 768px / 1280px: mobile cards show name, email, position and both dates with no action affordances.
Automated:
tests/db/draft-visibility.test.ts — manager sees only managed-position drafts; an unrelated manager sees none; admin sees all published-position drafts; soft-deleted drafts excluded; drafts on unpublished/soft-deleted positions excluded; non-draft statuses never returned; count agrees with rows; Object.keys(row) contains no globalAnswers/positionAnswers/applicantName/status.
tests/db/authorization.test.ts — getApplications/getApplicationsCount with { status: 'draft' } return no draft rows and the same total as {}; getApplicationForReview still returns null for a draft.
tests/unit/constants.test.ts — the filter list contains draft, REVIEWER_APPLICATION_STATUSES still doesn't.
tests/unit/utils.test.ts — buildApplicationsHref round-trips filters and omits page=1.
Risks / notes
The trap in the ticket has a second door. Widening ApplicationFilters['status'] makes buildApplicationListWhere spread status: 'draft'overlistable's status: { not: 'draft' } — drafts would land in the normal queue, selectable and linking to a 404. The explicit guard plus the authorization.test.ts case exist for exactly that.
Applicant filter gap:getReviewableApplicants is listable-scoped, so someone whose only application is a draft isn't in that dropdown. Widening it would change the option list for every status; left alone, documented in PM-8.
Stale sort=status:* carried into the draft view falls back to newest-first with no active sort header. Harmless; the drafts table has no status column.
Revision note (plan review): the applicant-facing disclosure line is cut. The ticket's Privacy section still proposes it — that proposal is answered "no"; the ticket text above is left as written for history. Only the reviewer-facing docs/PERMISSIONS.md policy record and PM-8 remain.
Goal
A manager or admin can see how many people have a draft application for their position, and who they are — without seeing anything they have written.
Today the top of the funnel is invisible. A position can have a dozen people mid-application and its manager sees an empty queue.
What this changes
Draft invisibility is currently a deliberate, documented invariant, not an oversight.
docs/PERMISSIONS.mdstates drafts are:It is enforced in three places:
buildApplicationWhere(user, scope)—listableusesstatus: { not: 'draft' },reviewableusesnotIn: NON_REVIEWABLE_APPLICATION_STATUSES(which is['draft','withdrawn']).getPositionApplicationStats—status: { notIn: ['draft','withdrawn'] }, so the manager card's stat cluster excludes drafts too.getApplicationForReview— built onlistable, so a draft detail page 404s.This ticket relaxes the first deliberately and leaves the third untouched. That distinction is the whole ticket.
The trap — read this before implementing
The obvious implementation is to loosen
buildApplicationWhereso drafts pass the scope filter. Do not do that.getApplicationForReviewshares the same helper, so widening it silently grants managers the ability to open/applications/[id]for a draft and read every answer the applicant has typed so far — the exact thing this ticket forbids.Drafts must reach the list through a separate, purpose-built query that selects only identity and timestamps, never answers.
getApplicationForReviewand both existing scopes stay exactly as they are.What may be exposed
What must never be exposed
/applications/[id]must continue to 404 for a draft.Decision needed at the plan gate
Are drafts in the queue by default, or only when filtered for?
/applicationsstays a queue of actionable work; a manager's totals and pagination don't shift underneath them; drafts are one click away when wanted.Scope
1. A dedicated draft query in
prisma/data/applications.ts, scoped bybuildApplicationScopeWhere(user)(position ownership) plusstatus: 'draft'anddeletedAt: null. Selects the applicant's name and email,createdAt,updatedAt, and the position — and nothing else. Soft-deleted drafts stay excluded, consistent with #611.2. Surface it on
/applications. Adddraftto the toolbar's status filter, which currently sourcesREVIEWER_APPLICATION_STATUS_OPTIONS(that list exists specifically to exclude draft — introduce a filter-only option list rather than mutating it, sinceREVIEWER_APPLICATION_STATUSESalso backs the write-side zod enum and must not gaindraft).3. Make draft rows inert. They are not links, not selectable by the bulk checkbox, and carry no status actions.
updateApplicationStatusesalready can't move a draft —draftappears in noforward/backarray — but the UI shouldn't offer the affordance in the first place.4. Use the right identity and date.
applicantNameis null until submission, so drafts must displayuser.name/user.email— via the shared display helper from #604, since a blank name would otherwise render a ghost row.submittedAtis also meaningless for a draft (it defaults at creation and is overwritten at submission), so showupdatedAtand label the column so it doesn't read as an applied date.5. Show the count. Whatever (a)/(b) is chosen, "how many" should be legible without hunting — a count on
/applicationsand, if cheap, in the position card's stat cluster. NotegetPositionApplicationStatsandPOSITION_CARD_STAT_STATUSESboth deliberately exclude draft today, so that's a second, separate decision rather than a free change.Privacy
This tells a reviewer that someone intends to apply before they have committed to it. Someone may start a draft, think better of it, and never submit — and a manager will have seen their name. That is a real disclosure and a defensible one for an internal SGA tool, but it should be a recorded decision rather than an implementation detail:
docs/PERMISSIONS.mdas deliberate policy, replacing the "never" entry in thedraftrow of the state table.Non-goals
listableorreviewable, or togetApplicationForReview.#550's other-applications list, which excludes drafts on purpose and should keep doing so.Acceptance criteria
/applications/[id]still 404s for a draft, for managers and admins alike.REVIEWER_APPLICATION_STATUSESstill excludesdraft, so no write path can target one.docs/PERMISSIONS.mdstate table anddocs/WORKFLOWS.mdPM-8 updated.npm run prettier:check,eslint:check,tsc:check,testall pass.Tests
tests/db/— the draft query returns drafts only for managed positions; an unrelated manager gets none; soft-deleted drafts are excluded; the selected fields contain no answer relations;getApplicationForReviewstill returns null for a draft after the change;buildApplicationWhereoutput is unchanged for both scopes.Implementation Plan
Overview
Drafts reach the reviewer through a second, purpose-built read path —
getDraftApplications— that selects identity and timestamps and nothing else. The shared scope helpers,getApplicationForReviewandREVIEWER_APPLICATION_STATUSESare all untouched, so the draft detail page keeps 404ing. On/manage/applications,draftbecomes a status filter value; picking it swaps the results region for an inert, identity-only drafts table. A count strip under the page header keeps "how many" visible without filtering.Decisions taken at this gate (say so at plan review if any is wrong):
listablescope keep their exact current meaning; drafts are one click away.PositionApplicationStats.totalmeans for every consumer ofgetPositionApplicationStats. The count strip covers "how many" for this ticket.docs/PERMISSIONS.mdonly. This ticket must not touchcomponents/features/start-application-card.tsxordocs/WORKFLOWS.mdAP-5.Changes
prisma/data/applications.ts— addgetDraftApplications/getDraftApplicationsCountplus privatebuildDraftListWhere/buildDraftListOrderBy; guardbuildApplicationListWhereso adraftstatus filter can never overwritelistable'sstatus: { not: 'draft' }.lib/types.ts— addDraftApplicationListItem(nostatus, noapplicantName, no answer relations) andApplicationStatusFilter; widenApplicationFilters['status']to it and update the "so 'draft' can never be filtered for" comment.lib/constants.ts— no new status constant:APPLICATION_STATUS_VALUES/APPLICATION_STATUS_OPTIONSalready are exactly "the filterable statuses" (draft in, withdrawn out). Only their comment changes, to say they are the queue's filter list.REVIEWER_APPLICATION_STATUSES/_OPTIONSstay draft-free.lib/utils.ts— extractbuildApplicationsHref(filters, page?)fromapplications-pagination.tsx; two call sites now (pagination + count strip).app/(main)/(auth)/manage/applications/page.tsx— widen thestatussearchParam enum, fetch the draft count alongside positions/applicants, branch the results region onfilters.status === 'draft'.components/features/draft-applications-results.tsx— new, server: draft rows + count, pagination, page clamping (mirrorsapplications-results.tsx).components/features/draft-applications-table.tsx— new, client:DataTablewith no checkbox column, no row links, no status actions.components/features/drafts-in-progress-notice.tsx— new, server: the count strip.components/features/applications-pagination.tsx— optionalnounprop (default'application'); use the shared href builder.components/features/applications-toolbar.tsx— status Select mapsAPPLICATION_STATUS_OPTIONS.components/features/applications-table-skeleton.tsx—showSelectionprop so the drafts skeleton doesn't render a checkbox column that never arrives.prisma/actions/applications.ts—revalidatePath('/manage/applications')increateDraftApplication(success path) anddeleteDraftApplication; a draft appearing/disappearing is now reviewer-visible.docs/PERMISSIONS.md— replace thedraftrow's never cell; add the bullet that states the split.docs/WORKFLOWS.md— PM-8 only (drafts filter, strip, inert rows; also correct "scoped byreviewable" →listable). AP-5 is deliberately untouched.tests/db/draft-visibility.test.ts— new;tests/db/authorization.test.ts,tests/unit/constants.test.ts,tests/unit/utils.test.ts— extended.Implementation
lib/types.ts:DraftApplicationListItemviaPrisma.ApplicationGetPayloadselecting onlyid,createdAt,updatedAt,position { id, title },user { id, name, email }.ApplicationStatusFilter = (typeof APPLICATION_STATUS_VALUES)[number];ApplicationFilters['status']uses it.prisma/data/applications.ts:buildDraftListWhere(user, filters)=buildApplicationScopeWhere(user)+status: 'draft'+ optionalpositionId/userId+ aqOR over applicant name/email and position title (no date branch — a draft has no meaningfulsubmittedAt).getDraftApplications(user, filters, page)— theDraftApplicationListItemselect,take/skiponAPPLICATIONS_PAGE_SIZE;getDraftApplicationsCount(user, filters)built from the samewhere(a divergent count would leak out-of-scope existence).buildDraftListOrderBy:name→ user name/email; anything else →[{ updatedAt: 'desc' }, { id: 'desc' }].buildApplicationListWhere, only apply the status filter when it isn'tdraft, so the widened filter type can never overwrite the basestatus: { not: 'draft' }. One-line comment naminggetDraftApplicationsas draft's only path.lib/utils.ts: movebuildHrefout ofapplications-pagination.tsxasbuildApplicationsHref; pagination imports it.status: z.enum(APPLICATION_STATUS_VALUES).optional().catch(undefined); addgetDraftApplicationsCount(user, { ...filters, status: undefined })to the existingPromise.all; renderDraftsInProgressNoticeunderPageHeaderwhenfilters.status !== 'draft'and the count > 0; inside theSuspense, renderDraftApplicationsResultswhenfilters.status === 'draft', elseApplicationsResults. Keep theSuspensekeyas-is so a filter change remounts the skeleton.DraftsInProgressNotice: muted bordered strip —APPLICATION_STATUS_ICONS.draft+ "N applications in progress" (singular at 1) + an outlineButton asChild"View drafts" linking tobuildApplicationsHref({ ...filters, status: 'draft' }).DraftApplicationsResults: fetch count + rows in parallel, clamp a stale?page=past the last page exactly likeApplicationsResults, render the privacy line, the table, thenApplicationsPaginationwithnoun="draft".DraftApplicationsTable(client): columns Applicant (sortable,displayUserName(app.user)+ email beneath only whenapp.user.nameis set) · Position (links/positions/[id]) · Started (createdAt, not sortable) · Last updated (updatedAt, sortable, sort keydate). No select column, noApplicationStatusActions, noApplicationsBulkBar, no link on the applicant cell. Controlled sort pushed to the URL, same asApplicationsTable; passsortthrough only forname/date.REVIEWER_APPLICATION_STATUS_OPTIONSforAPPLICATION_STATUS_OPTIONSin the status Select only. The other two option lists (bulk bar, status dialog) keep the reviewer list — they are write paths.prisma/actions/applications.ts: the tworevalidatePath('/manage/applications')lines.PERMISSIONS.mddraft row → "identity only — name, email, started/last-touched, viagetDraftApplications; never answers, files or the detail page", plus a bullet stating thatbuildApplicationWhere/getApplicationForRevieware deliberately unchanged and why, and that the change is reviewer-facing only — applicants are not notified.WORKFLOWS.mdPM-8 only; leave AP-5 as it is.npm run prettier:check,eslint:check,tsc:check,test.Data & contracts
position.managersjoin +status+deletedAt), so it warrants no index the queue doesn't already live without.prisma/actions/applications.tsare the tworevalidatePathcalls, so no new zod schema, error copy or toast. Existing actions' auth, validation and{ error }copy are unchanged.getDraftApplications/getDraftApplicationsCountscoping —buildApplicationScopeWhere(user)(⇒deletedAt: null+PUBLISHED_POSITION_WHERE, plusmanagers: { some: { id: user.id } }for a non-admin) andstatus: 'draft'. Both are cross-user identity: reviewer-gated callers only, marked with the same comment style asgetApplications. The page'srequireManagerOrAdminOr404is the gate.DraftApplicationListItemhas noglobalAnswers/positionAnswers/applicantName/status, so no answer, file or completion signal is reachable from the component — a compile error, not a review catch.statuswidened toAPPLICATION_STATUS_VALUES. Per-field.catch(undefined)still drops junk silently rather than 500ing.REVIEWER_APPLICATION_STATUSES(the write-side enum forupdateApplicationStatus/updateApplicationStatuses) must keep excludingdraft; the filter list is a separate constant and stays read-only.UX states
STATE_ICONS.hidden: "You can see who started an application, not what they've written. Draft answers stay private until the applicant submits." Rows are plain text — nothing about them suggests they can be opened or actioned.Suspense+ApplicationsTableSkeletonwithshowSelection={false}, so the skeleton matches the checkbox-less table.loading.tsxdeliberately gains no strip placeholder: the strip is conditional, and a skeleton that often resolves to nothing shifts more than it prevents.EmptyState, iconAPPLICATION_STATUS_ICONS.draft: "No drafts in progress" · "You'll see them here as soon as someone starts an application."/manage/applications?status=draft.DataTable's caption/aria-sortsemantics ("Applications" → "Drafts"); the strip's action is a real link with a visible label, not an icon-only control;LocalTimehandles both dates; no colour-only signal.Testing
Run against the Vercel preview (local dev 500s on auth).
/manage/applicationsshows "2 applications in progress" and none of the drafts in the default queue.⋯, applicant name is not a link, and the bulk bar never appears./manage/applications/<id>— 404, as a manager and as an admin.Automated:
tests/db/draft-visibility.test.ts— manager sees only managed-position drafts; an unrelated manager sees none; admin sees all published-position drafts; soft-deleted drafts excluded; drafts on unpublished/soft-deleted positions excluded; non-draft statuses never returned; count agrees with rows;Object.keys(row)contains noglobalAnswers/positionAnswers/applicantName/status.tests/db/authorization.test.ts—getApplications/getApplicationsCountwith{ status: 'draft' }return no draft rows and the same total as{};getApplicationForReviewstill returnsnullfor a draft.tests/unit/constants.test.ts— the filter list containsdraft,REVIEWER_APPLICATION_STATUSESstill doesn't.tests/unit/utils.test.ts—buildApplicationsHrefround-trips filters and omitspage=1.Risks / notes
ApplicationFilters['status']makesbuildApplicationListWherespreadstatus: 'draft'overlistable'sstatus: { not: 'draft' }— drafts would land in the normal queue, selectable and linking to a 404. The explicit guard plus theauthorization.test.tscase exist for exactly that.getReviewableApplicantsislistable-scoped, so someone whose only application is a draft isn't in that dropdown. Widening it would change the option list for every status; left alone, documented in PM-8.sort=status:*carried into the draft view falls back to newest-first with no active sort header. Harmless; the drafts table has no status column.docs/PERMISSIONS.mdpolicy record and PM-8 remain.