diff --git a/.env.example b/.env.example index b7a5abc1..6f896546 100644 --- a/.env.example +++ b/.env.example @@ -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 ` — 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 diff --git a/CLAUDE.md b/CLAUDE.md index 9dc9cef5..1e5446a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index e5c3e88f..840cdfa8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/api/cron/manager-daily-digest/route.ts b/app/api/cron/manager-daily-digest/route.ts new file mode 100644 index 00000000..e0ad389f --- /dev/null +++ b/app/api/cron/manager-daily-digest/route.ts @@ -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 { + const denied = rejectUnauthorizedCron(request); + if (denied) return denied; + + return Response.json(await dispatchDailyManagerDigests()); +} diff --git a/app/api/cron/manager-weekly-digest/route.ts b/app/api/cron/manager-weekly-digest/route.ts new file mode 100644 index 00000000..db50145f --- /dev/null +++ b/app/api/cron/manager-weekly-digest/route.ts @@ -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 { + const denied = rejectUnauthorizedCron(request); + if (denied) return denied; + + return Response.json(await dispatchWeeklyManagerDigests()); +} diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx index 74fd0a58..f6578090 100644 --- a/components/features/activity-feed.tsx +++ b/components/features/activity-feed.tsx @@ -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 ( + <> + + + {user.lastLoginAt ? ( + <> + Last sign-in{' '} + + + ) : ( + 'No sign-in recorded' + )} + {appCount > 0 ? (