Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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());
}
1 change: 1 addition & 0 deletions docs/ENGINEERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ A thrown error reaches Vercel's runtime logs with a stack and request context; a
- **A client `catch` that also toasts** — the §3-mandated wrapper around a server action, where the log keeps a real bug distinguishable from a stale-permission denial. Logging with **no** toast is not exempt.
- **A server-side cause the browser can't see** (`prisma/actions/auth.ts`) — log the upstream error, return `{ error }` with safe copy.
- **Best-effort side effects get no automatic exemption.** Where the caller can retry (a webhook whose 500 is retried), **throw** — `lib/email/resend.ts` still does, for both the single send and the batch send. The one exemption is a domain email already dispatched from `after()`: `lib/email/application-emails.ts` is the named swallow site, since the mutation it follows has already committed and the `EmailLog` row is the record that makes swallowing correct.
- **A second, differently-justified swallow site: `lib/email/manager-digests.ts`.** No mutation sits behind a digest send — the caller is Vercel Cron, which does not retry a 200 — so throwing on one bad address would abandon every later manager in the run rather than protect anything. Each recipient's send is its own `try`/`catch`; the `EmailLog` row is still the record.

## 5. Accessibility

Expand Down
1 change: 1 addition & 0 deletions docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Four principals, each derived rather than stored as a single role field:
- **Draft visibility is the question two audit passes answered differently: it is visible to its managers and admins only.** `resolvePositionView` (`app/(main)/positions/[id]/page.tsx`) returns `null` — and the page calls `notFound()` — when `position.status === 'draft' && !canManage`. A manager opening a draft position's public detail page sees it; a signed-in non-manager or anonymous visitor 404s identically.
- **The two shapes of applicant denial on the review routes are both correct, deliberately.** `/manage/applications` denies with the page guard `requireManagerOrAdminOr404` — the list itself is off-limits to a plain applicant. `/manage/applications/[id]` denies by **query scoping** (`getCurrentUser` + `buildApplicationWhere(user, 'listable')` → `notFound()`) — the detail route must 404 identically for "not yours" and "doesn't exist", so it can't gate at the top with a role check.
- `app/(main)/(auth)/layout.tsx` is the group-level gate: `getCurrentUser()` + `requireName()`. It wraps `manage/applications`, `manage/applications/[id]`, `global-questions`, `applications`, `applications/[id]`, `positions/[id]/apply`, `manage/positions`, `manage/positions/[id]/edit`, and `users`. `/`, `/positions`, `/positions/[id]`, and `/profile` sit outside that group and self-call `requireName` on a signed-in caller.
- **`/api/cron/manager-daily-digest` and `/api/cron/manager-weekly-digest` carry no principal.** They are machine-authenticated — `rejectUnauthorizedCron` (`lib/cron.ts`) checks a bearer secret (`CRON_SECRET`) against the request header before any DB access — not one of the four principals above, so they don't fit this table's columns.

## Server-action authorization

Expand Down
Loading
Loading