Staff allocation tracker for IDinsight. Shows who is working on what, week by week, as an editable grid you can view by project or by teammate — plus project and teammate tables, a shared notepad, and a read-only JSON API for agents and scripts.
Built with Next.js 16 (App Router), React 19, Tailwind 4, Prisma 7 against Postgres, and Better Auth for Google sign-in. three.js and React Three Fiber are there only for the cube easter egg behind the logo in the bottom-right corner, and are lazy-loaded so they stay out of the main bundle.
Requires Node 20+, pnpm, and a Postgres database — either the shared one or a local one.
pnpm install
cp template.env .env # then fill it in — see below
pnpm exec prisma migrate deploy
pnpm devOpen http://localhost:3000.
| Variable | What it's for |
|---|---|
DATABASE_URL |
Pooled connection, used by the app at runtime |
DIRECT_URL |
Direct connection, used for migrations |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
Google OAuth client, for sign-in |
BETTER_AUTH_SECRET |
Signs sessions. Generate with openssl rand -base64 32 |
BETTER_AUTH_URL |
This deployment's public origin. Used for OAuth redirects, and — for the MCP server — as the OAuth issuer, the /api/mcp resource identity, and the allowed browser origin. Required for MCP to work |
ALLOWED_EMAIL_DOMAINS |
Optional, comma-separated. Domains that may sign in. Defaults to idinsight.org |
EXTRA_ALLOWED_EMAILS |
Optional, comma-separated. Emails granted edit access without a teammate record |
READONLY_API_KEYS |
Optional, comma-separated. Grants GET-only API access |
You can point .env at the shared database, but running your own keeps
experiments off it. With Docker:
docker run --name allocate-db -e POSTGRES_PASSWORD=allocate \
-e POSTGRES_DB=allocate -p 5433:5432 -d postgres:17Port 5433 avoids clashing with a Postgres you may already have on 5432. Set both URLs to it — there is no pooler locally, so they are the same:
DATABASE_URL=postgresql://postgres:allocate@localhost:5433/allocate
DIRECT_URL=postgresql://postgres:allocate@localhost:5433/allocateThen pnpm exec prisma migrate deploy creates the schema. No extensions are
needed. The database starts empty, so set EXTRA_ALLOWED_EMAILS to your own
address — otherwise you can sign in but everything is read-only.
docker start allocate-db brings it back after a reboot; docker rm -f allocate-db throws it away. To work with real data instead, restore the dump in
data/ or run data/seed.py.
Sign-in is Google OAuth only, handled by Better Auth. There are three tiers, resolved from the email Google returns:
| Who | What they get |
|---|---|
An Active teammate, or in EXTRA_ALLOWED_EMAILS |
Full edit access |
| An Alumni teammate | Read-only |
Any other address on an allowed domain (idinsight.org by default) |
Read-only |
| Everyone else | Cannot sign in |
Read-only accounts may make GET and HEAD requests and nothing else.
src/proxy.ts enforces this on every request and rejects writes with 403; the UI
hides editing affordances to match, but the proxy is the boundary that counts.
The one exception is /api/mcp, which authenticates itself with OAuth bearer
tokens (see MCP server) — its tools are all read-only.
Tiers are resolved from the database per request rather than stored on the session, so both granting and revoking access take effect without signing out. Adding someone to the teammates table lets them write immediately, though the UI only unlocks its editing affordances after a page reload. Sessions last 30 days and are cached in a signed cookie for 5 minutes, so a deleted session survives that long — but an access tier is never cached.
EXTRA_ALLOWED_EMAILS exists because a fresh database has no teammates, which
would leave the whole app read-only. It is also a break-glass for admins who are
not themselves on the team.
Better Auth deletes session rows on sign-out and expiry, so they show who is
signed in now, not who has ever signed in. Every sign-in is therefore also
appended to a login_log table (email, name, access tier, IP, user agent,
timestamp),
which is never pruned:
SELECT "createdAt", email, access, "ipAddress" FROM login_log ORDER BY "createdAt" DESC LIMIT 20;In the Google Cloud console,
create an OAuth 2.0 Web application client and register an authorised redirect
URI for every origin you run on, each ending in /api/auth/callback/google:
http://localhost:3000/api/auth/callback/google
https://your-deployment.example.com/api/auth/callback/google
Google requires an exact full-path match, so a bare origin will not work.
Every endpoint lives under /api and requires auth: either a browser session
cookie, or a read-only key from READONLY_API_KEYS passed as
Authorization: Bearer <key> (or x-api-key). Read-only keys may only make GET
and HEAD requests; anything else returns 403. The exceptions are /api/auth/*,
which Better Auth serves and which must stay reachable to signed-out visitors;
the public OAuth discovery documents under /.well-known/; and /api/mcp,
which takes OAuth bearer tokens only — read-only keys do not work there.
Pages are gated too — an unauthenticated browser request is redirected to
/login.
The full contract is served as OpenAPI 3.1 from
/api/openapi — hand an agent the base URL
and a key and point it there. It is hand-written in
src/app/api/openapi/route.ts and must be kept
in sync whenever a route changes.
MCP clients (Claude, Claude Code, MCP Inspector, …) can connect to
<origin>/api/mcp over streamable HTTP for read-only tools: list_projects,
list_team_members, and get_allocations (filterable by date range, teammate,
or project). No key or pre-registration is needed — the app is its own OAuth
2.1 authorization server (clients self-register via Dynamic Client
Registration), and the user authenticates through the normal Google sign-in,
with the same access tiers as the browser: none-tier accounts cannot
complete the flow, and a revoked account gets 403 on every tool call.
claude mcp add --transport http allocate https://<origin>/api/mcpOn claude.ai or Claude Desktop, add it under Settings → Connectors → Add
custom connector (remote MCP servers in claude_desktop_config.json are
ignored). BETTER_AUTH_URL must be set to the deployment's public origin —
it is the OAuth issuer and the /api/mcp resource identity — but no extra
Google redirect URI is needed, since clients authorize against this app, not
Google.
The schema lives in prisma/schema.prisma. After
editing it:
pnpm exec prisma migrate dev --name describe_your_changeprisma generate runs automatically on install and build, emitting the client
to src/generated/prisma. data/seed.py imports the original allocations
spreadsheet into an empty database.
pnpm build compiles the app; pnpm start runs prisma migrate deploy before
booting, so migrations apply on release. Set every environment variable above in
the hosting platform, and add that deployment's callback URL to the Google OAuth
client.