A multi-tenant retrospective tool — Company → Project → Retrospective — that runs the meeting rather than just storing its output.
Super Admins sign in with a password; everyone else gets a 12-hour, single-use email link. There is no public sign-up: every account arrives by invitation from someone already inside the company or project.
What it does beyond a board of sticky notes:
- A guided session. The facilitator moves everyone through Lobby → Check-in → Collect → Group → Vote → Discuss → Actions → Closed together. Each step permits only what belongs to it, so the retro stops being eight activities happening at once.
- Safe ideation. During Collect nobody sees anyone else's cards — including the facilitator. Enforced by not fetching them, not by hiding them in CSS.
- Real anonymity. On an anonymous board the browser is never sent an author id, a voter id, or a member list to join them against. See Anonymity and privacy.
- Dot voting with a per-person budget and tallies hidden until voting closes, so early votes don't snowball.
- Guided discussion — the group works through cards in one server-decided order, so everyone is looking at the same one.
- Action items that survive the meeting. Real statuses (Open / In progress / Blocked / Done /
Dropped), assignees, due dates, carry-over into later retrospectives, a personal
/my-actionspage, and emailed reminders. - Cross-retro insights — participation, completion rate, time-to-close, board sentiment, recurring themes. Computed locally; no data leaves the deployment.
- Anonymous team health check-ins with k-anonymity thresholds, so a small team can answer honestly.
- Export to Markdown or CSV.
- Templates, drag-and-drop, card merging, comments, emoji reactions, a shared timer, @mentions, live presence and cursors — all synced over Socket.IO.
- Next.js 16 (App Router, TypeScript, strict) — single Node process, deploys as one Azure Web App
- Prisma 7 + PostgreSQL (via the
pgdriver adapter) - Auth.js (NextAuth v5) — two Credentials providers: password (Super Admins) and one-time login tokens (everyone else)
- Microsoft Graph (
sendMail, app-only auth) for all outgoing email - Socket.IO, attached to a custom
server.tsalongside the Next.js request handler - @platned/ui design system, @dnd-kit for the board, Tailwind 4
- Vitest against a real Postgres — 230 tests, no mocked database
npm install
cp .env.example .env # then fill it in — see below
npm run devThat is the whole setup. Point DATABASE_URL at an empty Postgres and the first start creates
every table, index and constraint, then creates your Super Admin. Open http://localhost:3000 and
sign in with SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD.
The values you need in .env:
| Variable | Notes |
|---|---|
APP_NAME |
Shown in the header next to the logo. Defaults to "Agile Retro" if unset |
DATABASE_URL |
An empty database is fine — it will be set up for you. Tested on Postgres 16 and 18 |
AUTH_SECRET |
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" |
APP_URL |
http://localhost:3000 in dev |
SUPER_ADMIN_EMAIL, SUPER_ADMIN_PASSWORD, SUPER_ADMIN_NAME |
Used once, to create the first account |
GRAPH_* |
Optional locally — see below |
Email is optional in development. Without the GRAPH_* values, sending fails and is logged;
everything except emailed sign-in links still works, and you can sign in as the Super Admin with a
password. To enable it, register an Entra ID app with the Mail.Send application permission
(admin consent granted) and set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET and
GRAPH_SENDER_EMAIL.
Everyone other than the seeded Super Admin has to be invited. Use that account to create the first company and project, then "Invite" on a project or company page.
This is a template repo — forks are expected to relabel it for their own deployment. Two things control that:
APP_NAME— set it and the header shows your name next to the logo instead of the default "Agile Retro". It only affects the header; page titles, emails and other copy are unaffected.- "Powered by Open Retro" underneath it is hardcoded, not configurable — it always credits the underlying project, the same way a white-label product still names the platform it's built on.
There is no other per-fork naming to change in the app itself. The project's own internal name
(package.json, the test-database container, CI comments) is open-retro — none of it should say
"RnI" or "R&I" anywhere; if you find an instance, it's a leftover that should be fixed.
On every start, before a single request is served, the app:
- Asks which migrations are applied — one query against
_prisma_migrations. - Applies any that are pending, via
prisma migrate deploy. - Creates the Super Admin only if the
Usertable is completely empty.
An up-to-date database costs about 15ms, because the Prisma CLI is only invoked when something is
genuinely pending — tsx watch restarts stay fast.
Seeding never updates an existing user, so a restart can't undo a password somebody changed. Two deliberate consequences:
- To re-provision an admin on purpose, run
npm run db:seed— that one does overwrite. - If every admin is deliberately demoted, a restart won't conjure a new one from environment variables. The rule is "no users at all", not "no Super Admin".
If setup fails the process exits rather than serving against a half-built schema, and says what
went wrong in terms you can act on. npm run db:migrate / npm run db:deploy still apply
migrations by hand, and AUTO_MIGRATE=false turns the automatic path off entirely.
Worth reading before trusting this with candid feedback, because these are properties of the queries rather than the UI.
- Anonymous boards. The payload sent to the browser contains no
authorId, nouserIdon votes or reactions, and no project member list. There is nothing to join, so the usual open-dev-tools-and-match trick has no key to work with. Cards arrive withisOwnandauthorNamealready decided by the server, and the shape is identical whether or not the board is anonymous — one code path, so redaction cannot be forgotten for one of them. - Safe ideation. During Collect, other people's cards are not fetched. The facilitator is not exempt. Once the board has been revealed the flag latches off: content that has been read cannot be un-read, and the settings dialog refuses to pretend otherwise.
- Health check-ins store no user id at all. A submission is keyed by
HMAC-SHA256(userId, retro.healthSalt), with a fresh salt per retrospective — so you can amend your own answer, but nobody (including someone with database access) can attribute a score or follow one person across retrospectives. Nothing is reported below 3 submissions, and distributions wait for 5. - AI is off by default, per company.
Company.aiFeaturesEnabledgates any future model call, and a platform Super Admin cannot bypass it — only the company's own admins can turn it on. Insights and themes are computed locally and work identically either way. - Emails never reach the browser. Queries select
{ id, name }for anything rendered next to content; addresses are looked up server-side at the point of sending.
npm run test:db:up # throwaway Postgres on port 5433 (Docker)
npm test
npm run test:db:downThe suite runs against a real database, not a mock: most of what it protects are query-shape
concerns — authorization scoping, payload redaction, migration correctness — that a mocked client
would happily accept. It refuses to run unless DATABASE_URL points at a local database whose name
ends in _test.
npm run test:watch for a watcher. CI runs the same suite against a Postgres service container, so
a green run also proves every committed migration still applies to an empty database.
Socket.IO needs a long-lived http.Server to attach a WebSocket upgrade handler to. server.ts
creates that server once, hands HTTP requests to Next's own handler, and runs Socket.IO alongside it
in the same process — so the whole app is still one deployable Node process, started with tsx server.ts instead of next start.
Two consequences worth knowing:
next.config.tsmust not setoutput: "standalone"— Next doesn't trace custom server files in that mode.- Nothing in
server.ts's import graph may useserver-only, which throws when imported outside the react-server condition. That is whysrc/lib/socket/roomAccess.tsandsrc/server/bootstrap.tsare deliberately standalone. Anything needing the app's own modules goes insrc/instrumentation.tsinstead, which Next bundles.
| Variable | Used for |
|---|---|
APP_NAME |
Display name shown next to the logo in the header. Defaults to "Agile Retro" if unset — see Branding |
DATABASE_URL |
Postgres connection (Prisma) |
AUTH_SECRET |
Session JWT signing/encryption (Auth.js) |
APP_URL |
Absolute links in emails, and the base URL Auth.js uses for every sign-in/sign-out redirect (see below) |
GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER_EMAIL |
Sending email via Microsoft Graph |
SUPER_ADMIN_EMAIL, SUPER_ADMIN_PASSWORD, SUPER_ADMIN_NAME |
Creating the first account on an empty database |
PORT |
Port server.ts listens on (Azure sets this automatically) |
AUTO_MIGRATE |
Defaults on. Applies pending migrations at startup and seeds the first Super Admin on a completely empty database. "false" skips both |
RUN_SCHEDULER |
"true" on exactly one instance enables due-date reminders and cleanup. Unset everywhere else, including every developer machine |
None of these belong in source control — .env* is git-ignored except .env.example.
APP_URL doubles as Auth.js's base URL. server.ts sets AUTH_URL = APP_URL at startup, so
every redirect is anchored to it rather than depending on the Host header surviving whatever
reverse proxy sits in front (Azure App Service's included). Get it wrong in production and
everything breaks the same way: emailed links, sign-out, sign-in redirects. server.ts refuses to
start in production if APP_URL is unset or still contains localhost.
Set RUN_SCHEDULER=true on exactly one instance to enable:
- Due-date reminders — one email per action item, ever. The marker is written whether or not the send succeeds, so a 15-minute sweep can never become a daily nag.
- Cleanup of spent login tokens and invitations that lapsed unaccepted. Accepted invitations are
kept:
acceptedAtis the only record of how somebody came to be in a company.
It is an interval in the web process rather than a queue — deliberate for a single-instance
deployment, and RUN_SCHEDULER is what makes that reversible when it isn't one.
Never set it on a developer machine. Your
.envpoints at a real database and holds real Graph credentials, so the process will delete rows and email real people. The scheduler refuses to start ifAPP_URLis unset, localhost, or a reserved test domain, which catches the common form of that mistake but not all of it.
A single Node process, so it deploys as an ordinary Azure Web App (Linux, Node 20 LTS) — no
separate services needed. This is a template repo: fork it and connect your fork to your Web
App — .github/workflows/ci.yml deploys nothing from the upstream repo itself.
-
GitHub Actions secrets — on your fork, Settings → Secrets and variables → Actions:
AZURE_WEBAPP_PUBLISH_PROFILE(secret) — Portal → your Web App → Overview → Get publish profile downloads a.PublishSettingsfile (XML). Open it in a text editor and paste the entire contents in as the secret's value.AZURE_APP_NAME(variable, not secret) — the Web App's name as shown in the Portal, used to target the right app in the deploy step.
Newer Web Apps disable basic auth by default, which both the publish-profile download and the workflow's Kudu zip-deploy step depend on: Configuration → General settings → SCM Basic Auth Publishing Credentials: On.
No database credential is needed in GitHub at all — see step 3 and "How the database sets itself up" above.
-
Runtime & startup
- Stack: Node 20 LTS.
- Deployment is a zip push from GitHub Actions on every push to
main(see.github/workflows/ci.yml). The workflow builds the app itself —npm ci,npm run build, thennpm prune --omit=dev— and ships the finished result, including.nextand a production-onlynode_modules. Azure builds nothing: leaveSCM_DO_BUILD_DURING_DEPLOYMENTunset (orfalse). This is deliberate, not an optimization — Oryx's own build sandbox proved unreliable for this app (multi-minute hangs midnpm install, and separately, zip deploys that silently skip the build entirely unless that setting is explicitlytrue, serving a stale.nextfrom whenever it last happened to build). Building once, in GitHub Actions, and shipping the result sidesteps all of that. Don't also wire up the Deployment Center: two deployers racing each other is worse than either alone. - Startup command: leave default (
npm start), which runsNODE_ENV=production node --import tsx server.tsand honours thePORTApp Service injects. Deliberately nottsx server.ts(tsx's own bin) orcross-env ...(tried first): Azure's Zip Deploy "node project optimizer" re-tars and re-extractsnode_modulesat container startup, and that round-trip was corrupting whichever package's.bin/shim the start command happened to invoke — firstcross-env's, thentsx's own, each a different crash on startup.node --import tsxresolvestsxthrough its packageexportsmap instead of a.binsymlink, sidestepping the whole class of bug. Production only ever runs on Linux, so the plain shell env-var syntax is fine too.
-
Application settings — add every row from the table above as an Application Setting, including
AUTO_MIGRATE=true(the default — nothing else applies migrations, since the pipeline deliberately doesn't). SetAPP_URLto the realhttps://<your-app>.azurewebsites.netor custom domain.Also add
WEBSITE_RUN_FROM_PACKAGE=1. Without it, Kudu re-compresses and re-extracts the entirenode_moduleson every deploy and every container start — 20-25s of CPU each time, which on a constrained plan (Free F1's 60 CPU-minutes/day, for one) a handful of restarts can exhaust outright, and which was separately observed corrupting.bin/shim symlinks in the process.WEBSITE_RUN_FROM_PACKAGEmounts the deployed zip directly instead of re-processing it — instant startup, nothing to corrupt.This only works because the app doesn't write to its own deployment directory at runtime —
images.unoptimizedinnext.config.tsturns off the one thing that did (next/image's optimizer cache) specifically so this mount can be read-only. It also only works because this pipeline builds in GitHub Actions and ships a finished app: run-from-package's mount is read-only, so pairing it with a server-side build (Oryx installing/building into that same directory) breaks outright — don't reintroduceSCM_DO_BUILD_DURING_DEPLOYMENT=truealongside this setting. If the app ever needs to write to disk at runtime again, this is the setting to revisit first. -
Enable WebSockets — Configuration → General settings → Web sockets: On. Without it Socket.IO falls back to polling or fails outright.
-
Enable Always On — prevents the app idling out and dropping every open WebSocket.
-
Database firewall — allowlist the Web App's outbound traffic on the Postgres server (firewall/security group and
pg_hba.conf, if it also restricts by host — both layers exist independently and both need an entry). Two caveats we hit ourselves:- A
pg_hba.confedit needs a reload (SELECT pg_reload_conf();) — it does not take effect until then. - Standard App Service tiers have no single stable outbound IP. Azure publishes a pool (Networking → Outbound IP addresses) and any of them may be used. Allowlist the whole pool, or — more robust, and the honest recommendation if this matters for your security posture — put the Web App behind Regional VNet Integration with a NAT Gateway for one fixed egress IP.
- A
-
Migrations and the first admin are automatic; nothing to run by hand for a first deploy, and nothing in GitHub Actions touches the database — the Web App applies its own migrations at startup, driven entirely by
AUTO_MIGRATEand theDATABASE_URLyou set in step 3.That startup step applies hand-written migrations to production unattended, and some are destructive. Before releasing one you haven't rehearsed against a copy, take a backup with
npx tsx scripts/backup.ts <file>and check it afterwards withnpx tsx scripts/compare-rows.ts <file>.scripts/restore-rows.tsputs rows back.The
deployjob declaresenvironment: production, so adding a required reviewer under Settings → Environments turns the release into a one-click approval. Worth doing. -
Single-instance note — Socket.IO room broadcasting assumes one Node process. Azure's default ARR affinity makes this correct even on a multi-instance plan, as long as you don't rely on cross-instance broadcast. For true fan-out, add the Socket.IO Redis adapter; nothing else changes, because every emit already goes through
getIO()/broadcastToRetro().
server.ts Custom Next.js + Socket.IO entrypoint (dev and prod)
src/instrumentation.ts Runs once at server start: scheduler, error reporting
src/proxy.ts Route protection (Next 16's replacement for middleware.ts)
prisma/schema.prisma Company → Project → Retrospective data model
prisma/migrations/ Hand-written, committed migration history
src/app/(app)/ Authenticated shell: dashboard, admin, companies, projects,
retros, /my-actions, /projects/:id/insights
src/app/api/ Auth.js route, and the retro export endpoint
src/components/retro/ The board: columns, cards, phase bar, discussion, health, timer
src/lib/authz.ts require* guards — the authorization vocabulary
src/lib/retroPhases.ts The phase rulebook, shared by server and UI so they can't disagree
src/lib/health.ts Check-in dimensions, scale and k-anonymity thresholds
src/lib/export/ Markdown and CSV formatters
src/lib/insights/ Local theme extraction — no model calls
src/lib/socket/ Socket.IO singleton, typed events, client hook
src/lib/email/ Microsoft Graph mailer and HTML templates
src/server/bootstrap.ts Startup migration and first-admin seeding
src/server/queries/ Reads. Plain server-only modules, never Server Actions
src/server/actions/ The Server Action boundary — thin wrappers returning ActionResult
src/server/retro/ Retro domain logic: lifecycle, cards, action items, phases, health
src/server/validation/ Zod schemas, one module per action module
src/server/scheduler/ Due-date reminders and cleanup jobs
tests/ Vitest suite, run against a real Postgres
scripts/ Backup, restore, row comparison and migration pre-flight tooling
Reads throw; mutations return results. Queries in src/server/queries/ throw NotFoundError /
ForbiddenError, which Server Components turn into notFound() or an error page. Server Actions
return ActionResult<T> instead, because Next.js replaces a thrown error's message with an opaque
digest in production — a carefully worded refusal would otherwise reach users as "An error
occurred". Both conventions are deliberate; don't unify them.