Skip to content
Merged
63 changes: 40 additions & 23 deletions components/features/activity-feed.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Link from 'next/link';

import { getActivityGroups } from '@/prisma/data/activity';

import {
Expand All @@ -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 (
<>
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${dotClass}`}
aria-hidden="true"
/>
<p className="line-clamp-3 min-w-0 flex-1 text-sm">{item.sentence}</p>
<LocalTime
date={item.timestamp}
precision="relative"
className="text-muted-foreground ml-auto shrink-0 text-xs tabular-nums"
/>
</>
);
}

export function ActivityFeedList({ items }: { items: ActivityItem[] }) {
return (
<ol>
{items.map((item) => {
const dotClass = STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant];

return (
<li
key={item.id}
className="flex items-start gap-3 border-b px-4 py-3 last:border-0"
>
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${dotClass}`}
aria-hidden="true"
/>
<p className="line-clamp-3 min-w-0 flex-1 text-sm">
{item.sentence}
</p>
<LocalTime
date={item.timestamp}
precision="relative"
className="text-muted-foreground ml-auto shrink-0 text-xs tabular-nums"
/>
</li>
);
})}
{items.map((item) => (
<li key={item.id} className="border-b last:border-0">
{item.href ? (
<SheetClose asChild>
<Link
href={item.href}
className="hover:bg-muted/50 focus-visible:ring-ring flex items-start gap-3 px-4 py-3 outline-none focus-visible:ring-2"
>
<ActivityFeedRowContent item={item} />
</Link>
</SheetClose>
) : (
<div className="flex items-start gap-3 px-4 py-3">
<ActivityFeedRowContent item={item} />
</div>
)}
</li>
))}
</ol>
);
}
Expand Down
3 changes: 2 additions & 1 deletion docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading