Skip to content

Show Managers Activity When Their Position Is Opened #775

Description

@cielbellerose

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

  • The panel's reviewer group is built by getActivityGroups (prisma/data/activity.ts, added in #617 Move Recent Activity Into A Global Activity Panel #771), from application events (getRecentApplications).
  • 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.
    • Scoping matches the rest of the reviewer group: listed managers only, not the creator unless listed (decision on Move Recent Activity Into A Global Activity Panel #617). Admins see it in "All positions".
  • 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.
  • tests/db/position-transitions.test.ts: event-write cases.
  • docs/WORKFLOWS.md, docs/PERMISSIONS.md: see Implementation step 8.

Data & contracts

Schema

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])
}
  • 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);
    • otherwise → { error: POSITION_UNPUBLISH_BLOCKED_ERROR } (unchanged).
  • 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.
  • select: { id, from, createdAt, position: { select: { id, title } } }.
  • orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take.
  • 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.
    • Hover: hover:bg-muted/50. Focus: focus-visible:ring-2 ring-ring outline-none.
    • 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.
  • 3. Rework updatePositionStatus: transaction, compare-and-swap, event write, three-way count === 0 fallback.
  • 4. Add href? to ActivityItem and PositionOpeningActivity to lib/types.ts.
  • 5. Add getRecentPositionOpenings to prisma/data/positions.ts.
  • 6. Extend getActivityGroups: fetch, map, merge, sort, cap.
  • 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 PositionStatusEvent to: '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

  1. As an admin, create a draft with manager M (a non-admin). Click Open position.
  2. 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.
  3. As a manager of an unrelated position, and as a plain applicant → no item.
  4. As the admin → the item shows under "All positions".
  5. Admin: close, then reopen → M sees "<title> was reopened" as well as the original.
  6. 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.

Activity

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

Metadata

Metadata

Assignees

Labels

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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions