All projects work/01 · production

JVB: a bespoke legal ERP

From WhatsApp and spreadsheets to a single system: intake, cases, deadlines, filings, billing and AI in one product, in production and used daily.

Year
2026 · in production
Role
Product, architecture, back end, front end and infra
Stack
TypeScript · React 19 · tRPC · Drizzle · PostgreSQL · MCP · Railway
Link
private client system
Central de comando do ERP: um prazo vencido e um vencendo hoje em destaque no topo, e abaixo os catorze módulos do sistema em cartões, de kanban de tarefas a limite de OAB.
command centre · the whole system on one screen hover to see it in colour

The problem

A small law firm ran the way almost every small firm runs: a spreadsheet for cases, WhatsApp for client contact, email for deadlines and a generic legal SaaS for the rest. The same information was typed into four places, deadlines showed up late, leads got lost inside personal chat threads, and nobody could answer "where is so-and-so’s case?" quickly.

What was missing was a tool that knew the firm’s whole flow. A lead arrives on WhatsApp, becomes a client, becomes a case, produces deadlines, hearings, filings and fees. Each piece of software covered one stretch of that path and none covered the seams between them, which is exactly where information went missing.

The decision

Build one system, with one database and one login, that follows the whole funnel instead of becoming another island. tRPC with Zod so the types are shared between client and server, which means renaming a field breaks the build before it breaks production. Drizzle for real SQL when the domain calls for it. Railway with a single replica, because speculative scale in a five-person firm only generates cost.

And a multi-provider AI layer of my own, so switching models is a line of configuration rather than an architectural decision.

The scale

The system covers intake, cases, deadlines and hearings, filings, intelligence on official court gazettes, billing and permission management, with action auditing, 2FA and a credential vault. All built, maintained and supported by one person.

124tables in Postgres
881tRPC procedures
3,500+automated tests
1developer

Counting a legal deadline is harder than it looks

The Brazilian Code of Civil Procedure counts deadlines in business days (art. 219), excludes the starting day (art. 224), rolls a due date falling on a non-business day forward (art. 224 §1) and suspends everything during the court recess from 20 December to 20 January (art. 220). "Business day" also has to account for movable holidays derived from Easter, court holidays and holidays specific to each individual court.

I wrote a pure engine, no calendar library, with all the arithmetic done in UTC at midday to rule out timezone and daylight-saving bugs. Easter comes from the Meeus/Jones/Butcher algorithm, every rule covered by tests. It is the most tested code in the system: nobody praises code that counts holidays, but if it is off by one day the client loses the case.

server/businessDays.ts private client repository
// Datas são strings ISO "YYYY-MM-DD". Toda a aritmética é feita em UTC ao meio-dia
// para evitar problemas de fuso/horário de verão.

function toUtcNoon(iso: string): Date {
  return new Date(`${iso}T12:00:00Z`);
}

// ...

// Motivo de um dia não ser útil (ou null se for útil). Considera, na ordem:
// fim de semana, recesso forense, feriado forense específico (extra) e feriado
// nacional/forense usual.
function reasonForNonBusinessDay(
  d: Date,
  extra: Map<string, string>
): string | null {
  if (isWeekend(d)) return d.getUTCDay() === 0 ? "Domingo" : "Sábado";
  if (isInRecess(d)) return "Recesso forense (20/12–20/01)";
  const iso = formatISO(d);
  if (extra.has(iso)) return extra.get(iso) || "Feriado forense";
  const names = holidayNamesForYear(d.getUTCFullYear());
  if (names.has(iso)) return `Feriado: ${names.get(iso)}`;
  return null;
}
Cadastro de prazo: o sistema mostra a data fatal calculada em 17/08/2026, segunda-feira, avisa que começou a contar em 01/08 e lista os dois dias não úteis que pulou, o sábado 15/08 e o domingo 16/08.
the deadline lands on a Saturday, so it rolls to Monday: it says which day it skipped and why hover to see it in colour

The firm talks to the system

The ERP exposes an MCP server with 67 tools, 46 read and 21 write. In practice a lawyer opens Claude and asks "what do I have today?", "log this deadline", "summarise this conversation". The system is what executes, under the permissions of the person who asked, and every call is logged.

The laborious part was authentication. For claude.ai to connect as a Custom Connector an API key was not enough: it needed a real OAuth 2.1 server, with dynamic client registration, authorization code, refresh and a consent screen of its own, which is where the person sees what they are authorising before they authorise it.

Then came the decision that scares you. The owner wanted Claude to send WhatsApp messages directly, without going through a draft. That changes the damage ceiling: it stops being "nothing leaves the system". A model in a loop, with one reasoning error, becomes dozens of messages on a real client’s phone.

Three guards hold the send back, each against a different way of going wrong. The sender is revalidated on the spot, and that check fails closed: without confirming they are still an active manager, nothing goes out. The connector’s reads fail open on purpose, because there a database hiccup should not bring anything down, but here the effect is external and there is no undo. The second is a daily send ceiling. The third is idempotency, and it uses no key table: the conversation itself is the source of truth, so an identical message, in the same place, inside a short window, is a retry and does not go out again.

The tools that call a model (transcribe audio, summarise a conversation) have a different problem and a different breaker. It sums the real dollar cost of the last 24 hours and refuses once that is blown, with a call count underneath as a net: the cost record is fire-and-forget and can go missing, and without that net one failed write would swing the whole breaker open.

67tools exposed over MCP
3guards before a message goes out
24hwindow of the cost breaker

What runs without anyone asking

There are 307 test files and more than 3,500 cases across unit and integration, on Vitest, plus two Playwright smoke tests that boot the real system.

Two things run on GitHub Actions. The first is a secret scan on every push and every pull request: if an API key, a password or a connection string lands in the diff, the check fails and it never reaches the main branch.

The second wakes at three in the morning, when nobody is on the system: a dump of the entire database, attachments included, encrypted with AES-256 and shipped outside the infrastructure, with thirty days of retention. That is not diligence, it is obligation. This is privileged client data from a law firm, and the LGPD, the Brazilian data protection act, does not accept "the platform should have had backups".

I know nothing about law

I am the only developer on the project and almost nothing here was decided alone. I did not know what makes a deadline peremptory, why a filing needs a second person to check it, or what makes a case turn urgent before it is due. The people who knew were the team who would use the system every day.

So every module came out of conversation: I brought the proposal, they pointed at where it would not survive the real routine, and the screen changed before it became code. Writing the software was the easy part. The hard part was building alongside people who understand the subject, and accepting that my first version almost always had to go through them before it became something anyone would actually use.

The healthcheck that poisoned the database

A real incident. The system learned its own public URL from the first request after a deploy, and the first request after a deploy is the platform’s healthcheck, carrying a Host that does not resolve publicly. The result: attachment links written to the database pointing at a host that does not exist.

The diagnosis ran from the symptom ("the attachment will not open") to the root cause, and the fix was a guard at the source plus an idempotent repair on boot for the data already written wrong. The user reported as a product failure something that happened in the infrastructure layer.

Doing less, on purpose

Every deferral in this project has a written trigger: virtualise the message list only above ~1,500 messages, move media to a bucket only when the volume justifies it. A five-person firm does not pay for SaaS-grade architecture, and the biggest risk for a solo developer is building for a problem that never arrives.

It was the first project where I was product and engineering at once, with a real user on the other side who loses money if I get it wrong. Working alone, I spent more energy deciding what to leave out than choosing the stack. And a good part of the work was not programming at all: it was understanding the procedural code, the electronic court system and the firm’s routine well enough to turn all of it into screens.