Command Palette

Search for a command to run...

Projects / Internal Admin Tool / Overview

Internal Admin Tool

A from-scratch admin platform giving Lifeed's Customer Success team self-serve ownership of clients, contracts, and entitlements — a Postgres-backed domain model where the invariants live in the database, not the form.

Year
2026
Role
Full-Stack Engineer
Focus
Backend · Platform
Status
Live
GoPostgreSQLVueTypeScriptREST

Problem

Growing a B2B eLearning platform means onboarding organizations, not just users. At Lifeed, every new client — their seat allowance, who's allowed to self-register, the onboarding fields their learners see, which parts of the product they've paid for — was configured by hand. Each change was an engineering ticket: a migration, a script, a deploy. That's slow for Customer Success and a poor use of engineering time, and every manual edit is a chance to put the system into a state the product's billing and access rules never expected.

The goal was self-serve tooling for a non-engineering team — and, harder than it sounds, tooling that can't be talked into an invalid state. When you hand a form to someone who isn't going to read the code, the form has to make the wrong thing impossible, not merely discouraged.

I built this one alone, from the domain model and interface design through to implementation.

Approach

  • The admin server owns the database outright. Customer Success edits clients and contracts through it, and the learner-facing platform never touches those tables — when the platform needs to know whether someone's access is valid, it sends an internal request and gets an answer. The platform can't construct a query, so it can't construct a wrong one, and authorization exists in exactly one codebase.
  • Invariants live in the database, not the form. Uniqueness, required relationships, and the rules that make a contract coherent are enforced by constraints — the last line of defence sits below the UI, below the API, and below any future caller, where nothing can bypass it.
  • Expiry is derived, not stored. A contract's validity is computed from its status and its date window at read time, in a single function every caller routes through, rather than saved as a status a scheduled job keeps flipping. There's no window where the database says "active" and reality disagrees.
  • The lifecycle is an explicit state machine — draft, active, suspended — with no hard delete. Cancellation is a reversible suspension, so an accidental change is recoverable and the history stays intact. It also removes delete's special-cased code path: suspension is governed by the same authorization and audit logic as every other transition, because it is every other transition.
  • Registration mode is a consequence of the data, not a separate switch. Whether a client is open to self-registration or restricted to specific email domains follows from whether any domain rules exist — so the two facts can never contradict each other.
  • Optimistic concurrency with a version guard. Two Customer Success users editing the same contract get a clean conflict instead of a silent last-write-wins overwrite. The one who loses the race is told, rather than discovering later that their change simply vanished.

Results

  • Customer Success self-serves the operations that used to be engineering tickets — provisioning clients, adjusting seats, changing registration rules, editing onboarding fields — with no deploy in the loop.
  • The system's invariants are guaranteed by the database, so a mistaken edit fails loudly at the boundary instead of quietly producing a client whose contract doesn't add up.
  • Manual, engineer-gated operations became a tool a non-engineering team owns outright — the operational flexibility compounding as the platform and its customer base grew.

Lessons

  • Removing a duplicate copy isn't the same as deciding who may touch the original. One database with two systems reading it has no sync problem and a real authorization problem. Putting a service boundary in front of the data — rather than trusting every consumer to scope its own queries — is what makes the guarantee hold.
  • Derived state beats stored state for anything you can compute, and the cost is knowing what you gave up: a predicate involving the current time can't be indexed or stored as a generated column, so you index its inputs and let the planner do the comparison.
  • When correctness lives in SQL and constraints, tests that mock the database test your mocks. Anything touching the data layer clears a merge gate against a real Postgres. Coverage is aimed at the functionality where being wrong costs something rather than spread evenly to reach a percentage — which takes more judgment to defend than a threshold does, and is worth more.
  • Building for non-engineers raises the bar on the model, not just the UI. There's no engineer in the loop to catch a bad edit, so "make invalid states unrepresentable" stops being a slogan and becomes the specification.

Go deeper