Skip to content

#348 Exclude Soft-Deleted and Draft Positions From Application Queries and Counts - #410

Merged
b-at-neu merged 4 commits into
devfrom
348-exclude-soft-deleted-and-draft-positions
Aug 12, 2026
Merged

#348 Exclude Soft-Deleted and Draft Positions From Application Queries and Counts#410
b-at-neu merged 4 commits into
devfrom
348-exclude-soft-deleted-and-draft-positions

Conversation

@b-at-neu

Copy link
Copy Markdown
Collaborator

Closes #348

Summary

  • Every application query that joins through Position now excludes soft-deleted positions (always) and draft positions (on cross-position surfaces only) via two shared where-fragments in lib/constants.ts.
  • submitApplication gains a deleted-position gate; updateApplicationStatus/updateApplicationStatuses fold the same exclusion into their authorization where (merged, not overwritten, so manager scoping is unaffected).
  • Seed fixtures now include a draft position and a soft-deleted position, each with an application, so this is regression-tested going forward.

Changes

  • lib/constants.ts — add VISIBLE_POSITION_WHERE ({ deletedAt: null }) and PUBLISHED_POSITION_WHERE ({ deletedAt: null, status: { not: 'draft' } }), the rule stated once.
  • prisma/data/applications.ts — apply PUBLISHED_POSITION_WHERE to every cross-position query (getMyApplications, getRecentMyApplications, getMyApplicationStatusCounts, getMySubmittedCount, getMyRecentActivity, getApplicationStatusCounts, getRecentApplications, buildBaseWhere's admin/manager branches — merged with the manager scope, not overwritten — and getApplicationForReview's admin/manager branches); apply VISIBLE_POSITION_WHERE to the position-scoped getPositionApplications/getPositionApplicationStats (draft stays visible there — the deliberate escape hatch); getReviewablePositions excludes draft too.
  • prisma/data/users.ts + lib/types.ts — the admin user list's _count.applications (and its mirrored AdminUserListItem payload type) now excludes hidden-position applications.
  • prisma/actions/applications.tssubmitApplication returns { error: 'This position is no longer available.' } when the position was soft-deleted after the draft was created (same copy as createDraftApplication); updateApplicationStatus/updateApplicationStatuses merge the published-position constraint into their existing authorization where.
  • prisma/actions/position-actions.tsdeletePosition/updatePosition now also revalidate /, /my-applications, and /applications so hidden rows and stale counts clear immediately.
  • prisma/seed/types.ts, prisma/seed/positions.ts, prisma/seed/applications.ts, prisma/seed.tsPositionDef gains optional status/deleted; two new fixture positions (one draft, one soft-deleted) each get a submitted application, plus a draft application on the deleted position.

The recorded decision (per the plan): cross-position surfaces hide soft-deleted-or-draft-position applications entirely — deletedAt means "does not exist" everywhere else in this codebase, and a surviving row is a dead link inflating reviewer counts. Position-scoped surfaces (getPositionApplications/stats) keep draft positions visible, since a manager reverting a position to draft should still see its applications on the position card.

Testing plan

  • Reset and reseed a local DB (npm run db:reset, then npm run prisma:seed) so the new draft and soft-deleted fixtures exist.
  • As the seeded applicant with applications on the draft/deleted positions: /my-applications shows neither row (no dead "Continue" button); the dashboard "My Applications" widget, its status summary line, the "Submitted" stat card, and the activity feed all exclude them.
  • As an admin: /applications lists neither row, the toolbar count matches the visible rows, and the position filter dropdown offers neither the draft nor the deleted position.
  • As an admin: / dashboard pipeline totals and "Recent applications" exclude both; /users per-user application counts drop by the hidden applications.
  • Deep-link /applications/<hidden application id> → 404, as both admin and as the position's manager.
  • As an admin on /positions: the draft position's card still shows its application stats (escape hatch); the deleted position does not appear at all.
  • Manager-scoping regression: as a manager (not admin), /applications still shows only applications for positions you manage — confirm the merged position where didn't widen the result set.
  • Round-trip: flip the draft position to open in /positions/<id>/edit → its applications reappear across /my-applications, /applications, and the dashboards without a manual refresh; flip it back to draft → they disappear again.
  • Delete an open position with applications as an admin → those applications vanish from /my-applications and /applications immediately (revalidation), and submitting a draft application against it returns "This position is no longer available."
  • Empty state: an applicant whose only application(s) are on the hidden positions sees the existing "No applications yet" / "You haven't started any applications yet" states, not a broken/blank list.

Automated checks

  • npm run prettier:check
  • npm run eslint:check
  • npm run tsc:check

Notes

  • The manager-branch position merge in buildBaseWhere, getApplicationForReview, and both status-update actions was the one real hazard flagged in the plan — overwriting instead of merging would have silently widened a manager's visibility to every position. Verified each branch spreads PUBLISHED_POSITION_WHERE alongside the existing managers: { some: { id: user.id } } } constraint rather than replacing it.
  • An applicant's draft application on a soft-deleted position becomes unreachable from the UI (hidden from the list, deleteDraftApplication no longer reachable) but stays in the database as an orphan row — acceptable and consistent with what soft-deleting the position means; restoring the position resurfaces it via the existing @@unique([userId, positionId]) constraint.
  • withdrawApplication, reopenApplication, and deleteDraftApplication were deliberately not touched — they're self-service on the caller's own record and unreachable from the UI once hidden; gating them would only produce confusing errors on rows the user can no longer see.
  • getPositionApplications / PositionApplicationsTable / PositionApplicationListItem currently have no consumer. They're constrained here for consistency rather than deleted; removing the dead triple is a separate follow-up.
  • No .env was available in this worktree (by design), so the DB-dependent testing steps above are left as checklist items for review rather than something I ran myself; npm run prettier:check / eslint:check / tsc:check all pass locally.

@b-at-neu b-at-neu self-assigned this Aug 11, 2026
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aplio Ready Ready Preview Aug 12, 2026 1:31pm

@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent labels Aug 11, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 1 · needs revision

2 open — 1 🔴 Critical, 1 ⚪ Nit

R1-C1 🔴 Critical — required Vercel deployment check is failing on this PR: https://vercel.com/sga-operational-affairs-projects/aplio/G3PhBwPL2ombQQnZLrNAMJEWpvqR. Fix: resolve the deploy failure before merge (lint/tsc/prettier all pass, so this is isolated to the Vercel build/deploy step).

R1-N1 ⚪ Nit — getApplicationForReview's doc comment (

// Authorization is folded into the where clause: admins see any application;
// managers only see applications for positions they manage. Unauthorized callers
// and soft-deleted records both return null, which the page converts to notFound().
) still says nulls come from "unauthorized callers and soft-deleted records" only — doesn't name the new draft/deleted-position exclusion this PR adds, unlike the sibling comment update on updateApplicationStatus. Fix: extend it to mention the position exclusion for consistency.

@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed reviewing Review agent working (in-flight) needs revision Review found issues that need fixing labels Aug 11, 2026
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 1

fixed R1-N1 · bddc23f

R1-C1 (Vercel deploy failure): investigated, not fixable by a code change in
this PR. Root cause: @neondatabase/auth's authApiHandler() (called at
module scope in app/api/auth/[...path]/route.ts) throws Missing environment variable: NEON_AUTH_BASE_URL during "Collecting page data" —
this reproduces identically building a clean origin/dev locally, and the
same Vercel failure is currently occurring on unrelated sibling PRs #411 and
#409 (both opened the same day), while earlier PRs #407/#408 deployed fine.
This points to NEON_AUTH_BASE_URL being missing/misconfigured in the
Vercel project's Preview environment variables (dashboard-side), not
anything in this PR's diff — vercel/npx vercel are pipeline-denied so I
couldn't inspect the deployment logs directly to confirm further. Pushing
this revision re-triggered a deploy, which failed with the same error,
consistent with an environment-config issue rather than a transient blip.
follow-up: fix/verify NEON_AUTH_BASE_URL in Vercel Preview env vars (likely
related to recent Neon Previews Integration work in #403/#249).

@b-at-neu b-at-neu added ready for review PR ready for review agent and removed revising Revise agent working (in-flight) labels Aug 11, 2026
@b-at-neu b-at-neu added reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent labels Aug 11, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 2 · needs revision

1 open — 1 🔴 Critical (see below)

R2-C1 🔴 Critical — required Vercel deployment check is still failing on this PR: https://vercel.com/sga-operational-affairs-projects/aplio/9FY3qwjKq7CUtYJzE53QZiM7Nfk7. Fix: resolve the deploy failure before merge (lint/tsc/prettier all pass; this remains isolated to the Vercel build/deploy step).

@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed reviewing Review agent working (in-flight) needs revision Review found issues that need fixing labels Aug 11, 2026
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Pipeline Escalation

Rebase: 348-exclude-soft-deleted-and-draft-positions onto origin/dev — aborted, no changes pushed.

Conflicted file: prisma/data/applications.ts (commit 6e632b87 — "#348 exclude soft-deleted and draft positions from queries" — the first commit in this PR's stack).

The ambiguous hunk

Inside getApplicationForReview, both sides touched the same lines that build the where clause:

Ours (origin/dev, current tip) — this function no longer builds its own where inline; a prior, unrelated dev commit refactored it to reuse the shared buildBaseWhere(user) helper (added specifically "so the list, the denominator and the detail page all agree on what a reviewer may see (drafts excluded, withdrawn included)"):

export async function getApplicationForReview(
  id: string,
  user: { id: string; isAdmin: boolean },
): Promise<ApplicationForReview | null> {
  const application = await prisma.application.findFirst({
    where: { id, ...buildBaseWhere(user) },
    ...

buildBaseWhere on dev (before this PR's changes) was:

function buildBaseWhere(user: { id: string; isAdmin: boolean }) {
  return user.isAdmin
    ? { deletedAt: null, status: { not: 'draft' as const } }
    : {
        deletedAt: null,
        status: { not: 'draft' as const },
        position: { managers: { some: { id: user.id } } },
      };
}

Theirs (PR commit 6e632b87) — written before that dev refactor landed, it still constructs where locally inside getApplicationForReview, adding the new PUBLISHED_POSITION_WHERE position filter to it:

export async function getApplicationForReview(
  id: string,
  user: { id: string; isAdmin: boolean },
): Promise<ApplicationForReview | null> {
  const where = user.isAdmin
    ? { id, deletedAt: null, position: PUBLISHED_POSITION_WHERE }
    : {
        id,
        deletedAt: null,
        position: {
          ...PUBLISHED_POSITION_WHERE,
          managers: { some: { id: user.id } },
        },
      };

  const application = await prisma.application.findFirst({
    where,
    ...

Note the local where in the original (pre-PR) getApplicationForReview had no status filter on the application at all — only deletedAt: null (+ manager scoping). This PR's commit preserved that (it only adds the position filter). It never touched status.

Why this isn't safe to auto-resolve

buildBaseWhere's own body merged cleanly (no conflict) — the PR's PUBLISHED_POSITION_WHERE addition to buildBaseWhere applied on top of dev's pre-existing managers scoping without overlap, so buildBaseWhere now correctly returns the same position-filtering this PR intends everywhere else in the file.

But mechanically dropping the conflicting local where block in getApplicationForReview and keeping dev's { id, ...buildBaseWhere(user) } call is not obviously equivalent: buildBaseWhere also carries status: { not: 'draft' as const }, a filter the original (and this PR's) getApplicationForReview never applied. Adopting dev's side would silently start excluding an applicant's own draft application from single-record review by ID — a behavior change neither this PR's diff nor its stated scope (issue #348, "soft-deleted and draft positions") asked for, and one this agent can't confirm is safe without knowing why the dev-side refactor consolidated onto buildBaseWhere in the first place (e.g. whether reviewers are ever expected to open a draft application directly, perhaps via a link, vs. only ever landing here from a list that already excludes drafts).

Per the rebase conflict protocol, this is "both sides modified the same function body… accepting one side would drop the other's [implicit] logic" — resolving it either way (keep the PR's original local where without status filtering, or take dev's buildBaseWhere and add the draft-application exclusion) requires the author's/reviewer's call on intended behavior, not a mechanical merge.

What's needed

Please advise (as a PR comment or by pushing a fixup) whether getApplicationForReview should:

  1. Keep excluding draft applications (adopt dev's buildBaseWhere(user) as-is, now that it also carries PUBLISHED_POSITION_WHERE), or
  2. Preserve the original no-status-filter behavior and only fold in PUBLISHED_POSITION_WHERE via { id, deletedAt: null, position: buildBaseWhere(user).position } (or similar) — status filtering is untouched.

Once decided, re-run the pipeline (label needs revision → the label state machine will re-trigger revise-agent) or push the resolved rebase directly.

@b-at-neu b-at-neu added needs human Pipeline escalation: 3 review cycles without convergence and removed revising Revise agent working (in-flight) labels Aug 12, 2026
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Feedback from human

Resolves the rebase-conflict escalation above. Decision: adopt dev's side.

getApplicationForReview should spread buildBaseWhere(user) (which already excludes status: 'draft' and scopes by admin/manager) merged together with this PR's new position soft-delete/draft filter, so both exclusions apply consistently — same pattern the list queries already use. Draft applications must not be visible to managers or admins anywhere, including this single-record detail lookup. Don't leave a second, divergent where-building path for this function.

@b-at-neu b-at-neu added needs revision Review found issues that need fixing and removed needs human Pipeline escalation: 3 review cycles without convergence labels Aug 12, 2026
@b-at-neu b-at-neu added revising Revise agent working (in-flight) and removed needs revision Review found issues that need fixing labels Aug 12, 2026
b-at-neu and others added 4 commits August 12, 2026 09:28
rule (deletedAt means "does not exist"; draft positions are hidden on
cross-position surfaces but stay visible on position-scoped ones) is
stated once, then apply them to every application/user query that
joins through Position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
submitApplication now rejects submitting against a since-deleted
position with the same copy createDraftApplication already uses.
updateApplicationStatus/updateApplicationStatuses merge the published-
position constraint into their existing authorization where (merged,
not overwritten, so manager scoping is preserved) so a stale id can't
mutate a hidden record. deletePosition/updatePosition revalidate the
cross-position surfaces whose contents they now change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The seed previously produced only open positions, which is why the
missing position exclusions went unnoticed. Add a draft position and
a soft-deleted position, each with a submitted application, plus a
draft application on the deleted position, so every affected surface
is regression-tested going forward.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extend getApplicationForReview's doc comment to name the
draft/deleted-position exclusion, matching updateApplicationStatus's
sibling comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@b-at-neu
b-at-neu force-pushed the 348-exclude-soft-deleted-and-draft-positions branch from bddc23f to d18476c Compare August 12, 2026 13:30
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 2

R2-C1 self-resolved: the flagged Vercel deploy (9FY3qwjKq7CUtYJzE53QZiM7Nfk7) was a transient build failure — a retry on the same pre-rebase commit already succeeded (ENDfjjHXSpo2qZ4QftyGixkYHHee) before this cycle, and the fresh deployment for the rebased commit (7Nbrn8KcGyxHZfJA3hrTMEqcwbND) also passed cleanly. No code change was required; lint/prettier/tsc/Vercel all green on d18476c.
rebase: prisma/data/applications.ts (dead where local var from the pre-rebase commit removed — its position-filter logic was already folded into buildBaseWhere, which dev's already-merged #349 uses directly; no logic dropped on either side)

@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed revising Revise agent working (in-flight) ready for review PR ready for review agent labels Aug 12, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 3 · approved

0 open — clean

@b-at-neu b-at-neu added approved Review passed, ready to merge and removed reviewing Review agent working (in-flight) labels Aug 12, 2026
@b-at-neu
b-at-neu merged commit c9ae6f1 into dev Aug 12, 2026
5 checks passed
@b-at-neu
b-at-neu deleted the 348-exclude-soft-deleted-and-draft-positions branch August 12, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Review passed, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Exclude Soft-Deleted and Draft Positions From Application Queries and Counts

1 participant