Skip to content

Show Managers Who Has A Draft Application, Without Exposing Answers #616

Description

@b-at-neu

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.md states drafts are:

never — excluded from both listable and reviewable scopes (including soft-deleted drafts)

It is enforced in three places:

  • buildApplicationWhere(user, scope)listable uses status: { not: 'draft' }, reviewable uses notIn: NON_REVIEWABLE_APPLICATION_STATUSES (which is ['draft','withdrawn']).
  • getPositionApplicationStatsstatus: { 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 pathgetDraftApplications — 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-results.tsxnew, server: draft rows + count, pagination, page clamping (mirrors applications-results.tsx).
  • components/features/draft-applications-table.tsxnew, client: DataTable with no checkbox column, no row links, no status actions.
  • components/features/drafts-in-progress-notice.tsxnew, 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.tsxshowSelection prop so the drafts skeleton doesn't render a checkbox column that never arrives.
  • prisma/actions/applications.tsrevalidatePath('/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.mdPM-8 only (drafts filter, strip, inert rows; also correct "scoped by reviewable" → listable). AP-5 is deliberately untouched.
  • tests/db/draft-visibility.test.tsnew; tests/db/authorization.test.ts, tests/unit/constants.test.ts, tests/unit/utils.test.ts — extended.

Implementation

  • 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 Suspense key 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 scopingbuildApplicationScopeWhere(user) (⇒ deletedAt: null + PUBLISHED_POSITION_WHERE, plus managers: { some: { id: user.id } } for a non-admin) and status: '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 surfacesno 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.tsgetApplications/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.tsbuildApplicationsHref 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' over listable'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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

claudeWill be worked on by ClaudeenhancementNew feature or requestpr openedPull request has been opened

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions