From b9ea869882d5f1df1aa012bb72df6349fa477182 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 24 Sep 2026 18:16:38 -0400 Subject: [PATCH 1/9] #775 add PositionStatusEvent model and migration Records every position status change, mirroring ApplicationStatusEvent, so the activity panel can later show "position opened" items. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 23 +++++++++++++++++++ prisma/schema.prisma | 19 ++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260924221554_add_position_status_event/migration.sql 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 From f1c207dab87019ea514f2ff70850244ee2980677 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 24 Sep 2026 18:21:41 -0400 Subject: [PATCH 2/9] #775 record and check position status events in updatePositionStatus Wraps the status write in a transaction with a compare-and-swap on the prior status, so a concurrent change surfaces a clear error instead of silently overwriting, and writes a PositionStatusEvent per real move. Co-Authored-By: Claude Sonnet 5 --- lib/constants.ts | 13 ++++++++ prisma/actions/position-actions.ts | 53 +++++++++++++++++++++--------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index 3d4158a3..981293c1 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."; @@ -1140,6 +1143,16 @@ 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`, +}; + // Order is meaningful — rendered left to right on position cards. export const POSITION_CARD_STAT_STATUSES = [ 'applied', diff --git a/prisma/actions/position-actions.ts b/prisma/actions/position-actions.ts index 34526f0f..bbaad20c 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, @@ -274,27 +275,47 @@ 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 }, + // application, or a concurrent status change, can't slip past the checks + // above — same shape as deletePosition. status: existing.status is a + // compare-and-swap: it makes the event's `from` provably the replaced + // status, and stops a concurrent double-open from writing two events. + 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, }; } From 8f72e43d814e5a931f4eadbc7f8be8f0eed5a6e8 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 24 Sep 2026 18:21:48 -0400 Subject: [PATCH 3/9] #775 surface position openings in the activity panel's reviewer group getRecentPositionOpenings reads PositionStatusEvent rows scoped like the existing application feed; getActivityGroups merges them in by time and caps the combined reviewer group at 10. Co-Authored-By: Claude Sonnet 5 --- lib/types.ts | 14 ++++++++++++++ prisma/data/activity.ts | 28 ++++++++++++++++++++++++++-- prisma/data/positions.ts | 23 +++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/lib/types.ts b/lib/types.ts index 94c2addc..0d88c458 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -413,11 +413,14 @@ export type QuestionFileDownload = { }; // sentence is pre-rendered safe copy; statusVariant drives the dot color. +// href is set only for rows that link somewhere (position openings); plain +// application rows leave it undefined and render as static text. export type ActivityItem = { id: string; statusVariant: BadgeVariant; sentence: string; timestamp: Date; + href?: string; }; // 'none' (plain applicant), 'managed' (manages ≥1 position), 'all' (admin). @@ -431,6 +434,17 @@ export type ActivityGroups = { reviewed: ActivityItem[]; }; +// Matches getRecentPositionOpenings's select in prisma/data/positions.ts. +// No actor identity selected — changedBy never reaches the activity panel. +export type PositionOpeningActivity = Prisma.PositionStatusEventGetPayload<{ + select: { + id: true; + from: true; + createdAt: true; + position: { select: { id: true; title: true } }; + }; +}>; + // Exposes other users' identities — admin-gated contexts only, never a non-admin client. export type AdminUserListItem = Prisma.UserGetPayload<{ select: { diff --git a/prisma/data/activity.ts b/prisma/data/activity.ts index 36c83b88..425540f7 100644 --- a/prisma/data/activity.ts +++ b/prisma/data/activity.ts @@ -7,10 +7,13 @@ import { getRecentApplications, } from '@/prisma/data/applications'; import { isManager } from '@/prisma/data/managers'; +import { getRecentPositionOpenings } from '@/prisma/data/positions'; import { APPLICATION_STATUS_BADGE_VARIANT, APPLICATION_STATUS_LABELS, + POSITION_ACTIVITY_SENTENCE, + POSITION_STATUS_BADGE_VARIANT, } from '@/lib/constants'; import { type ActivityGroups, type ActivityItem } from '@/lib/types'; import { getDisplayName, getRenamedTo } from '@/lib/utils'; @@ -28,11 +31,14 @@ export const getActivityGroups = cache(async function getActivityGroups( ? 'managed' : 'none'; - const [applications, reviewed] = await Promise.all([ + const [applications, reviewed, openings] = await Promise.all([ getMyRecentActivity(userId, ACTIVITY_TAKE), scope !== 'none' ? getRecentApplications({ id: userId, isAdmin }, ACTIVITY_TAKE) : Promise.resolve([]), + scope !== 'none' + ? getRecentPositionOpenings({ id: userId, isAdmin }, ACTIVITY_TAKE) + : Promise.resolve([]), ]); const mine: ActivityItem[] = applications.map((app) => { @@ -46,7 +52,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 +66,23 @@ 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[] = openings + .filter( + (event): event is typeof event & { from: 'draft' | 'closed' } => + event.from !== '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: `/positions/${event.position.id}`, + })); + + const reviewedItems = [...applicationItems, ...openingItems] + .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..e001dd57 100644 --- a/prisma/data/positions.ts +++ b/prisma/data/positions.ts @@ -2,6 +2,7 @@ import 'server-only'; import { cache } from 'react'; +import { buildReviewablePositionWhere } from '@/lib/auth/scopes'; import { MANAGED_POSITIONS_WINDOW_DAYS, NON_REVIEWABLE_APPLICATION_STATUSES, @@ -17,7 +18,9 @@ import { type PositionDeletionSummary, type PositionDetail, type PositionForEdit, + type PositionOpeningActivity, type PositionWithQuestions, + type Reviewer, } from '@/lib/types'; import { getPositionAvailability, @@ -378,3 +381,23 @@ export async function getPositionDeletionSummary( return { submittedCount, draftCount }; } + +// Feeds the activity panel's reviewer group — scoped identically to +// getRecentApplications, so a position leaving the reviewer's scope (returned +// to draft, or the reviewer no longer manages it) drops its opening out too. +export async function getRecentPositionOpenings( + reviewer: Reviewer, + take: number, +): Promise { + return prisma.positionStatusEvent.findMany({ + where: { to: 'open', position: buildReviewablePositionWhere(reviewer) }, + select: { + id: true, + from: true, + createdAt: true, + position: { select: { id: true, title: true } }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take, + }); +} From e827203de6e4a1e709a16427bf3e85625109090f Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 24 Sep 2026 18:21:53 -0400 Subject: [PATCH 4/9] #775 link position-opening rows in the activity panel A row with an href renders as a Link wrapped in SheetClose so opening it also closes the panel; plain application rows are unaffected. Co-Authored-By: Claude Sonnet 5 --- components/features/activity-feed.tsx | 63 +++++++++++++++++---------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx index 66ba006a..f6578090 100644 --- a/components/features/activity-feed.tsx +++ b/components/features/activity-feed.tsx @@ -1,3 +1,5 @@ +import Link from 'next/link'; + import { getActivityGroups } from '@/prisma/data/activity'; import { @@ -10,34 +12,49 @@ import { type ActivityItem, type ActivityScope } from '@/lib/types'; import { LocalTime } from '@/components/ui/local-time'; import { SectionCardEmpty } from '@/components/ui/section-card'; +import { SheetClose } from '@/components/ui/sheet'; import { Skeleton } from '@/components/ui/skeleton'; +function ActivityFeedRowContent({ item }: { item: ActivityItem }) { + const dotClass = STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant]; + + return ( + <> +