Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
08aa1ee
#549 rename manager_digest to manager_daily_digest, add weekly
b-at-neu Sep 12, 2026
e7fd2d6
#549 add org week date helpers
b-at-neu Sep 12, 2026
8b1a547
#549 add manager digest recipient queries and templates
b-at-neu Sep 12, 2026
09653cc
#549 add manager digest cron routes
b-at-neu Sep 12, 2026
eddcecc
#549 amend api-route allowlist for the digest cron routes
b-at-neu Sep 12, 2026
3d2e412
#549 address review feedback
b-at-neu Sep 12, 2026
7054cb7
#549 address review feedback
b-at-neu Sep 12, 2026
c594d25
#549 make the weekly digest a pure unresolved-work reminder
b-at-neu Sep 12, 2026
0726d04
#549 window the daily digest since each manager's last send
b-at-neu Sep 12, 2026
e56c3b7
#549 address review feedback
b-at-neu Sep 12, 2026
c279e85
#549 handle nullable submittedAt after rebasing onto #747
b-at-neu Sep 23, 2026
025c989
#769 highlight the section actually in view in section nav
cielbellerose Sep 24, 2026
83a4c21
Merge pull request #770 from SGAOperations/769-highlight-section-in-v…
cielbellerose Sep 24, 2026
1fb601e
#748 disable continue on past-deadline drafts, not hide it
cielbellerose Sep 24, 2026
3ddedd2
#748 address review feedback
cielbellerose Sep 24, 2026
4901248
#748 address review feedback
cielbellerose Sep 24, 2026
0b2151d
#748 fix detail-page crash and tooltip hover on disabled action
cielbellerose Sep 24, 2026
2cad9b5
Merge pull request #768 from SGAOperations/748-hide-the-continue-acti…
cielbellerose Sep 24, 2026
4502e69
#772 animate tooltips in as well as out
cielbellerose Sep 24, 2026
607943c
Merge pull request #773 from SGAOperations/772-animate-tooltips-in-as…
cielbellerose Sep 24, 2026
880d75e
#617 move recent activity into a global activity panel
cielbellerose Sep 24, 2026
5eabc65
#617 address review feedback
cielbellerose Sep 24, 2026
a5ff4e0
#617 address review feedback
cielbellerose Sep 24, 2026
6578536
#617 address review feedback
cielbellerose Sep 24, 2026
f41496d
Merge pull request #771 from SGAOperations/617-move-recent-activity-i…
cielbellerose Sep 24, 2026
b9ea869
#775 add PositionStatusEvent model and migration
cielbellerose Sep 24, 2026
f1c207d
#775 record and check position status events in updatePositionStatus
cielbellerose Sep 24, 2026
8f72e43
#775 surface position openings in the activity panel's reviewer group
cielbellerose Sep 24, 2026
e827203
#775 link position-opening rows in the activity panel
cielbellerose Sep 24, 2026
35d4664
#775 add test coverage for position-opening activity events
cielbellerose Sep 24, 2026
91dddcb
#775 document position-opening activity in WORKFLOWS and PERMISSIONS
cielbellerose Sep 24, 2026
c958549
#775 address review feedback
cielbellerose Sep 24, 2026
37a975d
#775 address review feedback
cielbellerose Sep 24, 2026
01b41ad
#645 stamp User.lastLoginAt on sign-in
cielbellerose Sep 24, 2026
9cac4c3
#645 extract DataTable's row comparator into sortRows
cielbellerose Sep 24, 2026
625073c
#645 add a Last sign-in column to the users page
cielbellerose Sep 24, 2026
ece313a
#775 address review feedback
cielbellerose Sep 24, 2026
8d45b25
#645 address review feedback
cielbellerose Sep 24, 2026
b604ad4
Merge pull request #776 from SGAOperations/775-show-managers-activity…
cielbellerose Sep 25, 2026
a678c07
Merge pull request #777 from SGAOperations/645-add-a-last-sign-in-col…
cielbellerose Sep 25, 2026
b5defae
Merge branch 'dev' into 549-manager-daily-digest
b-at-neu Sep 25, 2026
91d3d39
Merge pull request #717 from SGAOperations/549-manager-daily-digest
b-at-neu Sep 25, 2026
ffce431
bump version to v1.14.0
b-at-neu Sep 25, 2026
ec15525
Merge pull request #778 from SGAOperations/bump/v1.14.0
b-at-neu Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ RESEND_FROM_EMAIL=
# Signing secret for the Resend webhook's endpoint (Resend dashboard → the webhook).
RESEND_WEBHOOK_SECRET=

# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` — verified
# by the manager-digest cron routes (app/api/cron/manager-{daily,weekly}-digest).
CRON_SECRET=

# Dev-bypass login (see lib/utils.ts#isBypassAllowed) is disabled by default
# on any host, including production deployments not on Vercel. This repo's
# `dev` script is plain `next dev --turbopack` (no Vercel CLI), so VERCEL_ENV
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Next.js 16 (App Router, React 19) · Prisma 7 · Tailwind CSS 4 · shadcn/ui (Ra
- **IMPORTANT: routes under `app/api/` are forbidden except for the ones allowlisted here.** Mutations are Server Actions; a route earns a line below only when it needs something an action cannot have, and a new route is a rule change that appends to this list.
- `app/api/auth/[...path]/route.ts` — Better Auth needs a reachable HTTP endpoint.
- `app/api/webhooks/resend/route.ts` — Resend signs the **raw** request body, which a server action never sees.
- `app/api/cron/manager-daily-digest/route.ts` and `app/api/cron/manager-weekly-digest/route.ts` — Vercel Cron needs an HTTP trigger on a schedule, which a server action cannot have. **Each route runs exactly the one digest its path names — these are not a job runner; a third job needs its own route, its own line, and its own justification.**
- **Mutations are Server Actions** in `prisma/actions/`, each with `'use server'`, an auth check, and zod validation. They return **`void` / the relevant data on success, `{ error }` for a user-facing failure, and `throw` for unexpected ones — never `{ ok }`** (`docs/ENGINEERING.md` §4). Decision test: _would you show this exact sentence to the user, and can they act on it?_ **yes → `{ error }`, no → throw**.
- **Data fetching is server-side** — server components call data-fetching functions in `prisma/data/`; Prisma never runs in a client component. **Avoid `useEffect`** — almost every use is a mistake here, and an empty-deps `useEffect` is essentially never right.
- **Default to server components**; add `'use client'` only for interactivity/hooks/browser APIs, on the smallest leaf possible.
Expand Down
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@ cp .env.example .env.local

Open `.env.local` and fill in the required variables:

| Variable | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Postgres connection string (pooled). Local Docker: `postgresql://admin:admin@localhost:5432/aplio` |
| `DIRECT_URL` | Direct (non-pooled) connection string. Local Docker: same as `DATABASE_URL` |
| `BETTER_AUTH_SECRET` | Signs session cookies. At least 32 characters: `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | Production only, pinned to the real domain. Preview/local derive it from `VERCEL_URL`, else `http://localhost:3000`. Also governs the absolute URLs (logo, sign-in link) in outgoing email. |
| `RESEND_API_KEY` | Resend API key for transactional email delivery |
| `RESEND_FROM_EMAIL` | Verified sender address in Resend (e.g. `noreply@yourdomain.com`) |
| `RESEND_WEBHOOK_SECRET` | Signing secret for the Resend webhook that reports delivery events (Resend dashboard → the webhook) |
| Variable | Description |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Postgres connection string (pooled). Local Docker: `postgresql://admin:admin@localhost:5432/aplio` |
| `DIRECT_URL` | Direct (non-pooled) connection string. Local Docker: same as `DATABASE_URL` |
| `BETTER_AUTH_SECRET` | Signs session cookies. At least 32 characters: `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | Production only, pinned to the real domain. Preview/local derive it from `VERCEL_URL`, else `http://localhost:3000`. Also governs the absolute URLs (logo, sign-in link) in outgoing email. |
| `RESEND_API_KEY` | Resend API key for transactional email delivery |
| `RESEND_FROM_EMAIL` | Verified sender address in Resend (e.g. `noreply@yourdomain.com`) |
| `RESEND_WEBHOOK_SECRET` | Signing secret for the Resend webhook that reports delivery events (Resend dashboard → the webhook) |
| `CRON_SECRET` | Bearer secret Vercel Cron sends as `Authorization: Bearer …`; verified by the manager-digest cron routes. Set it in the Vercel project env (`openssl rand -base64 32`) or both routes reject every call. |

> **Note:** Prisma CLI commands (`prisma:migrate`, `prisma:seed`) read from `.env`; Next.js reads `.env.local`. Both files are gitignored. For local development you can keep the same values in both.

Expand Down
11 changes: 11 additions & 0 deletions app/api/cron/manager-daily-digest/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { rejectUnauthorizedCron } from '@/lib/cron';
import { dispatchDailyManagerDigests } from '@/lib/email/manager-digests';

export const maxDuration = 300;

export async function GET(request: Request): Promise<Response> {
const denied = rejectUnauthorizedCron(request);
if (denied) return denied;

return Response.json(await dispatchDailyManagerDigests());
}
11 changes: 11 additions & 0 deletions app/api/cron/manager-weekly-digest/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { rejectUnauthorizedCron } from '@/lib/cron';
import { dispatchWeeklyManagerDigests } from '@/lib/email/manager-digests';

export const maxDuration = 300;

export async function GET(request: Request): Promise<Response> {
const denied = rejectUnauthorizedCron(request);
if (denied) return denied;

return Response.json(await dispatchWeeklyManagerDigests());
}
246 changes: 144 additions & 102 deletions components/features/activity-feed.tsx
Original file line number Diff line number Diff line change
@@ -1,134 +1,176 @@
import {
getMyRecentActivity,
getRecentApplications,
} from '@/prisma/data/applications';
import Link from 'next/link';

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 { SheetClose } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';

// ─── Presentational leaf ─────────────────────────────────────────────────────
function ActivityFeedRowContent({ item }: { item: ActivityItem }) {
const dotClass = STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant];

interface ActivityFeedListProps {
items: ActivityItem[];
emptyDescription: string;
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"
/>
</>
);
}

function ActivityFeedList({ items, emptyDescription }: ActivityFeedListProps) {
export function ActivityFeedList({ items }: { items: ActivityItem[] }) {
return (
<SectionCard
title="Recent Activity"
icon={CONCEPT_ICONS.activity}
sectionLabel="Recent activity"
>
{items.length === 0 ? (
<SectionCardEmpty
icon={CONCEPT_ICONS.activity}
title="No recent activity"
description={emptyDescription}
/>
) : (
<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"
<ol>
{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"
>
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${dotClass}`}
aria-hidden="true"
/>
<p className="line-clamp-2 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>
);
})}
</ol>
)}
</SectionCard>
<ActivityFeedRowContent item={item} />
</Link>
</SheetClose>
) : (
<div className="flex items-start gap-3 px-4 py-3">
<ActivityFeedRowContent item={item} />
</div>
)}
</li>
))}
</ol>
);
}

// ─── Applicant feed wrapper ───────────────────────────────────────────────────
function ActivityFeedGroup({
id,
title,
items,
}: {
id: string;
title: string;
items: ActivityItem[];
}) {
return (
<section aria-labelledby={id}>
<h3
id={id}
className="text-muted-foreground px-4 pt-4 pb-1 text-xs font-medium"
>
{title}
</h3>
<ActivityFeedList items={items} />
</section>
);
}

interface ApplicantActivityFeedProps {
interface ActivityFeedProps {
userId: string;
isAdmin: boolean;
}

// 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,
};
});
export async function ActivityFeed({ userId, isAdmin }: ActivityFeedProps) {
let groups;
try {
groups = await getActivityGroups(userId, isAdmin);
} catch (error) {
console.error('getActivityGroups failed', error);
return (
<SectionCardEmpty
variant="compact"
message="Couldn't load recent activity."
/>
);
}

const { scope, mine, reviewed } = groups;
const copy = ACTIVITY_FEED_COPY[scope];

if (mine.length === 0 && reviewed.length === 0)
return (
<div className="px-4">
<SectionCardEmpty
icon={CONCEPT_ICONS.activity}
title="No recent activity"
description={copy.emptyDescription}
/>
</div>
);

if (scope === 'none') return <ActivityFeedList items={mine} />;

return (
<ActivityFeedList
items={items}
emptyDescription="Updates to your applications will show up here."
/>
<>
{mine.length > 0 && (
<ActivityFeedGroup
id="activity-mine"
title={ACTIVITY_MINE_TITLE}
items={mine}
/>
)}
{reviewed.length > 0 && copy.reviewedTitle && (
<ActivityFeedGroup
id="activity-reviewed"
title={copy.reviewedTitle}
items={reviewed}
/>
)}
</>
);
}

// ─── Reviewer feed wrapper ─────────────────────────────────────────────────────
function ActivityFeedRowsSkeleton({ count }: { count: number }) {
return (
<ol>
{Array.from({ length: count }).map((_, i) => (
<li
key={i}
className="flex items-center gap-3 border-b px-4 py-3 last:border-0"
>
<Skeleton className="size-2 shrink-0 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-12" />
</li>
))}
</ol>
);
}

interface ReviewerActivityFeedProps {
reviewer: Reviewer;
function ActivityFeedGroupSkeleton() {
return (
<div>
<div className="px-4 pt-4 pb-1">
<Skeleton className="h-3 w-28" />
</div>
<ActivityFeedRowsSkeleton count={5} />
</div>
);
}

// 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 <ActivityFeedRowsSkeleton count={10} />;

return (
<ActivityFeedList
items={items}
emptyDescription={
reviewer.isAdmin
? 'New applications across all positions will show up here.'
: 'New applications to the positions you manage will show up here.'
}
/>
<>
<ActivityFeedGroupSkeleton />
<ActivityFeedGroupSkeleton />
</>
);
}
42 changes: 42 additions & 0 deletions components/features/activity-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open recent activity">
<ActivityIcon className="size-5" />
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0"
aria-describedby={undefined}
>
<div className="border-b px-4 py-3 pr-12">
<SheetTitle className="text-base">Recent activity</SheetTitle>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</SheetContent>
</Sheet>
);
}
Loading
Loading