diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx index 74fd0a58..66ba006a 100644 --- a/components/features/activity-feed.tsx +++ b/components/features/activity-feed.tsx @@ -1,134 +1,159 @@ -import { - getMyRecentActivity, - getRecentApplications, -} from '@/prisma/data/applications'; +import { getActivityGroups } from '@/prisma/data/activity'; import { - APPLICATION_STATUS_BADGE_VARIANT, - APPLICATION_STATUS_LABELS, + ACTIVITY_FEED_COPY, + ACTIVITY_MINE_TITLE, STATUS_BADGE_VARIANT_TO_DOT, } from '@/lib/constants'; import { CONCEPT_ICONS } from '@/lib/icons'; -import { type ActivityItem, type Reviewer } from '@/lib/types'; -import { getDisplayName, getRenamedTo } from '@/lib/utils'; +import { type ActivityItem, type ActivityScope } from '@/lib/types'; import { LocalTime } from '@/components/ui/local-time'; -import { SectionCard, SectionCardEmpty } from '@/components/ui/section-card'; +import { SectionCardEmpty } from '@/components/ui/section-card'; +import { Skeleton } from '@/components/ui/skeleton'; -// ─── Presentational leaf ───────────────────────────────────────────────────── +export function ActivityFeedList({ items }: { items: ActivityItem[] }) { + return ( +
    + {items.map((item) => { + const dotClass = STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant]; + + return ( +
  1. +
  2. + ); + })} +
+ ); +} -interface ActivityFeedListProps { +function ActivityFeedGroup({ + id, + title, + items, +}: { + id: string; + title: string; items: ActivityItem[]; - emptyDescription: string; +}) { + return ( +
+

+ {title} +

+ +
+ ); } -function ActivityFeedList({ items, emptyDescription }: ActivityFeedListProps) { - return ( - - {items.length === 0 ? ( +interface ActivityFeedProps { + userId: string; + isAdmin: boolean; +} + +export async function ActivityFeed({ userId, isAdmin }: ActivityFeedProps) { + let groups; + try { + groups = await getActivityGroups(userId, isAdmin); + } catch (error) { + console.error('getActivityGroups failed', error); + return ( + + ); + } + + const { scope, mine, reviewed } = groups; + const copy = ACTIVITY_FEED_COPY[scope]; + + if (mine.length === 0 && reviewed.length === 0) + return ( +
- ) : ( -
    - {items.map((item) => { - const dotClass = STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant]; +
+ ); - return ( -
  • -
  • - ); - })} - + if (scope === 'none') return ; + + return ( + <> + {mine.length > 0 && ( + )} -
    + {reviewed.length > 0 && copy.reviewedTitle && ( + + )} + ); } -// ─── Applicant feed wrapper ─────────────────────────────────────────────────── - -interface ApplicantActivityFeedProps { - userId: string; -} - -// States the current status only — no status-history table, so no from-state to assert. -export async function ApplicantActivityFeed({ - userId, -}: ApplicantActivityFeedProps) { - const applications = await getMyRecentActivity(userId, 10); - - const items: ActivityItem[] = applications.map((app) => { - const statusLabel = APPLICATION_STATUS_LABELS[app.status]; - const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status]; - return { - id: app.id, - statusVariant: variant, - sentence: `Your application for ${app.position.title} is ${statusLabel}`, - timestamp: app.submittedAt, - }; - }); - +function ActivityFeedRowsSkeleton({ count }: { count: number }) { return ( - +
      + {Array.from({ length: count }).map((_, i) => ( +
    1. + + + +
    2. + ))} +
    ); } -// ─── Reviewer feed wrapper ───────────────────────────────────────────────────── - -interface ReviewerActivityFeedProps { - reviewer: Reviewer; +function ActivityFeedGroupSkeleton() { + return ( +
    +
    + +
    + +
    + ); } -// Ordered by submittedAt (a provable event stream); cross-user data, reviewer-gated only. -export async function ReviewerActivityFeed({ - reviewer, -}: ReviewerActivityFeedProps) { - const applications = await getRecentApplications(reviewer, 10); - - const items: ActivityItem[] = applications.map((app) => { - const applicantLabel = getDisplayName(app); - const renamedTo = getRenamedTo(app); - const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status]; - return { - id: app.id, - statusVariant: variant, - sentence: `${applicantLabel}${renamedTo ? ` (${renamedTo})` : ''} applied for ${app.position.title}`, - timestamp: app.submittedAt, - }; - }); +export function ActivityFeedListSkeleton({ scope }: { scope: ActivityScope }) { + if (scope === 'none') return ; return ( - + <> + + + ); } diff --git a/components/features/activity-panel.tsx b/components/features/activity-panel.tsx new file mode 100644 index 00000000..beaaf1fd --- /dev/null +++ b/components/features/activity-panel.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { type ReactNode, useState } from 'react'; + +import { CONCEPT_ICONS } from '@/lib/icons'; + +import { Button } from '@/components/ui/button'; +import { + Sheet, + SheetContent, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; + +interface ActivityPanelProps { + children: ReactNode; +} + +export function ActivityPanel({ children }: ActivityPanelProps) { + const [open, setOpen] = useState(false); + const ActivityIcon = CONCEPT_ICONS.activity; + + return ( + + + + + +
    + Recent activity +
    +
    {children}
    +
    +
    + ); +} diff --git a/components/features/admin-dashboard.tsx b/components/features/admin-dashboard.tsx index 3f567135..d79e93b6 100644 --- a/components/features/admin-dashboard.tsx +++ b/components/features/admin-dashboard.tsx @@ -2,7 +2,6 @@ import { Suspense } from 'react'; import { type Reviewer } from '@/lib/types'; -import { ReviewerActivityFeed } from '@/components/features/activity-feed'; import { OpenPositionsSummary } from '@/components/features/open-positions-summary'; import { PipelineSummary, @@ -36,14 +35,6 @@ export function AdminDashboard({ reviewer }: AdminDashboardProps) { }> - - - } - > - - ); } diff --git a/components/features/manager-dashboard.tsx b/components/features/manager-dashboard.tsx index 7e734df3..fb84f0a6 100644 --- a/components/features/manager-dashboard.tsx +++ b/components/features/manager-dashboard.tsx @@ -2,7 +2,6 @@ import { Suspense } from 'react'; import { type Reviewer } from '@/lib/types'; -import { ReviewerActivityFeed } from '@/components/features/activity-feed'; import { ManagedPositionsWidget, ManagedPositionsWidgetSkeleton, @@ -41,14 +40,6 @@ export function ManagerDashboard({ user }: ManagerDashboardProps) { - - } - > - - - } > diff --git a/components/features/user-dashboard.tsx b/components/features/user-dashboard.tsx index 38ccb3a1..e5264235 100644 --- a/components/features/user-dashboard.tsx +++ b/components/features/user-dashboard.tsx @@ -2,7 +2,6 @@ import { Suspense } from 'react'; import { getFirstName } from '@/lib/utils'; -import { ApplicantActivityFeed } from '@/components/features/activity-feed'; import { ApplicantSummary, ApplicantSummarySkeleton, @@ -48,14 +47,6 @@ export function UserDashboard({ userId, userName }: UserDashboardProps) { }> - - - } - > - - ); } diff --git a/components/layouts/app-shell.tsx b/components/layouts/app-shell.tsx index a7810059..f7b67486 100644 --- a/components/layouts/app-shell.tsx +++ b/components/layouts/app-shell.tsx @@ -1,10 +1,15 @@ -import type { ReactNode } from 'react'; +import { type ReactNode, Suspense } from 'react'; import { isManager } from '@/prisma/data/managers'; import { getIsBypass, getOptionalUser } from '@/lib/auth/server'; -import type { NavIdentity } from '@/lib/types'; +import type { ActivityScope, NavIdentity } from '@/lib/types'; +import { + ActivityFeed, + ActivityFeedListSkeleton, +} from '@/components/features/activity-feed'; +import { ActivityPanel } from '@/components/features/activity-panel'; import { AppFooter } from '@/components/layouts/app-footer'; import { MobileNav } from '@/components/layouts/mobile-nav'; import { Sidebar } from '@/components/layouts/sidebar'; @@ -15,6 +20,7 @@ export async function AppShell({ children }: { children: ReactNode }) { let identity: NavIdentity | null = null; let isAdmin = false; let canReviewApplications = false; + let activityPanel: ReactNode = null; if (user) { // Admins always see reviewer nav, so manager status matters only for the rest. @@ -32,6 +38,20 @@ export async function AppShell({ children }: { children: ReactNode }) { : 'User'; identity = { name: user.name, email: user.email, roleLabel, isBypass }; + + const scope: ActivityScope = user.isAdmin + ? 'all' + : userIsManager + ? 'managed' + : 'none'; + + activityPanel = ( + + }> + + + + ); } return ( @@ -40,12 +60,14 @@ export async function AppShell({ children }: { children: ReactNode }) { isAdmin={isAdmin} identity={identity} canReviewApplications={canReviewApplications} + activityPanel={activityPanel} />
    -
    +
    + {activityPanel}
    ); - case 'timeline': - return ( -
    - - - -
    - ); case 'badge-stacked': return (
    diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index b7be6441..80d3c91a 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -21,7 +21,7 @@ Behaviour shared by many workflows is stated once under [Cross-cutting behaviour ## Table of contents -**[Cross-cutting behaviours](#cross-cutting-behaviours)** — [XC-1](#xc-1-sign-in-gate-and-the-redirectto-round-trip) · [XC-2](#xc-2-name-gate) · [XC-3](#xc-3-profile-completeness) · [XC-4](#xc-4-denial-shape) · [XC-5](#xc-5-errors-and-feedback) · [XC-6](#xc-6-rate-limiting) · [XC-7](#xc-7-deactivated-account) · [XC-8](#xc-8-applicant-facing-status-grouping) · [XC-9](#xc-9-applicant-email) +**[Cross-cutting behaviours](#cross-cutting-behaviours)** — [XC-1](#xc-1-sign-in-gate-and-the-redirectto-round-trip) · [XC-2](#xc-2-name-gate) · [XC-3](#xc-3-profile-completeness) · [XC-4](#xc-4-denial-shape) · [XC-5](#xc-5-errors-and-feedback) · [XC-6](#xc-6-rate-limiting) · [XC-7](#xc-7-deactivated-account) · [XC-8](#xc-8-applicant-facing-status-grouping) · [XC-9](#xc-9-applicant-email) · [XC-10](#xc-10-activity-panel) **[Anonymous (AN)](#anonymous-an)** — [AN-1](#an-1-browse-positions) · [AN-2](#an-2-view-a-position) · [AN-3](#an-3-start-applying-from-a-position) · [AN-4](#an-4-sign-in-with-an-email-code) · [AN-5](#an-5-request-a-new-code) · [AN-6](#an-6-set-your-name-on-first-sign-in) · [AN-7](#an-7-read-the-legal-pages) · [AN-8](#an-8-dev-bypass-sign-in) @@ -92,6 +92,17 @@ Three applicant-facing email _events_, over two rendered templates, all through - **A failed send never fails the mutation.** The status write (or the submission) has already committed by the time the email is attempted; the send is a side effect dispatched in `after()`, and a provider failure is logged to `EmailLog` as `failed` and surfaced nowhere — not to the reviewer, not to the applicant. - **No opt-out, no preferences, no per-position copy.** +### XC-10 Activity panel + +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. +- **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. + --- ## Anonymous (AN) @@ -195,7 +206,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-1 See your dashboard - **Trigger** — signing in, the logo, or the Home nav item (`/`). -- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row is `title · trailing slot · status badge`; for a draft or withdrawn application the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold red `text-destructive-text` with the warning icon for any future deadline (`distant`, `soon`, or `urgent`) once the row is editable ([AP-10](#ap-10-track-your-applications)) — with the bare date, or the compact countdown (`Nh left`) once inside 24 hours, no `Closes`/`Closed` prefix. A `past` deadline always renders the plain muted date, regardless of status. And its subtitle appends `N closing soon` when any at-risk draft or withdrawn application exists, so one that would otherwise sit outside the top-3 by recency still surfaces here. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams four independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, and the three open positions closing soonest. Each has its own skeleton. The applications widget's row is `title · trailing slot · status badge`; for a draft or withdrawn application the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold red `text-destructive-text` with the warning icon for any future deadline (`distant`, `soon`, or `urgent`) once the row is editable ([AP-10](#ap-10-track-your-applications)) — with the bare date, or the compact countdown (`Nh left`) once inside 24 hours, no `Closes`/`Closed` prefix. A `past` deadline always renders the plain muted date, regardless of status. And its subtitle appends `N closing soon` when any at-risk draft or withdrawn application exists, so one that would otherwise sit outside the top-3 by recency still surfaces here. Recent activity lives in the activity panel, reachable from every page ([XC-10](#xc-10-activity-panel)), not on the dashboard. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). @@ -398,7 +409,7 @@ A user who manages at least one non-deleted position. Manager status is **derive ### PM-1 See your dashboard - **Trigger** — Home (`/`). -- **Happy path** — `ManagerDashboard` — "Overview of applications for the positions you manage." — streams a pipeline summary, the three most recent applications, three managed positions, an activity feed, and the manager's own applications widget. Every section is scoped to positions they manage. +- **Happy path** — `ManagerDashboard` — "Overview of applications for the positions you manage." — streams a pipeline summary, the three most recent applications, three managed positions, and the manager's own applications widget. Every section is scoped to positions they manage. Recent activity — a manager's own application activity as well as new applications to their managed positions — lives in the activity panel instead ([XC-10](#xc-10-activity-panel)). - **Failure / edge** — as [AP-1](#ap-1-see-your-dashboard); an admin gets `AdminDashboard` instead. - **End state** — read-only. @@ -579,7 +590,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)). +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)). ### AD-1 See every position diff --git a/lib/constants.ts b/lib/constants.ts index ff256e35..3d4158a3 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -3,7 +3,11 @@ import { z } from 'zod/v4'; import { $Enums } from '@/prisma/client'; import type { PositionStatus, Prisma, QuestionType } from '@/prisma/client'; -import type { PositionAvailability, UserRoleFilter } from '@/lib/types'; +import type { + ActivityScope, + PositionAvailability, + UserRoleFilter, +} from '@/lib/types'; import type { BadgeVariant } from '@/components/ui/badge'; @@ -1110,6 +1114,32 @@ export const STATUS_BADGE_VARIANT_TO_DOT: Record = { outline: 'bg-border', }; +// Heading for the activity panel's applicant-scoped group — the only group +// a 'none'-scope user sees, rendered with no heading at all in that case. +export const ACTIVITY_MINE_TITLE = 'Your applications'; + +// Drives the activity panel's empty state, keyed by the caller's derived +// scope (lib/types.ts#ActivityScope). +export const ACTIVITY_FEED_COPY: Record< + ActivityScope, + { emptyDescription: string; reviewedTitle: string | null } +> = { + none: { + emptyDescription: 'Updates to your applications will show up here.', + reviewedTitle: null, + }, + managed: { + emptyDescription: + 'Updates to your applications and new applications to the positions you manage will show up here.', + reviewedTitle: 'Positions you manage', + }, + all: { + emptyDescription: + 'Updates to your applications and new applications across all positions will show up here.', + reviewedTitle: 'All positions', + }, +}; + // 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 59a46eda..94c2addc 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -420,6 +420,17 @@ export type ActivityItem = { timestamp: Date; }; +// 'none' (plain applicant), 'managed' (manages ≥1 position), 'all' (admin). +export type ActivityScope = 'none' | 'managed' | 'all'; + +// mine is always the caller's own submitted applications; reviewed is the +// self-filtered reviewer feed, empty when scope is 'none'. +export type ActivityGroups = { + scope: ActivityScope; + mine: ActivityItem[]; + reviewed: ActivityItem[]; +}; + // 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 new file mode 100644 index 00000000..36c83b88 --- /dev/null +++ b/prisma/data/activity.ts @@ -0,0 +1,64 @@ +import 'server-only'; + +import { cache } from 'react'; + +import { + getMyRecentActivity, + getRecentApplications, +} from '@/prisma/data/applications'; +import { isManager } from '@/prisma/data/managers'; + +import { + APPLICATION_STATUS_BADGE_VARIANT, + APPLICATION_STATUS_LABELS, +} from '@/lib/constants'; +import { type ActivityGroups, type ActivityItem } from '@/lib/types'; +import { getDisplayName, getRenamedTo } from '@/lib/utils'; + +const ACTIVITY_TAKE = 10; + +// Deduped across the sidebar's and mobile nav's header instances by cache(). +export const getActivityGroups = cache(async function getActivityGroups( + userId: string, + isAdmin: boolean, +): Promise { + const scope = isAdmin + ? 'all' + : (await isManager(userId)) + ? 'managed' + : 'none'; + + const [applications, reviewed] = await Promise.all([ + getMyRecentActivity(userId, ACTIVITY_TAKE), + scope !== 'none' + ? getRecentApplications({ id: userId, isAdmin }, ACTIVITY_TAKE) + : Promise.resolve([]), + ]); + + const mine: ActivityItem[] = applications.map((app) => { + const statusLabel = APPLICATION_STATUS_LABELS[app.status]; + const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status]; + return { + id: app.id, + statusVariant: variant, + sentence: `Your application for ${app.position.title} is ${statusLabel}`, + timestamp: app.submittedAt, + }; + }); + + const reviewedItems: ActivityItem[] = reviewed + .filter((app) => app.user.id !== userId) + .map((app) => { + const applicantLabel = getDisplayName(app); + const renamedTo = getRenamedTo(app); + const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status]; + return { + id: app.id, + statusVariant: variant, + sentence: `${applicantLabel}${renamedTo ? ` (${renamedTo})` : ''} applied for ${app.position.title}`, + timestamp: app.submittedAt, + }; + }); + + return { scope, mine, reviewed: reviewedItems }; +}); diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 23bfca17..b14ab948 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -32,6 +32,7 @@ import { toggleUserAdmin, } from '@/prisma/actions/users'; import type { Application, Position, User } from '@/prisma/client'; +import { getActivityGroups } from '@/prisma/data/activity'; import { getApplicationForApply, getApplicationForReview, @@ -833,3 +834,102 @@ describe('createPositionQuestion / updatePositionQuestion / deletePositionQuesti ).rejects.toThrow(); }); }); + +describe('getActivityGroups composition and scoping', () => { + it('applicant only: scope none, mine is only their own application, no reviewed', async () => { + const groups = await getActivityGroups(applicantAppliedA.id, false); + expect(groups.scope).toBe('none'); + expect(groups.mine.map((i) => i.id)).toEqual([applicationA1.id]); + expect(groups.reviewed).toEqual([]); + }); + + it('neither applicant nor manager: scope none, both groups empty', async () => { + const fresh = await createTestUser(); + const groups = await getActivityGroups(fresh.id, false); + expect(groups.scope).toBe('none'); + expect(groups.mine).toEqual([]); + expect(groups.reviewed).toEqual([]); + }); + + it('draft only: mine excludes the draft', async () => { + const groups = await getActivityGroups(applicantDraftA.id, false); + expect(groups.mine).toEqual([]); + }); + + it('manager only: scope managed, mine empty, reviewed contains the managed position’s application and excludes another manager’s', async () => { + const manager = await createTestUser(); + const position = await createTestPosition(admin, { managers: [manager] }); + const otherApplicant = await createTestUser(); + const x = await createTestApplication(otherApplicant, position, { + status: 'applied', + }); + + const groups = await getActivityGroups(manager.id, false); + expect(groups.scope).toBe('managed'); + expect(groups.mine).toEqual([]); + const reviewedIds = groups.reviewed.map((i) => i.id); + expect(reviewedIds).toContain(x.id); + expect(reviewedIds).not.toContain(applicationB1.id); + }); + + it('manager who is also an applicant: mine has public status and self-filter drops mine from reviewed', async () => { + const manager = await createTestUser(); + const position = await createTestPosition(admin, { managers: [manager] }); + const otherApplicant = await createTestUser(); + const x = await createTestApplication(otherApplicant, position, { + status: 'applied', + }); + const mB = await createTestApplication(manager, positionB, { + status: 'applied', + }); + const mP = await createTestApplication(manager, position, { + status: 'reviewing', + }); + + const groups = await getActivityGroups(manager.id, false); + expect(groups.scope).toBe('managed'); + const mineIds = groups.mine.map((i) => i.id); + expect(mineIds).toContain(mB.id); + expect(mineIds).toContain(mP.id); + const reviewingItem = groups.mine.find((i) => i.id === mP.id); + expect(reviewingItem?.sentence).toContain('is Applied'); + + const reviewedIds = groups.reviewed.map((i) => i.id); + expect(reviewedIds).toContain(x.id); + expect(reviewedIds).not.toContain(mP.id); + expect(reviewedIds).not.toContain(mB.id); + expect(reviewedIds).not.toContain(applicationB1.id); + }); + + it('admin who is also an applicant: scope all, mine contains own application, reviewed excludes self/drafts/deleted', async () => { + const positionQ = await createTestPosition(admin, { managers: [managerA] }); + const otherApplicant = await createTestUser(); + const y = await createTestApplication(otherApplicant, positionQ, { + status: 'applied', + }); + const adminApp = await createTestApplication(admin, positionQ, { + status: 'applied', + }); + const draftPositionApplicant = await createTestUser(); + const onDraftPosition = await createTestApplication( + draftPositionApplicant, + draftPosition, + { status: 'applied' }, + ); + const deletedPositionApplicant = await createTestUser(); + const onDeletedPosition = await createTestApplication( + deletedPositionApplicant, + deletedPosition, + { status: 'applied' }, + ); + + const groups = await getActivityGroups(admin.id, true); + expect(groups.scope).toBe('all'); + expect(groups.mine.map((i) => i.id)).toContain(adminApp.id); + const reviewedIds = groups.reviewed.map((i) => i.id); + expect(reviewedIds).toContain(y.id); + expect(reviewedIds).not.toContain(adminApp.id); + expect(reviewedIds).not.toContain(onDraftPosition.id); + expect(reviewedIds).not.toContain(onDeletedPosition.id); + }); +});