You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When an admin opens a draft position, that position's managers should see it in the Recent activity panel (#617 / PR #771). For example: "Engineering Director is now open for applications". Only admins can open a position (#655), so today a manager has no in-app signal that their draft went live.
Blocked by #617: this builds on the activity panel introduced in PR #771.
Position status changes aren't recorded.ApplicationStatusEvent exists for applications, but there is no equivalent history for positions. The only change on open is Position.status. The plan needs to decide how to source this, for example:
a new position status event table written by the status-change action;
an openedAt timestamp on Position;
deriving it from existing fields, if that's sound.
Prefer an approach that could later cover close and reopen events too. Only surface open in this ticket. The scope amendment below also surfaces closes.
Scope amendment (operator decision, during PR #776 review)
Position closes are now in scope as well as opens:
Manual close: when a position's status changes to closed, show "<title> was closed" (from PositionStatusEvent).
Deadline close: when an open position's closesAt passes, show "<title> closed". This is derived in the activity query from closesAt, with no stored event and no cron.
Same scoping, merging, 10-item cap and linking as the "opened" rows. No duplicate deadline-close row for a position that was closed manually.
Tests cover both close kinds, the scoping, and the no-duplicate rule.
Second amendment: deletions. When an admin deletes a published position (its status was open or closed at deletion, not draft), show "<title> was deleted".
Derive it from deletedAt, the soft-delete stamp. No new event and no schema change, unless deriving it proves unsound.
The row is not linked, because the page no longer exists.
Scoping matches the other rows: the position's listed managers, plus admins under "All positions". Because the query normally filters out deleted positions, this needs a scoped lookup that includes them.
Deleted drafts produce no row.
The position's earlier opened and closed rows drop out after deletion.Operator decision: once an activity row has appeared it is never removed by deletion.
The position's earlier opened and closed rows stay after it's deleted, unlinked, with the position's title as it was.
This includes deadline-close rows the position had already produced.
Rows still age out through the normal recency window and 10-item cap.
Tests cover the published-vs-draft rule, the scoping, and the unlinked row.
Scope
When a position moves to open from draft (and from closed, if an admin reopens it), each listed manager of that position gets an activity item in their reviewer group, "Positions you manage".
The item includes the position title and a relative time, and links to the position.
The admin who opened it doesn't need a separate "you opened" item unless it falls out naturally.
Items merge into the existing group, sorted by time. The 10-item limit stays as it is.
Update docs/WORKFLOWS.md (XC-10 and the position-status flow) and docs/PERMISSIONS.md if visibility rules change.
Acceptance criteria
An admin opens a draft position with manager M. M's activity panel shows the "now open" item.
A manager of a different position doesn't see it. An applicant doesn't see it.
Admins see it under "All positions".
Reopening a closed position (admin) shows the same item.
tests/db/ coverage for the scoping cases above, next to the existing getActivityGroups tests.
Any schema change comes with a migration via npm run prisma:migrate.
Implementation Plan
Overview
Record every position status change in a new PositionStatusEvent table, written in the same transaction as the status update (mirrors ApplicationStatusEvent). The activity panel's reviewer group reads the to: 'open' events, scoped exactly like the existing application rows. They merge by time and are capped at 10. Close and return-to-draft events are recorded too, but not shown.
Base: plan against branch 617-move-recent-activity-into-a-global-activity-panel (PR #771). Rebase onto dev once #771 merges. prisma/data/activity.ts, ActivityItem/ActivityGroups and components/features/activity-feed.tsx only exist there.
Why an event table, not openedAt or deriving it:openedAt only keeps the latest open, so it can't later cover close or reopen history. Deriving it isn't sound: updatedAt changes on every title, description or schedule edit, and opensAt is the applicant window, not the publish moment.
Changes
prisma/schema.prisma: new PositionStatusEvent model, plus back-relations on Position (statusEvents) and User ("PositionStatusEventChangedBy").
prisma/migrations/<ts>_add_position_status_event/: generated with npm run prisma:migrate -- --name add_position_status_event. No backfill. Positions that are already open have no truthful opened-at time, so they don't produce an item.
prisma/actions/position-actions.ts: updatePositionStatus. Wrap the write in prisma.$transaction, add a compare-and-swap on status, and create the event when from !== to.
lib/constants.ts:
POSITION_STATUS_CHANGED_ERROR, the new user-facing copy.
POSITION_ACTIVITY_SENTENCE: Record<'draft' | 'closed', (title: string) => string>, the copy keyed by from.
prisma/data/positions.ts: new getRecentPositionOpenings(reviewer: Reviewer, take: number).
lib/types.ts:
ActivityItem gets an optional href?: string.
New PositionOpeningActivity payload type, for the query's return.
prisma/data/activity.ts: getActivityGroups fetches openings alongside applications for managed/all scopes, then merges, sorts and caps them.
components/features/activity-feed.tsx: ActivityFeedList renders a row as a link that closes the Sheet when item.href is set.
tests/helpers/fixtures.ts: cleanupFixtures deletes positionStatusEvent rows for test positions and users before position.deleteMany, because of the FK.
tests/db/authorization.test.ts: new cases in the getActivityGroups describe block.
from is non-null. Positions are always created as draft and every write is a real transition, so there is no creation event.
The [to, createdAt] index serves the admin "all positions" read.
updatePositionStatus: zod schema, auth and admin-only-open are unchanged.
Everything after getPositionStatusTransitionError moves into prisma.$transaction(async (tx) => …).
The updateMany where adds status: existing.status (compare-and-swap). This makes the event's from provably the replaced status, and a concurrent double-open can't write two events.
If existing.status !== status and count === 1: tx.positionStatusEvent.create({ data: { positionId: id, from: existing.status, to: status, changedById: user.id } }).
A same-status save (from === to) still runs the update as today but writes no event.
The count === 0 fallback re-reads { status } and resolves in this order:
row gone → { error: 'This position no longer exists.' } (unchanged);
status !== existing.status → { error: POSITION_STATUS_CHANGED_ERROR }, with copy 'This position just changed. Refresh to see its current status.' (user-facing, and they can act on it by refreshing);
Any unexpected DB error throws. Revalidation is unchanged (revalidatePositionSurfaces).
getRecentPositionOpenings(reviewer, take): read-only data function. The caller is getActivityGroups, which already has a derived reviewer.
where: { to: 'open', position: buildReviewablePositionWhere(reviewer) }. For a manager this means non-deleted, non-draft positions they are currently listed on. For an admin it means every published position.
A position later returned to draft drops out, because it's no longer live.
A manager added after the open event does see it, matching how application rows scope by current membership.
No actor identity is selected, so changedBy never reaches the panel.
getActivityGroups:
When scope !== 'none', fetch getRecentPositionOpenings({ id: userId, isAdmin }, ACTIVITY_TAKE) in the same Promise.all.
Map each row to { id: event.id, statusVariant: POSITION_STATUS_BADGE_VARIANT.open, sentence: POSITION_ACTIVITY_SENTENCE[event.from](title), timestamp: event.createdAt, href: /positions/${position.id} }. The from is typed 'draft' | 'closed' after narrowing, since to: 'open' means from can never be 'open'. Narrow with a guard, not a cast.
reviewed = [...self-filtered application items, ...opening items], sorted by timestamp desc, then .slice(0, ACTIVITY_TAKE).
Opening events get no self-filter. The admin who opened it sees it under "All positions" (it falls out naturally).
mine is untouched.
UX states
Row: same layout as the application rows: dot · sentence · relative time. The dot uses default (the open badge variant).
Copy, from draft: "Engineering Director was opened".
Copy, from closed: "Engineering Director was reopened".
This uses past tense rather than the ticket's "is now open for applications". The item stays in the feed after the position closes, and if opensAt is still in the future it isn't accepting applications yet. Both past-tense sentences stay true. The verbs match the existing "Position opened" / "Position reopened" toasts.
Link: the whole row is a next/link to /positions/{id}, the live listing, which confirms what applicants now see.
Wrap it in SheetClose asChild. The panel lives in the layout, so without this the Sheet would stay open over the new page.
Application rows stay non-links. Adding links to them is out of scope.
Ordering: openings interleave with "X applied for Y" rows by time within "Positions you manage" / "All positions". Group headings and the "Your applications" group are unchanged.
Empty / loading / error: unchanged from #617 Move Recent Activity Into A Global Activity Panel #771. A manager whose only activity is an opening now sees the reviewer group instead of the empty state. The empty-state copy doesn't mention openings, so leave it as is.
Plain applicant: no change. scope === 'none' never queries openings.
A11y: the link's accessible name is the sentence plus the <time>. The dot stays aria-hidden. The row keeps its <li> inside the <ol>, with the link inside the li.
Mobile: same Sheet and row. The link fills the row's full width, so the tap target is the row height (≥44px with py-3).
Implementation
1. Add the PositionStatusEvent model and back-relations, then run npm run prisma:migrate -- --name add_position_status_event and npm run prisma:generate.
2. Add POSITION_STATUS_CHANGED_ERROR and POSITION_ACTIVITY_SENTENCE to lib/constants.ts.
7. Update ActivityFeedList to render linked rows via SheetClose asChild + Link when href is set, and plain rows otherwise.
8. Update the docs:
docs/WORKFLOWS.md XC-10 Composition: the reviewer group also carries "was opened / was reopened" items for positions in scope, from PositionStatusEventto: 'open', linking to /positions/{id}, with no self-filter.
docs/WORKFLOWS.md XC-10 cap bullet: newest 10 across both kinds.
docs/WORKFLOWS.md PM-4 status flow: updatePositionStatus compare-and-swaps and records a PositionStatusEvent for every real transition. Add the new "just changed" error.
docs/WORKFLOWS.md AD intro: replace "no notification back to the manager" with "the only signal back to the manager is an activity-panel item (XC-10)".
docs/PERMISSIONS.md line ~103: amend "no notification to the manager" the same way.
Leave isPositionActive / archiving untouched. Position status events do not count as activity for archive; state that in the PM-4 addition so a later ticket doesn't assume otherwise.
9. Clean up the fixtures and add tests (below).
10. Pre-push checks.
Testing
Automated (tests/db/)
authorization.test.ts, in the getActivityGroups composition and scoping block. Drive status changes through updatePositionStatus with actAs(admin) so the real write path is covered:
Admin opens a draft with manager M → M's reviewed contains an item with sentence "<title> was opened" and href/positions/<id>.
A manager of a different position → no such item.
An applicant on the position (not a manager) → scope: 'none', and neither group contains it.
Admin → reviewed contains it (no self-filter).
Reopen (open → closed → open, closesAt null) → M gets a "was reopened" item. The close event produces no item.
Returning to draft after opening (no applications) → the item disappears for M.
Merge: an application newer than the opening sorts above it, and the combined reviewed length is ≤ 10.
position-transitions.test.ts:
Each legal transition writes exactly one event with the correct from/to/changedById.
A same-status save writes none.
A blocked transition writes none.
npm run test, plus prettier, eslint and tsc.
Manual
As an admin, create a draft with manager M (a non-admin). Click Open position.
Sign in as M and open the activity panel → "Positions you manage" shows "<title> was opened · just now". Click it → it navigates to /positions/<id> and the panel closes.
As a manager of an unrelated position, and as a plain applicant → no item.
As the admin → the item shows under "All positions".
Admin: close, then reopen → M sees "<title> was reopened" as well as the original.
Keyboard: Tab to the row and press Enter → it navigates and the Sheet closes, with a visible focus ring. Check at a 375px viewport too.
When an admin opens a draft position, that position's managers should see it in the Recent activity panel (#617 / PR #771). For example: "Engineering Director is now open for applications". Only admins can open a position (#655), so today a manager has no in-app signal that their draft went live.
Blocked by #617: this builds on the activity panel introduced in PR #771.
Context
getActivityGroups(prisma/data/activity.ts, added in #617 Move Recent Activity Into A Global Activity Panel #771), from application events (getRecentApplications).ApplicationStatusEventexists for applications, but there is no equivalent history for positions. The only change on open isPosition.status. The plan needs to decide how to source this, for example:openedAttimestamp onPosition;Prefer an approach that could later cover close and reopen events too.
Only surface open in this ticket.The scope amendment below also surfaces closes.Scope amendment (operator decision, during PR #776 review)
Position closes are now in scope as well as opens:
closed, show "<title> was closed" (fromPositionStatusEvent).openposition'sclosesAtpasses, show "<title> closed". This is derived in the activity query fromclosesAt, with no stored event and no cron.Second amendment: deletions. When an admin deletes a published position (its status was
openorclosedat deletion, notdraft), show "<title> was deleted".deletedAt, the soft-delete stamp. No new event and no schema change, unless deriving it proves unsound.The position's earlier opened and closed rows drop out after deletion.Operator decision: once an activity row has appeared it is never removed by deletion.Scope
openfromdraft(and fromclosed, if an admin reopens it), each listed manager of that position gets an activity item in their reviewer group, "Positions you manage".docs/WORKFLOWS.md(XC-10 and the position-status flow) anddocs/PERMISSIONS.mdif visibility rules change.Acceptance criteria
tests/db/coverage for the scoping cases above, next to the existinggetActivityGroupstests.npm run prisma:migrate.Implementation Plan
Overview
Record every position status change in a new
PositionStatusEventtable, written in the same transaction as the status update (mirrorsApplicationStatusEvent). The activity panel's reviewer group reads theto: 'open'events, scoped exactly like the existing application rows. They merge by time and are capped at 10. Close and return-to-draft events are recorded too, but not shown.Base: plan against branch
617-move-recent-activity-into-a-global-activity-panel(PR #771). Rebase ontodevonce #771 merges.prisma/data/activity.ts,ActivityItem/ActivityGroupsandcomponents/features/activity-feed.tsxonly exist there.Why an event table, not
openedAtor deriving it:openedAtonly keeps the latest open, so it can't later cover close or reopen history. Deriving it isn't sound:updatedAtchanges on every title, description or schedule edit, andopensAtis the applicant window, not the publish moment.Changes
prisma/schema.prisma: newPositionStatusEventmodel, plus back-relations onPosition(statusEvents) andUser("PositionStatusEventChangedBy").prisma/migrations/<ts>_add_position_status_event/: generated withnpm run prisma:migrate -- --name add_position_status_event. No backfill. Positions that are already open have no truthful opened-at time, so they don't produce an item.prisma/actions/position-actions.ts:updatePositionStatus. Wrap the write inprisma.$transaction, add a compare-and-swap onstatus, and create the event whenfrom !== to.lib/constants.ts:POSITION_STATUS_CHANGED_ERROR, the new user-facing copy.POSITION_ACTIVITY_SENTENCE: Record<'draft' | 'closed', (title: string) => string>, the copy keyed byfrom.prisma/data/positions.ts: newgetRecentPositionOpenings(reviewer: Reviewer, take: number).lib/types.ts:ActivityItemgets an optionalhref?: string.PositionOpeningActivitypayload type, for the query's return.prisma/data/activity.ts:getActivityGroupsfetches openings alongside applications formanaged/allscopes, then merges, sorts and caps them.components/features/activity-feed.tsx:ActivityFeedListrenders a row as a link that closes the Sheet whenitem.hrefis set.tests/helpers/fixtures.ts:cleanupFixturesdeletespositionStatusEventrows for test positions and users beforeposition.deleteMany, because of the FK.tests/db/authorization.test.ts: new cases in thegetActivityGroupsdescribe block.tests/db/position-transitions.test.ts: event-write cases.docs/WORKFLOWS.md,docs/PERMISSIONS.md: see Implementation step 8.Data & contracts
Schema
fromis non-null. Positions are always created asdraftand every write is a real transition, so there is no creation event.[to, createdAt]index serves the admin "all positions" read.updatePositionStatus: zod schema, auth and admin-only-open are unchanged.getPositionStatusTransitionErrormoves intoprisma.$transaction(async (tx) => …).updateManywhere addsstatus: existing.status(compare-and-swap). This makes the event'sfromprovably the replaced status, and a concurrent double-open can't write two events.existing.status !== statusandcount === 1:tx.positionStatusEvent.create({ data: { positionId: id, from: existing.status, to: status, changedById: user.id } }).from === to) still runs the update as today but writes no event.count === 0fallback re-reads{ status }and resolves in this order:{ error: 'This position no longer exists.' }(unchanged);status !== existing.status→{ error: POSITION_STATUS_CHANGED_ERROR }, with copy'This position just changed. Refresh to see its current status.'(user-facing, and they can act on it by refreshing);{ error: POSITION_UNPUBLISH_BLOCKED_ERROR }(unchanged).revalidatePositionSurfaces).getRecentPositionOpenings(reviewer, take): read-only data function. The caller isgetActivityGroups, which already has a derived reviewer.where: { to: 'open', position: buildReviewablePositionWhere(reviewer) }. For a manager this means non-deleted, non-draft positions they are currently listed on. For an admin it means every published position.select: { id, from, createdAt, position: { select: { id, title } } }.orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],take.changedBynever reaches the panel.getActivityGroups:scope !== 'none', fetchgetRecentPositionOpenings({ id: userId, isAdmin }, ACTIVITY_TAKE)in the samePromise.all.{ id: event.id, statusVariant: POSITION_STATUS_BADGE_VARIANT.open, sentence: POSITION_ACTIVITY_SENTENCE[event.from](title), timestamp: event.createdAt, href:/positions/${position.id}}. Thefromis typed'draft' | 'closed'after narrowing, sinceto: 'open'meansfromcan never be'open'. Narrow with a guard, not a cast.reviewed = [...self-filtered application items, ...opening items], sorted bytimestampdesc, then.slice(0, ACTIVITY_TAKE).mineis untouched.UX states
default(theopenbadge variant).draft: "Engineering Director was opened".closed: "Engineering Director was reopened".opensAtis still in the future it isn't accepting applications yet. Both past-tense sentences stay true. The verbs match the existing "Position opened" / "Position reopened" toasts.next/linkto/positions/{id}, the live listing, which confirms what applicants now see.SheetClose asChild. The panel lives in the layout, so without this the Sheet would stay open over the new page.hover:bg-muted/50. Focus:focus-visible:ring-2 ring-ring outline-none.scope === 'none'never queries openings.<time>. The dot staysaria-hidden. The row keeps its<li>inside the<ol>, with the link inside theli.py-3).Implementation
PositionStatusEventmodel and back-relations, then runnpm run prisma:migrate -- --name add_position_status_eventandnpm run prisma:generate.POSITION_STATUS_CHANGED_ERRORandPOSITION_ACTIVITY_SENTENCEtolib/constants.ts.updatePositionStatus: transaction, compare-and-swap, event write, three-waycount === 0fallback.href?toActivityItemandPositionOpeningActivitytolib/types.ts.getRecentPositionOpeningstoprisma/data/positions.ts.getActivityGroups: fetch, map, merge, sort, cap.ActivityFeedListto render linked rows viaSheetClose asChild+Linkwhenhrefis set, and plain rows otherwise.docs/WORKFLOWS.mdXC-10 Composition: the reviewer group also carries "was opened / was reopened" items for positions in scope, fromPositionStatusEventto: 'open', linking to/positions/{id}, with no self-filter.docs/WORKFLOWS.mdXC-10 cap bullet: newest 10 across both kinds.docs/WORKFLOWS.mdPM-4 status flow:updatePositionStatuscompare-and-swaps and records aPositionStatusEventfor every real transition. Add the new "just changed" error.docs/WORKFLOWS.mdAD intro: replace "no notification back to the manager" with "the only signal back to the manager is an activity-panel item (XC-10)".docs/PERMISSIONS.mdline ~103: amend "no notification to the manager" the same way.isPositionActive/ archiving untouched. Position status events do not count as activity for archive; state that in the PM-4 addition so a later ticket doesn't assume otherwise.Testing
Automated (
tests/db/)authorization.test.ts, in thegetActivityGroups composition and scopingblock. Drive status changes throughupdatePositionStatuswithactAs(admin)so the real write path is covered:reviewedcontains an item with sentence"<title> was opened"andhref/positions/<id>.scope: 'none', and neither group contains it.reviewedcontains it (no self-filter).closesAtnull) → M gets a"was reopened"item. The close event produces no item.reviewedlength is ≤ 10.position-transitions.test.ts:from/to/changedById.npm run test, plus prettier, eslint and tsc.Manual
/positions/<id>and the panel closes.