+ ))}
);
}
diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md
index c43a82ee..ca14b5c4 100644
--- a/docs/PERMISSIONS.md
+++ b/docs/PERMISSIONS.md
@@ -100,7 +100,7 @@ Four principals, each derived rather than stored as a single role field:
| `draft → closed`, `closed → draft` | no | A draft has never accepted applications — nothing to close. A closed position never returns to draft — reopen it instead |
| create as anything but `draft` | no | `createPosition` takes no status input — every position is born `draft` |
-- **Publishing is a permission, not a workflow.** There is no submit-for-approval step, no pending queue, no approve/reject with a reason, and no notification to the manager. A manager creates and shapes a draft; an admin performs the act of setting it `open`, from either `draft` or `closed`. Content edits after publishing stay unrestricted — approval gates the status change, not the position's fields.
+- **Publishing is a permission, not a workflow.** There is no submit-for-approval step, no pending queue, no approve/reject with a reason, and the only signal back to the manager is an activity-panel item (`docs/WORKFLOWS.md` XC-10). A manager creates and shapes a draft; an admin performs the act of setting it `open`, from either `draft` or `closed`. Content edits after publishing stay unrestricted — approval gates the status change, not the position's fields.
- **Reopening past `closesAt` is a silent no-op**, not a reopen — `getPositionAvailability` (`lib/utils.ts`) still returns `closed_by_date`, so the position reads Open and accepts nothing. Reject it: `{ error: "This position's close date has passed. Clear or extend the close date to reopen it." }`
- **Unpublishing hides a position out from under applicants who already have work in it**, so the first application — including a draft nobody has submitted — is one-way out of `open`: `{ error: 'Someone has already started an application, so this position cannot go back to draft. Close it instead.' }`
- **A draft was never listed, so it has nothing to close.** `draft → closed` is rejected with `{ error: 'A draft has never accepted applications, so there is nothing to close. Publish it first, or leave it as a draft.' }` — a manager who wants a draft off the board leaves it as a draft, or publishes and closes it instead.
@@ -138,6 +138,7 @@ Confirmations carry the risk the freezes don't.
- **Archived is derived, never stored.** `isPositionActive` (`lib/utils.ts`) is the single source of truth, fed by `positionActivitySelect` / `withPositionActivity` (`prisma/data/positions.ts`). A second implementation is an authorization bug, not a display bug.
- A position is archived once it is closed (`status: 'closed'`, or `open` past `closesAt`) for more than `MANAGED_POSITIONS_WINDOW_DAYS` **and** no application status has changed in that same window. Unresolved applications no longer pin a position active indefinitely — only recent activity does.
+- **"Activity" here means an `ApplicationStatusEvent` only.** `PositionStatusEvent` (the activity-panel "was opened"/"was reopened"/"was closed" item, `docs/WORKFLOWS.md` XC-10) is not read by `positionActivitySelect`/`withPositionActivity` and does not reset the archive window — reopening a position starts the clock over via its own `status`/`closesAt`, not via this event table.
- **There is no manual archive or unarchive.** A position leaves archive when an admin reopens it with a future close date, or — since a status change is itself activity — the moment a reviewer moves one of its applications, admin or not.
- Managers are denied with `ARCHIVED_POSITION_EDIT_ERROR` (`lib/constants.ts`), returned by the four position-field actions (`updatePositionTitle`, `updatePositionDescription`, `updatePositionSchedule`, `updatePositionStatus`), the three position-question actions, and `addPositionManager`/`removePositionManager`. Admins short-circuit the check inside `checkPositionEditable` (`prisma/data/positions.ts`).
- **The edit page does not 404.** It renders Details/Availability as read-only text, `PositionQuestionsReadonly`, and `PositionManagersReadonly` behind an explanatory callout, so a manager can still read what they can no longer change.
diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md
index 80d3c91a..fa2a9dd7 100644
--- a/docs/WORKFLOWS.md
+++ b/docs/WORKFLOWS.md
@@ -96,9 +96,9 @@ Three applicant-facing email _events_, over two rendered templates, all through
A `Sheet` reachable from every authenticated page, not just the dashboard — top-right of the sidebar's `h-14` header bar on desktop, and immediately left of the hamburger menu on mobile, so the control lands in the same visual position at both breakpoints. Hidden entirely for anonymous visitors. Opens from the right (`side="right"`), so it never reads as the same surface as `MobileNav`'s `side="left"` menu drawer.
-- **Composition** — everyone always gets **their own** non-draft applications on published positions, with public status only ([XC-8](#xc-8-applicant-facing-status-grouping)) — this covers a manager or admin who has also applied. Managers and admins additionally get a reviewer group: managed positions only for a manager, every published position for an admin, scoped the same way as [PM-8](#pm-8-work-the-application-queue)'s queue (`buildApplicationScopeWhere`). Both groups render when both apply.
-- **Self-filter** — a row in the reviewer group for the viewer's own application is dropped; it already appears in "Your applications", and without the filter a manager or admin who applied to a position they review would see their own application twice.
-- **Newest 10 per group** — each group is independently capped at 10, newest `submittedAt` first. The filter runs after the cap, so the reviewer group can show fewer than 10 when some of the newest rows are the viewer's own.
+- **Composition** — everyone always gets **their own** non-draft applications on published positions, with public status only ([XC-8](#xc-8-applicant-facing-status-grouping)) — this covers a manager or admin who has also applied. Managers and admins additionally get a reviewer group: managed positions only for a manager, every published position for an admin, scoped the same way as [PM-8](#pm-8-work-the-application-queue)'s queue (`buildApplicationScopeWhere`). A manager whose only position was just deleted still counts as having a reviewer group for this — `isManager` alone would say otherwise, since it only counts non-deleted positions — so `getActivityGroups` also unlocks it from a non-empty deletion row. The reviewer group also carries position status rows for positions in that same scope: "\ was opened" / "\ was reopened" / "\ was closed", sourced from `PositionStatusEvent` rows with `to: 'open'` or `to: 'closed'` (`getRecentPositionStatusEvents`), plus a derived "\ closed" row for an `open` position whose `closesAt` has passed with no status change — nothing runs on a schedule to write an event for that, so it's read straight off `closesAt` within `RECENTLY_CLOSED_WINDOW_DAYS` (`getRecentPositionDeadlineCloses`); a manually-closed position never also gets this derived row, since its stored status is no longer `open`. A position deleted while `open` or `closed` (not `draft`) also gets a row, "\ was deleted", read straight off `deletedAt` (`getRecentPositionDeletions`, `buildDeletedPositionWhere`). Once a row has appeared it survives the position's own later deletion: the opened/closed/deadline-close queries scope by `buildPositionHistoryWhere`, which — unlike `buildReviewablePositionWhere` — never excludes `deletedAt`, so a deleted position's earlier rows keep showing, just unlinked (a deadline that only lapses after deletion still produces nothing). The "was deleted" row and every surviving deleted-position row render **unlinked**, since the position page no longer exists; every other row links to `/positions/[id]`. Unlike applications, none of these position rows get a **self-filter** — the admin who opened, closed or deleted a position still sees it under "All positions". Both groups render when either has items.
+- **Self-filter** — a row in the reviewer group for the viewer's own **application** is dropped; it already appears in "Your applications", and without the filter a manager or admin who applied to a position they review would see their own application twice. Position status rows (opens, closes and deletions) are never self-filtered.
+- **Newest 10, merged** — "Your applications" is capped at 10, newest `submittedAt` first. The reviewer group merges applications and position status rows by timestamp (a deadline close's timestamp is its `closesAt`; a deletion's is its `deletedAt`) and keeps the newest 10 **across all kinds** — an active feed can show fewer than 10 applications to make room for a status row, or vice versa. The application self-filter runs before the merge, so the reviewer group can show fewer than 10 when some of the newest application rows are the viewer's own.
- **Group headings** — only for a viewer with a reviewer group at all. "Your applications" first, then "Positions you manage" (manager) or "All positions" (admin); a group with no items is omitted. A plain applicant (no reviewer group) sees one flat list with no heading at all.
- **Empty** — only when every visible group is empty: "No recent activity" plus a scope-specific description ("Updates to your applications will show up here." / "…and new applications to the positions you manage will show up here." / "…and new applications across all positions will show up here.").
- **Freshness** — the panel lives in the shared layout, so Next keeps it across client-side navigations. It refreshes on a hard load, `router.refresh()`, or any server action whose `revalidatePath`/`revalidateTag` covers the layout — not on a plain link navigation. There is no unread state, badge, or mark-as-read, so this cannot tell a manager that something changed without them opening it and reading it.
@@ -439,7 +439,7 @@ A user who manages at least one non-deleted position. Manager status is **derive
- **Trigger** — **Edit** on `/positions/[id]`, or a managed position card (`/manage/positions/[id]/edit`).
- **Happy path** — the page is a single `max-w-5xl` scroll, no tabs: one merged Details card (title, then opens/closes side by side, then description), Managers and Questions side by side, then the danger zone last. The page loads the position, then `requireListedManagerOr404` against the already-loaded managers list. Status is not a form field anywhere — it lives in the header as a badge (`PositionStatusBadge`, next to the title) plus a split-button/caret action control ([PM-3](#pm-3-create-a-position) covers create; the header actions below cover every later move). Title and description each autosave independently on blur — `updatePositionTitle`/`updatePositionDescription` — with inline **Saving…**/**Saved**/error text under the field instead of a toast (a toast per field per save would be spam); `opensAt`/`closesAt` autosave together as a pair through `updatePositionSchedule`, sharing one status line, since a single date can't be validated alone. Every autosave debounces to blur only — nothing fires mid-keystroke. Each date field shows a ghost **✕** control beside it whenever it holds a value, labeled to name its field ("Clear open date" / "Clear close date"); clicking it empties the field and commits immediately through `updatePositionSchedule`, the same as any other date edit — there is no staged, uncommitted clear. Each write revalidates the position, its detail page, the dashboard, `/applications` and `/manage/applications`, since a status flip elsewhere changes what every one of those surfaces shows.
- - **Status transitions (header actions).** The legal targets from the current status (`getPositionTransitionTargets`) render as a split button — first target primary, the rest behind a caret — always behind a confirm dialog, since a header button has none of the deliberateness a select-then-Save had. `draft` → **Open position** is always rendered (never hidden): an admin sees it enabled; a manager sees it disabled with a tooltip, "Only an admin can open this position." `open`, no applications → **Close position** + caret → **Return to draft**; with applications, **Close position** alone. `closed` → **Reopen position** is always rendered (never hidden), disabled with a tooltip when it isn't currently legal: a manager sees it disabled with "Only an admin can reopen this position."; an admin past the close date sees it disabled with "Clear or extend Closes At to reopen this position."; an admin with `closesAt` null or in the future sees it enabled. There is no **Return to draft** from `closed` — that move no longer exists. Archived → no header actions. Toasts: **"Position opened"** / **"Position closed"** / **"Position reopened"** / **"Position returned to draft"**.
+ - **Status transitions (header actions).** The legal targets from the current status (`getPositionTransitionTargets`) render as a split button — first target primary, the rest behind a caret — always behind a confirm dialog, since a header button has none of the deliberateness a select-then-Save had. `draft` → **Open position** is always rendered (never hidden): an admin sees it enabled; a manager sees it disabled with a tooltip, "Only an admin can open this position." `open`, no applications → **Close position** + caret → **Return to draft**; with applications, **Close position** alone. `closed` → **Reopen position** is always rendered (never hidden), disabled with a tooltip when it isn't currently legal: a manager sees it disabled with "Only an admin can reopen this position."; an admin past the close date sees it disabled with "Clear or extend Closes At to reopen this position."; an admin with `closesAt` null or in the future sees it enabled. There is no **Return to draft** from `closed` — that move no longer exists. Archived → no header actions. Toasts: **"Position opened"** / **"Position closed"** / **"Position reopened"** / **"Position returned to draft"**. `updatePositionStatus` re-reads the row inside a transaction, compare-and-swaps on the exact status just read, and — for every real transition — records a `PositionStatusEvent` in the same transaction; a same-status save writes none. A `to: 'open'` event is what feeds the managers' activity-panel item ([XC-10](#xc-10-activity-panel)); it is not application activity and does not affect the archive window below.
- **Live-edit warning.** An `open` or `closed` position — never `draft` — shows a full-width warning bar under the page title: "Changes made here are immediately visible in the live application."
- **Failure / edge**
- Position missing or soft-deleted → `notFound()`, checked **before** the access guard so both paths 404 identically.
@@ -450,6 +450,7 @@ A user who manages at least one non-deleted position. Manager status is **derive
- `closed → draft`, any caller (stale tab or hand-made request — no header control ever offers it, since `POSITION_STATUS_TRANSITIONS` has no entry for it, independent of application count or `closesAt`) → `{ error: 'A closed position cannot go back to draft. Reopen it instead, or leave it closed.' }`.
- `open → draft` once any non-deleted application exists (stale tab or a race with a concurrent first application) → `{ error: 'Someone has already started an application, so this position cannot go back to draft. Close it instead.' }`; **Return to draft** drops out of the header the moment an application exists.
- `closed → open` with `closesAt` still in the past (stale tab, or the manager didn't clear/extend it first) → `{ error: "This position's close date has passed. Clear or extend the close date to reopen it." }`; **Reopen position** stays visible but disabled, reading the position's stored `closesAt` — never a value from the request — with the same message in its tooltip.
+ - A concurrent save changed the position's status between load and submit (the compare-and-swap's `updateMany` matches zero rows, and a re-read shows a different status than the one this request started from) → `{ error: POSITION_STATUS_CHANGED_ERROR }`: **"This position just changed. Refresh to see its current status."**
- `opensAt` or `closesAt` **changed** to a date before today → the same past-date messages as [PM-3](#pm-3-create-a-position), checked against the position's own previous dates. An untouched past date on an already-open position saves normally — the rule only fires on a date the manager actually changed. A partially-typed date reads as empty to the form but the browser reports it as `badInput`; on blur that shows "This date is incomplete. Finish it, or use Clear to remove it." inline instead of committing, so a stray incomplete keystroke can no longer save as a silent clear — only a genuinely empty field clears. Clicking Clear on one date while the other is still incomplete reverts the click and shows "Not cleared — finish or clear the other date first." on the clicked field, since the pair only ever commits together. Clearing `closesAt` on an open position makes it accept applications indefinitely; clearing it on a closed position drops the `closesAtPast` block and re-enables **Reopen position** without a reload.
- An empty title on blur → "Title is required" inline; nothing saves, the header keeps showing the last-saved title.
- Deleted between render and an autosave → **"This position no longer exists."**, shown inline on the field, not a toast.
@@ -590,7 +591,7 @@ A user who manages at least one non-deleted position. Manager status is **derive
## Admin (AD)
-An admin is a **manager on every position**: every [Position manager](#position-manager-pm) workflow applies unchanged, with the scope widened from "positions I manage" to all of them (`buildReviewablePositionWhere`), and draft positions visible everywhere. Admins are exempt from the archived-position edit block ([PM-4](#pm-4-edit-position-details)) and from the self-removal rule ([PM-7](#pm-7-remove-a-manager)). Admins alone may set a position to `open`, from `draft` or `closed` ([PM-4](#pm-4-edit-position-details)) — publishing is a permission, not a workflow: no queue, no approve/reject, no notification back to the manager. This section covers only the admin-exclusive surfaces. The sidebar gains a **Settings** group with **Users**, **Global Questions** and **Email Log**, alongside the **Manage** group with **Manage Positions** and Applications a manager already sees ([PM intro](#position-manager-pm)). The activity panel's `all` scope follows the same widening — an admin's reviewer group covers every published position, alongside their own application activity ([XC-10](#xc-10-activity-panel)).
+An admin is a **manager on every position**: every [Position manager](#position-manager-pm) workflow applies unchanged, with the scope widened from "positions I manage" to all of them (`buildReviewablePositionWhere`), and draft positions visible everywhere. Admins are exempt from the archived-position edit block ([PM-4](#pm-4-edit-position-details)) and from the self-removal rule ([PM-7](#pm-7-remove-a-manager)). Admins alone may set a position to `open`, from `draft` or `closed` ([PM-4](#pm-4-edit-position-details)) — publishing is a permission, not a workflow: no queue, no approve/reject, and the only signal back to the manager is an activity-panel item ([XC-10](#xc-10-activity-panel)). This section covers only the admin-exclusive surfaces. The sidebar gains a **Settings** group with **Users**, **Global Questions** and **Email Log**, alongside the **Manage** group with **Manage Positions** and Applications a manager already sees ([PM intro](#position-manager-pm)). The activity panel's `all` scope follows the same widening — an admin's reviewer group covers every published position, alongside their own application activity ([XC-10](#xc-10-activity-panel)).
### AD-1 See every position
@@ -611,7 +612,7 @@ An admin is a **manager on every position**: every [Position manager](#position-
### AD-3 Delete a position
- **Trigger** — the Delete Position card at the bottom of `/manage/positions/[id]/edit`, rendered only for admins.
-- **Happy path** — the card states "Deleting hides this position everywhere — the positions list, search results and any direct link. This can't be undone from the app." The confirmation names the position and, when relevant, adds "N unsubmitted draft applications will disappear too." `deletePosition` soft-deletes with the blocking condition folded into the `where`, so the check and the write are one atomic statement. Toast **"Position deleted"**.
+- **Happy path** — the card states "Deleting hides this position everywhere — the positions list, search results and any direct link. This can't be undone from the app." The confirmation names the position and, when relevant, adds "N unsubmitted draft applications will disappear too." `deletePosition` soft-deletes with the blocking condition folded into the `where`, so the check and the write are one atomic statement. Toast **"Position deleted"**. Deleting a position that was `open` or `closed` (not `draft`) signals its managers with an unlinked "\ was deleted" activity-panel item, derived from `deletedAt`; its earlier opened/closed/deadline-close rows persist too, also unlinked ([XC-10](#xc-10-activity-panel)); a deleted draft produces no row.
- **Failure / edge**
- Any non-draft application exists → the button is disabled, and a stale tab that posts anyway gets `POSITION_DELETE_BLOCKED_ERROR`: **"This position has applications, so it can't be deleted. Close it instead."**
- Already deleted → **"This position no longer exists."**
diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts
index f72f9c30..93a69460 100644
--- a/lib/auth/scopes.ts
+++ b/lib/auth/scopes.ts
@@ -17,6 +17,25 @@ export function buildReviewablePositionWhere(
: { ...PUBLISHED_POSITION_WHERE, managers: { some: { id: user.id } } };
}
+// Same admin/manager split as buildReviewablePositionWhere, but for the deleted
+// rows PUBLISHED_POSITION_WHERE always excludes.
+export function buildDeletedPositionWhere(
+ user: Reviewer,
+): Prisma.PositionWhereInput {
+ const base = { deletedAt: { not: null }, status: { not: 'draft' } } as const;
+ return user.isAdmin ? base : { ...base, managers: { some: { id: user.id } } };
+}
+
+// Same admin/manager split, but omits buildReviewablePositionWhere's deletedAt:
+// null — for activity rows a position already produced, which must survive
+// its own later deletion.
+export function buildPositionHistoryWhere(
+ user: Reviewer,
+): Prisma.PositionWhereInput {
+ const base = { status: { not: 'draft' } } as const;
+ return user.isAdmin ? base : { ...base, managers: { some: { id: user.id } } };
+}
+
// `status` omitted so a caller's own filter can't overwrite the position scoping.
export function buildApplicationScopeWhere(
user: Reviewer,
diff --git a/lib/constants.ts b/lib/constants.ts
index 3d4158a3..053811e7 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -824,6 +824,9 @@ export const POSITION_CLOSED_DRAFT_BLOCKED_ERROR =
'A closed position cannot go back to draft. Reopen it instead, or leave it closed.';
export const POSITION_UNPUBLISH_BLOCKED_ERROR =
'Someone has already started an application, so this position cannot go back to draft. Close it instead.';
+// A concurrent save changed the position's status between load and submit.
+export const POSITION_STATUS_CHANGED_ERROR =
+ 'This position just changed. Refresh to see its current status.';
export const POSITION_REOPEN_PAST_CLOSE_ERROR =
"This position's close date has passed. Clear or extend the close date to reopen it.";
@@ -1005,6 +1008,10 @@ export const POSITION_STATUS_BADGE_VARIANT: Record<
BadgeVariant
> = { draft: 'secondary', open: 'default', closed: 'outline' };
+// No 'deleted' member on PositionStatus, so the deletion activity row's badge
+// isn't part of the map above.
+export const POSITION_DELETED_BADGE_VARIANT: BadgeVariant = 'destructive';
+
// Position-scoped surfaces (draft still shows its applications); PUBLISHED is for cross-position ones.
export const VISIBLE_POSITION_WHERE = {
deletedAt: null,
@@ -1140,6 +1147,27 @@ export const ACTIVITY_FEED_COPY: Record<
},
};
+// Activity panel copy for a position's "to: open" event, keyed by the status
+// it moved from — reopened (from closed) reads differently than opened.
+export const POSITION_ACTIVITY_SENTENCE: Record<
+ 'draft' | 'closed',
+ (title: string) => string
+> = {
+ draft: (title) => `${title} was opened`,
+ closed: (title) => `${title} was reopened`,
+};
+
+// "to: closed" event copy — always from 'open', the only legal transition in.
+export const POSITION_CLOSED_SENTENCE = (title: string) =>
+ `${title} was closed`;
+
+// Derived deadline-close copy (no event, so no "was" — nothing acted on it).
+export const POSITION_CLOSED_BY_DATE_SENTENCE = (title: string) =>
+ `${title} closed`;
+
+export const POSITION_DELETED_SENTENCE = (title: string) =>
+ `${title} was deleted`;
+
// Order is meaningful — rendered left to right on position cards.
export const POSITION_CARD_STAT_STATUSES = [
'applied',
diff --git a/lib/types.ts b/lib/types.ts
index 94c2addc..e247a3ef 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -413,11 +413,13 @@ export type QuestionFileDownload = {
};
// sentence is pre-rendered safe copy; statusVariant drives the dot color.
+// href is unset for a deleted position's rows — the page no longer exists.
export type ActivityItem = {
id: string;
statusVariant: BadgeVariant;
sentence: string;
timestamp: Date;
+ href?: string;
};
// 'none' (plain applicant), 'managed' (manages ≥1 position), 'all' (admin).
@@ -431,6 +433,32 @@ export type ActivityGroups = {
reviewed: ActivityItem[];
};
+// Matches getRecentPositionStatusEvents's select in prisma/data/positions.ts.
+// No actor identity selected — changedBy never reaches the activity panel.
+// position.deletedAt drives whether the row renders unlinked.
+export type PositionStatusActivity = Prisma.PositionStatusEventGetPayload<{
+ select: {
+ id: true;
+ from: true;
+ to: true;
+ createdAt: true;
+ position: { select: { id: true; title: true; deletedAt: true } };
+ };
+}>;
+
+// Matches getRecentPositionDeadlineCloses's select — no event backs this row,
+// so closesAt itself (guaranteed non-null by that query's where) is the
+// timestamp; deletedAt drives whether it renders unlinked.
+export type PositionDeadlineCloseActivity = Prisma.PositionGetPayload<{
+ select: { id: true; title: true; closesAt: true; deletedAt: true };
+}>;
+
+// Matches getRecentPositionDeletions's select — no event backs this row either,
+// so deletedAt (guaranteed non-null by that query's where) is the timestamp.
+export type PositionDeletionActivity = Prisma.PositionGetPayload<{
+ select: { id: true; title: true; deletedAt: true };
+}>;
+
// Exposes other users' identities — admin-gated contexts only, never a non-admin client.
export type AdminUserListItem = Prisma.UserGetPayload<{
select: {
diff --git a/prisma/actions/position-actions.ts b/prisma/actions/position-actions.ts
index 34526f0f..e2b714a0 100644
--- a/prisma/actions/position-actions.ts
+++ b/prisma/actions/position-actions.ts
@@ -22,6 +22,7 @@ import {
POSITION_OPENS_AT_ORDER_ERROR,
POSITION_OPENS_AT_PAST_ERROR,
POSITION_OPEN_REQUIRES_ADMIN_ERROR,
+ POSITION_STATUS_CHANGED_ERROR,
POSITION_STATUS_VALUES,
POSITION_UNPUBLISH_BLOCKED_ERROR,
createPositionFormSchema,
@@ -273,28 +274,45 @@ export async function updatePositionStatus(
);
if (transitionError) return { error: transitionError };
- // Folded into the where (not a separate count) so a concurrent first
- // application can't slip past the check above — same shape as deletePosition.
- const updateResult = await prisma.position.updateMany({
- where: {
- id,
- deletedAt: null,
- ...(isUnpublishing
- ? { applications: { none: { deletedAt: null } } }
- : {}),
- },
- data: { status, updatedById: user.id },
+ // status: existing.status is the CAS — a concurrent status change can't
+ // slip past, and can't double-write the event.
+ const updateCount = await prisma.$transaction(async (tx) => {
+ const updateResult = await tx.position.updateMany({
+ where: {
+ id,
+ deletedAt: null,
+ status: existing.status,
+ ...(isUnpublishing
+ ? { applications: { none: { deletedAt: null } } }
+ : {}),
+ },
+ data: { status, updatedById: user.id },
+ });
+
+ if (updateResult.count === 1 && existing.status !== status)
+ await tx.positionStatusEvent.create({
+ data: {
+ positionId: id,
+ from: existing.status,
+ to: status,
+ changedById: user.id,
+ },
+ });
+
+ return updateResult.count;
});
- if (updateResult.count === 0) {
- const stillExists = await prisma.position.findFirst({
+ if (updateCount === 0) {
+ const current = await prisma.position.findFirst({
where: { id, deletedAt: null },
- select: { id: true },
+ select: { status: true },
});
+ if (!current) return { error: 'This position no longer exists.' };
return {
- error: stillExists
- ? POSITION_UNPUBLISH_BLOCKED_ERROR
- : 'This position no longer exists.',
+ error:
+ current.status !== existing.status
+ ? POSITION_STATUS_CHANGED_ERROR
+ : POSITION_UNPUBLISH_BLOCKED_ERROR,
};
}
diff --git a/prisma/data/activity.ts b/prisma/data/activity.ts
index 36c83b88..b0770e7c 100644
--- a/prisma/data/activity.ts
+++ b/prisma/data/activity.ts
@@ -7,10 +7,21 @@ import {
getRecentApplications,
} from '@/prisma/data/applications';
import { isManager } from '@/prisma/data/managers';
+import {
+ getRecentPositionDeadlineCloses,
+ getRecentPositionDeletions,
+ getRecentPositionStatusEvents,
+} from '@/prisma/data/positions';
import {
APPLICATION_STATUS_BADGE_VARIANT,
APPLICATION_STATUS_LABELS,
+ POSITION_ACTIVITY_SENTENCE,
+ POSITION_CLOSED_BY_DATE_SENTENCE,
+ POSITION_CLOSED_SENTENCE,
+ POSITION_DELETED_BADGE_VARIANT,
+ POSITION_DELETED_SENTENCE,
+ POSITION_STATUS_BADGE_VARIANT,
} from '@/lib/constants';
import { type ActivityGroups, type ActivityItem } from '@/lib/types';
import { getDisplayName, getRenamedTo } from '@/lib/utils';
@@ -22,17 +33,30 @@ export const getActivityGroups = cache(async function getActivityGroups(
userId: string,
isAdmin: boolean,
): Promise {
+ // Fetched unconditionally: deleting a manager's only position drops it from
+ // isManager's non-deleted count, so this alone must still unlock 'managed'.
+ const [applications, isUserManager, deletions] = await Promise.all([
+ getMyRecentActivity(userId, ACTIVITY_TAKE),
+ isAdmin ? Promise.resolve(true) : isManager(userId),
+ getRecentPositionDeletions({ id: userId, isAdmin }, ACTIVITY_TAKE),
+ ]);
+
const scope = isAdmin
? 'all'
- : (await isManager(userId))
+ : isUserManager || deletions.length > 0
? 'managed'
: 'none';
- const [applications, reviewed] = await Promise.all([
- getMyRecentActivity(userId, ACTIVITY_TAKE),
+ const [reviewed, statusEvents, deadlineCloses] = await Promise.all([
scope !== 'none'
? getRecentApplications({ id: userId, isAdmin }, ACTIVITY_TAKE)
: Promise.resolve([]),
+ scope !== 'none'
+ ? getRecentPositionStatusEvents({ id: userId, isAdmin }, ACTIVITY_TAKE)
+ : Promise.resolve([]),
+ scope !== 'none'
+ ? getRecentPositionDeadlineCloses({ id: userId, isAdmin }, ACTIVITY_TAKE)
+ : Promise.resolve([]),
]);
const mine: ActivityItem[] = applications.map((app) => {
@@ -46,7 +70,7 @@ export const getActivityGroups = cache(async function getActivityGroups(
};
});
- const reviewedItems: ActivityItem[] = reviewed
+ const applicationItems: ActivityItem[] = reviewed
.filter((app) => app.user.id !== userId)
.map((app) => {
const applicantLabel = getDisplayName(app);
@@ -60,5 +84,76 @@ export const getActivityGroups = cache(async function getActivityGroups(
};
});
+ // to: 'open' means from can never be 'open' — narrow with a guard, not a cast.
+ const openingItems: ActivityItem[] = statusEvents
+ .filter(
+ (
+ event,
+ ): event is typeof event & { to: 'open'; from: 'draft' | 'closed' } =>
+ event.to === 'open',
+ )
+ .map((event) => ({
+ id: event.id,
+ statusVariant: POSITION_STATUS_BADGE_VARIANT.open,
+ sentence: POSITION_ACTIVITY_SENTENCE[event.from](event.position.title),
+ timestamp: event.createdAt,
+ href:
+ event.position.deletedAt === null
+ ? `/positions/${event.position.id}`
+ : undefined,
+ }));
+
+ const closedItems: ActivityItem[] = statusEvents
+ .filter((event) => event.to === 'closed')
+ .map((event) => ({
+ id: event.id,
+ statusVariant: POSITION_STATUS_BADGE_VARIANT.closed,
+ sentence: POSITION_CLOSED_SENTENCE(event.position.title),
+ timestamp: event.createdAt,
+ href:
+ event.position.deletedAt === null
+ ? `/positions/${event.position.id}`
+ : undefined,
+ }));
+
+ // closesAt is guaranteed non-null by the query's where — narrow, not cast.
+ const deadlineCloseItems: ActivityItem[] = deadlineCloses
+ .filter(
+ (position): position is typeof position & { closesAt: Date } =>
+ position.closesAt !== null,
+ )
+ .map((position) => ({
+ id: `deadline-close-${position.id}`,
+ statusVariant: POSITION_STATUS_BADGE_VARIANT.closed,
+ sentence: POSITION_CLOSED_BY_DATE_SENTENCE(position.title),
+ timestamp: position.closesAt,
+ href:
+ position.deletedAt === null ? `/positions/${position.id}` : undefined,
+ }));
+
+ // deletedAt is guaranteed non-null by the query's where — narrow, not cast.
+ // Not linked — the position page no longer exists.
+ const deletionItems: ActivityItem[] = deletions
+ .filter(
+ (position): position is typeof position & { deletedAt: Date } =>
+ position.deletedAt !== null,
+ )
+ .map((position) => ({
+ id: `deletion-${position.id}`,
+ statusVariant: POSITION_DELETED_BADGE_VARIANT,
+ sentence: POSITION_DELETED_SENTENCE(position.title),
+ timestamp: position.deletedAt,
+ }));
+
+ const reviewedItems = [
+ ...applicationItems,
+ ...openingItems,
+ ...closedItems,
+ ...deadlineCloseItems,
+ ...deletionItems,
+ ]
+ .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
+ .slice(0, ACTIVITY_TAKE);
+
return { scope, mine, reviewed: reviewedItems };
});
diff --git a/prisma/data/positions.ts b/prisma/data/positions.ts
index 9b484a3f..4f9965f5 100644
--- a/prisma/data/positions.ts
+++ b/prisma/data/positions.ts
@@ -2,6 +2,10 @@ import 'server-only';
import { cache } from 'react';
+import {
+ buildDeletedPositionWhere,
+ buildPositionHistoryWhere,
+} from '@/lib/auth/scopes';
import {
MANAGED_POSITIONS_WINDOW_DAYS,
NON_REVIEWABLE_APPLICATION_STATUSES,
@@ -14,10 +18,14 @@ import {
type ManagedPosition,
type ManagedPositionSummaryItem,
type OpenPositionSummaryItem,
+ type PositionDeadlineCloseActivity,
+ type PositionDeletionActivity,
type PositionDeletionSummary,
type PositionDetail,
type PositionForEdit,
+ type PositionStatusActivity,
type PositionWithQuestions,
+ type Reviewer,
} from '@/lib/types';
import {
getPositionAvailability,
@@ -378,3 +386,67 @@ export async function getPositionDeletionSummary(
return { submittedCount, draftCount };
}
+
+// buildPositionHistoryWhere, not buildReviewablePositionWhere, so opened/closed
+// rows outlive the position's own later deletion.
+export async function getRecentPositionStatusEvents(
+ reviewer: Reviewer,
+ take: number,
+): Promise {
+ return prisma.positionStatusEvent.findMany({
+ where: {
+ to: { in: ['open', 'closed'] },
+ position: buildPositionHistoryWhere(reviewer),
+ },
+ select: {
+ id: true,
+ from: true,
+ to: true,
+ createdAt: true,
+ position: { select: { id: true, title: true, deletedAt: true } },
+ },
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
+ take,
+ });
+}
+
+// No event backs a deadline lapse, so this derives from closesAt; status: 'open'
+// excludes a manually-closed position from double-reporting.
+export async function getRecentPositionDeadlineCloses(
+ reviewer: Reviewer,
+ take: number,
+): Promise {
+ const now = new Date();
+ const cutoff = new Date(now);
+ cutoff.setDate(cutoff.getDate() - RECENTLY_CLOSED_WINDOW_DAYS);
+
+ const positions = await prisma.position.findMany({
+ where: {
+ ...buildPositionHistoryWhere(reviewer),
+ status: 'open',
+ closesAt: { gte: cutoff, lte: now },
+ },
+ select: { id: true, title: true, closesAt: true, deletedAt: true },
+ orderBy: [{ closesAt: 'desc' }, { id: 'desc' }],
+ });
+
+ // Excludes a deadline lapsing only after deletion — Prisma can't compare two
+ // columns of one row in a where, so this runs post-fetch.
+ return positions
+ .filter((p) => p.deletedAt === null || p.closesAt! <= p.deletedAt)
+ .slice(0, take);
+}
+
+// buildDeletedPositionWhere, not buildReviewablePositionWhere, since these
+// rows are excluded by the latter's deletedAt: null.
+export async function getRecentPositionDeletions(
+ reviewer: Reviewer,
+ take: number,
+): Promise {
+ return prisma.position.findMany({
+ where: buildDeletedPositionWhere(reviewer),
+ select: { id: true, title: true, deletedAt: true },
+ orderBy: [{ deletedAt: 'desc' }, { id: 'desc' }],
+ take,
+ });
+}
diff --git a/prisma/migrations/20260924221554_add_position_status_event/migration.sql b/prisma/migrations/20260924221554_add_position_status_event/migration.sql
new file mode 100644
index 00000000..d7ce7a5c
--- /dev/null
+++ b/prisma/migrations/20260924221554_add_position_status_event/migration.sql
@@ -0,0 +1,23 @@
+-- CreateTable
+CREATE TABLE "PositionStatusEvent" (
+ "id" TEXT NOT NULL,
+ "positionId" TEXT NOT NULL,
+ "from" "PositionStatus" NOT NULL,
+ "to" "PositionStatus" NOT NULL,
+ "changedById" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PositionStatusEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "PositionStatusEvent_positionId_createdAt_idx" ON "PositionStatusEvent"("positionId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "PositionStatusEvent_to_createdAt_idx" ON "PositionStatusEvent"("to", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "PositionStatusEvent" ADD CONSTRAINT "PositionStatusEvent_positionId_fkey" FOREIGN KEY ("positionId") REFERENCES "Position"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PositionStatusEvent" ADD CONSTRAINT "PositionStatusEvent_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d8f89a88..3a35b5ca 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -111,6 +111,7 @@ model User {
updatedPositionAppAnswers PositionApplicationAnswer[] @relation("PositionApplicationAnswerUpdatedBy")
deletedPositionAppAnswers PositionApplicationAnswer[] @relation("PositionApplicationAnswerDeletedBy")
applicationStatusEvents ApplicationStatusEvent[] @relation("ApplicationStatusEventChangedBy")
+ positionStatusEvents PositionStatusEvent[] @relation("PositionStatusEventChangedBy")
}
model Position {
@@ -131,9 +132,10 @@ model Position {
updatedBy User @relation("PositionUpdatedBy", fields: [updatedById], references: [id])
deletedBy User? @relation("PositionDeletedBy", fields: [deletedById], references: [id])
- managers User[] @relation("PositionManagers")
+ managers User[] @relation("PositionManagers")
questions PositionQuestion[]
applications Application[]
+ statusEvents PositionStatusEvent[]
}
model GlobalQuestion {
@@ -250,6 +252,21 @@ model ApplicationStatusEvent {
@@index([applicationId, createdAt])
}
+model PositionStatusEvent {
+ id String @id @default(uuid(7))
+ positionId String
+ from PositionStatus
+ to PositionStatus
+ changedById String
+ createdAt DateTime @default(now())
+
+ position Position @relation(fields: [positionId], references: [id])
+ changedBy User @relation("PositionStatusEventChangedBy", fields: [changedById], references: [id])
+
+ @@index([positionId, createdAt])
+ @@index([to, createdAt])
+}
+
model GlobalApplicationAnswer {
id String @id @default(uuid(7))
applicationId String
diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts
index b14ab948..75b5b97c 100644
--- a/tests/db/authorization.test.ts
+++ b/tests/db/authorization.test.ts
@@ -20,7 +20,11 @@ import {
deleteGlobalQuestion,
updateGlobalQuestion,
} from '@/prisma/actions/global-questions';
-import { searchUsers } from '@/prisma/actions/position-actions';
+import {
+ deletePosition,
+ searchUsers,
+ updatePositionStatus,
+} from '@/prisma/actions/position-actions';
import {
createPositionQuestion,
deletePositionQuestion,
@@ -932,4 +936,392 @@ describe('getActivityGroups composition and scoping', () => {
expect(reviewedIds).not.toContain(onDraftPosition.id);
expect(reviewedIds).not.toContain(onDeletedPosition.id);
});
+
+ it('admin opens a draft: the listed manager sees a "was opened" item linking to the position', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'draft',
+ });
+
+ actAs(admin);
+ const result = await updatePositionStatus({
+ id: position.id,
+ status: 'open',
+ });
+ expect(result).toBeUndefined();
+
+ const groups = await getActivityGroups(manager.id, false);
+ expect(groups.scope).toBe('managed');
+ const item = groups.reviewed.find((i) =>
+ i.sentence.includes(position.title),
+ );
+ expect(item).toBeDefined();
+ expect(item?.sentence).toBe(`${position.title} was opened`);
+ expect(item?.href).toBe(`/positions/${position.id}`);
+ });
+
+ it('a manager of a different position does not see the opening', async () => {
+ const manager = await createTestUser();
+ const otherManager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'draft',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+
+ const groups = await getActivityGroups(otherManager.id, false);
+ const ids = groups.reviewed.map((i) => i.sentence);
+ expect(ids).not.toContain(`${position.title} was opened`);
+ });
+
+ it('an applicant on the position (not a manager) never gets a reviewer group', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'draft',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+
+ const positionApplicant = await createTestUser();
+ await createTestApplication(positionApplicant, position, {
+ status: 'applied',
+ });
+
+ const groups = await getActivityGroups(positionApplicant.id, false);
+ expect(groups.scope).toBe('none');
+ expect(groups.reviewed).toEqual([]);
+ });
+
+ it('an admin sees the opening under "all" with no self-filter', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'draft',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+
+ const groups = await getActivityGroups(admin.id, true);
+ expect(groups.scope).toBe('all');
+ const sentences = groups.reviewed.map((i) => i.sentence);
+ expect(sentences).toContain(`${position.title} was opened`);
+ });
+
+ it('reopening (open -> closed -> open) shows both "was closed" and "was reopened"', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'closed' });
+ await updatePositionStatus({ id: position.id, status: 'open' });
+
+ const groups = await getActivityGroups(manager.id, false);
+ const sentences = groups.reviewed.map((i) => i.sentence);
+ expect(sentences).toContain(`${position.title} was reopened`);
+ expect(sentences).toContain(`${position.title} was closed`);
+ });
+
+ it('manual close: the listed manager sees a "was closed" item linking to the position', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ });
+
+ actAs(admin);
+ const result = await updatePositionStatus({
+ id: position.id,
+ status: 'closed',
+ });
+ expect(result).toBeUndefined();
+
+ const groups = await getActivityGroups(manager.id, false);
+ const item = groups.reviewed.find((i) =>
+ i.sentence.includes(position.title),
+ );
+ expect(item).toBeDefined();
+ expect(item?.sentence).toBe(`${position.title} was closed`);
+ expect(item?.href).toBe(`/positions/${position.id}`);
+ });
+
+ it('a manager of a different position does not see the close', async () => {
+ const manager = await createTestUser();
+ const otherManager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'closed' });
+
+ const groups = await getActivityGroups(otherManager.id, false);
+ const sentences = groups.reviewed.map((i) => i.sentence);
+ expect(sentences).not.toContain(`${position.title} was closed`);
+ });
+
+ it('an applicant on the position never sees the close', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ });
+ const positionApplicant = await createTestUser();
+ await createTestApplication(positionApplicant, position, {
+ status: 'applied',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'closed' });
+
+ const groups = await getActivityGroups(positionApplicant.id, false);
+ expect(groups.scope).toBe('none');
+ expect(groups.reviewed).toEqual([]);
+ });
+
+ it('deadline close: an open position past closesAt shows a derived "closed" item with no "was"', async () => {
+ const manager = await createTestUser();
+ const past = new Date(Date.now() - 60 * 60 * 1000);
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ closesAt: past,
+ });
+
+ const groups = await getActivityGroups(manager.id, false);
+ const item = groups.reviewed.find((i) =>
+ i.sentence.includes(position.title),
+ );
+ expect(item).toBeDefined();
+ expect(item?.sentence).toBe(`${position.title} closed`);
+ expect(item?.href).toBe(`/positions/${position.id}`);
+ });
+
+ it('deadline close: scoped like other rows — a different manager and an applicant never see it', async () => {
+ const manager = await createTestUser();
+ const otherManager = await createTestUser();
+ const past = new Date(Date.now() - 60 * 60 * 1000);
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ closesAt: past,
+ });
+ const positionApplicant = await createTestUser();
+ await createTestApplication(positionApplicant, position, {
+ status: 'applied',
+ });
+
+ const otherGroups = await getActivityGroups(otherManager.id, false);
+ expect(otherGroups.reviewed.map((i) => i.sentence)).not.toContain(
+ `${position.title} closed`,
+ );
+
+ const applicantGroups = await getActivityGroups(
+ positionApplicant.id,
+ false,
+ );
+ expect(applicantGroups.scope).toBe('none');
+ expect(applicantGroups.reviewed).toEqual([]);
+ });
+
+ it('no duplicates: a manually-closed position never also gets the derived deadline-close row', async () => {
+ const manager = await createTestUser();
+ const past = new Date(Date.now() - 60 * 60 * 1000);
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ closesAt: past,
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'closed' });
+
+ const groups = await getActivityGroups(manager.id, false);
+ const matching = groups.reviewed.filter((i) =>
+ i.sentence.includes(position.title),
+ );
+ expect(matching).toHaveLength(1);
+ expect(matching[0]?.sentence).toBe(`${position.title} was closed`);
+ });
+
+ it('returning to draft after opening drops the item for the manager', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'draft',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+ await updatePositionStatus({ id: position.id, status: 'draft' });
+
+ const groups = await getActivityGroups(manager.id, false);
+ const sentences = groups.reviewed.map((i) => i.sentence);
+ expect(sentences).not.toContain(`${position.title} was opened`);
+ });
+
+ it('deletion of a published position: the listed manager sees an unlinked "was deleted" item', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ });
+
+ actAs(admin);
+ const result = await deletePosition({ id: position.id });
+ expect(result).toBeUndefined();
+
+ const groups = await getActivityGroups(manager.id, false);
+ expect(groups.scope).toBe('managed');
+ const item = groups.reviewed.find((i) =>
+ i.sentence.includes(position.title),
+ );
+ expect(item).toBeDefined();
+ expect(item?.sentence).toBe(`${position.title} was deleted`);
+ expect(item?.href).toBeUndefined();
+ });
+
+ it('deletion of a draft position produces no row', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'draft',
+ });
+
+ actAs(admin);
+ await deletePosition({ id: position.id });
+
+ const groups = await getActivityGroups(manager.id, false);
+ const sentences = groups.reviewed.map((i) => i.sentence);
+ expect(sentences).not.toContain(`${position.title} was deleted`);
+ });
+
+ it('deletion: scoped like other rows — a different manager and an applicant never see it', async () => {
+ const manager = await createTestUser();
+ const otherManager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'closed',
+ });
+ const positionApplicant = await createTestUser();
+ await createTestApplication(positionApplicant, position, {
+ status: 'draft',
+ });
+
+ actAs(admin);
+ const deleteResult = await deletePosition({ id: position.id });
+ expect(deleteResult).toBeUndefined();
+
+ const otherGroups = await getActivityGroups(otherManager.id, false);
+ expect(otherGroups.reviewed.map((i) => i.sentence)).not.toContain(
+ `${position.title} was deleted`,
+ );
+
+ const applicantGroups = await getActivityGroups(
+ positionApplicant.id,
+ false,
+ );
+ expect(applicantGroups.reviewed.map((i) => i.sentence)).not.toContain(
+ `${position.title} was deleted`,
+ );
+ });
+
+ it('deletion: an admin sees it under "all", and the earlier opened/closed rows persist unlinked', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'draft',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+ await updatePositionStatus({ id: position.id, status: 'closed' });
+ await deletePosition({ id: position.id });
+
+ const groups = await getActivityGroups(admin.id, true);
+ expect(groups.scope).toBe('all');
+ const opened = groups.reviewed.find(
+ (i) => i.sentence === `${position.title} was opened`,
+ );
+ const closed = groups.reviewed.find(
+ (i) => i.sentence === `${position.title} was closed`,
+ );
+ const deleted = groups.reviewed.find(
+ (i) => i.sentence === `${position.title} was deleted`,
+ );
+ expect(opened).toBeDefined();
+ expect(opened?.href).toBeUndefined();
+ expect(closed).toBeDefined();
+ expect(closed?.href).toBeUndefined();
+ expect(deleted).toBeDefined();
+ });
+
+ it('deletion: a deadline-close row already produced before deletion persists unlinked', async () => {
+ const manager = await createTestUser();
+ const past = new Date(Date.now() - 60 * 60 * 1000);
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ closesAt: past,
+ });
+
+ actAs(admin);
+ await deletePosition({ id: position.id });
+
+ const groups = await getActivityGroups(manager.id, false);
+ const item = groups.reviewed.find(
+ (i) => i.sentence === `${position.title} closed`,
+ );
+ expect(item).toBeDefined();
+ expect(item?.href).toBeUndefined();
+ });
+
+ it('deletion: a deadline lapsing only after deletion produces no row', async () => {
+ const manager = await createTestUser();
+ const deletedAt = new Date(Date.now() - 2 * 60 * 60 * 1000);
+ const closesAt = new Date(Date.now() - 60 * 60 * 1000);
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'open',
+ closesAt,
+ deletedAt,
+ deletedById: admin.id,
+ });
+
+ const groups = await getActivityGroups(manager.id, false);
+ const sentences = groups.reviewed.map((i) => i.sentence);
+ expect(sentences).not.toContain(`${position.title} closed`);
+ });
+
+ it('merges openings with applications by time and caps the combined feed at 10', async () => {
+ const manager = await createTestUser();
+ const position = await createTestPosition(admin, {
+ managers: [manager],
+ status: 'draft',
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+
+ const newerApplicant = await createTestUser();
+ const newerApplication = await createTestApplication(
+ newerApplicant,
+ position,
+ { status: 'applied' },
+ );
+
+ const groups = await getActivityGroups(manager.id, false);
+ expect(groups.reviewed.length).toBeLessThanOrEqual(10);
+ const newestId = groups.reviewed[0]?.id;
+ expect(newestId).toBe(newerApplication.id);
+ });
});
diff --git a/tests/db/position-transitions.test.ts b/tests/db/position-transitions.test.ts
index 68ab3daa..4feb1b78 100644
--- a/tests/db/position-transitions.test.ts
+++ b/tests/db/position-transitions.test.ts
@@ -51,6 +51,13 @@ async function status(id: string): Promise {
return row.status;
}
+async function events(positionId: string) {
+ return prisma.positionStatusEvent.findMany({
+ where: { positionId },
+ orderBy: { createdAt: 'asc' },
+ });
+}
+
beforeAll(async () => {
admin = await createTestUser({ isAdmin: true });
manager = await createTestUser();
@@ -197,5 +204,76 @@ describe('unchanged status', () => {
});
expect(result).toBeUndefined();
expect(await status(position.id)).toBe('open');
+
+ expect(await events(position.id)).toEqual([]);
+ });
+});
+
+describe('PositionStatusEvent writes', () => {
+ it('writes exactly one event with the right from/to/changedById for each legal transition', async () => {
+ const position = await makePosition({ status: 'draft' });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+ let rows = await events(position.id);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ from: 'draft',
+ to: 'open',
+ changedById: admin.id,
+ });
+
+ actAs(manager);
+ await updatePositionStatus({ id: position.id, status: 'closed' });
+ rows = await events(position.id);
+ expect(rows).toHaveLength(2);
+ expect(rows[1]).toMatchObject({
+ from: 'open',
+ to: 'closed',
+ changedById: manager.id,
+ });
+
+ actAs(admin);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+ rows = await events(position.id);
+ expect(rows).toHaveLength(3);
+ expect(rows[2]).toMatchObject({
+ from: 'closed',
+ to: 'open',
+ changedById: admin.id,
+ });
+
+ actAs(manager);
+ await updatePositionStatus({ id: position.id, status: 'draft' });
+ rows = await events(position.id);
+ expect(rows).toHaveLength(4);
+ expect(rows[3]).toMatchObject({
+ from: 'open',
+ to: 'draft',
+ changedById: manager.id,
+ });
+ });
+
+ it('writes no event on a same-status save', async () => {
+ const position = await makePosition({ status: 'open' });
+ await createTestApplication(applicant, position, { status: 'applied' });
+
+ actAs(manager);
+ await updatePositionStatus({ id: position.id, status: 'open' });
+
+ expect(await events(position.id)).toEqual([]);
+ });
+
+ it('writes no event on a blocked transition', async () => {
+ const position = await makePosition({ status: 'draft' });
+
+ actAs(manager);
+ const result = await updatePositionStatus({
+ id: position.id,
+ status: 'closed',
+ });
+ expect(result).toEqual({ error: POSITION_DRAFT_CLOSE_BLOCKED_ERROR });
+
+ expect(await events(position.id)).toEqual([]);
});
});
diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts
index 7ad6c482..d339effa 100644
--- a/tests/helpers/fixtures.ts
+++ b/tests/helpers/fixtures.ts
@@ -189,6 +189,10 @@ export async function cleanupFixtures(): Promise {
},
});
+ await prisma.positionStatusEvent.deleteMany({
+ where: { OR: [{ changedBy: testUser }, { position: testPosition }] },
+ });
+
await prisma.application.deleteMany({
where: { OR: [{ user: testUser }, { position: testPosition }] },
});