Enterprise

Telpass Panel

One operations panel for every application the company runs, replacing a PHP one that is still in production.

The panel's currency economy report. Date range, user type and gender filters across the top, and five summary figures beneath them: issued, spent, net change in circulation, transactions for the period, and the ratio between in and out. Below those, two ranked breakdowns of where currency enters and leaves, and a cumulative supply chart.

Scroll for the rest of the page

One report, over a quarter of ledger history. This is the screen that argues for reading the database directly.

What it replaces

A PHP admin panel, still in production, built for one application back when there was one application. The rewrite is one panel with a multi-app spine, so a new application is a configuration rather than another panel to maintain.

My title here is backend developer. This one is full stack, from the MySQL schema to the report screens.

The decisions I froze first

47 decision records, and the expensive ones came before the features:

  • MikroORM over TypeORM. The domain needed real aggregates and a Unit of Work. A table mapper would have pushed that logic into services and left the entities hollow.
  • The domain layer imports no framework. Repositories return domain entities, never ORM rows, so nothing about NestJS reaches the rules.
  • any needs a // reason: comment beside it. noUncheckedIndexedAccess and exactOptionalPropertyTypes are both on, which makes most of them unnecessary.
  • Tests run against a real schema, spun up per run, not against mocks.
KeyBuilt by another teamRuns on my serversDirect path
  1. Clients

    • Operator browser

      Next.js · React · TypeScript

  2. Contract

    • Panel API

      NestJS · TypeScript

  3. Application

    • Use cases and ports

      TypeScript

      Why

      One use case serves every application. The ports arrive as a map keyed by application, so adding one is a registration rather than a second panel, and an application that does not implement a capability is refused by name instead of half-rendered.

    • Scope and permissions

      CASL · TypeScript

      Why

      A scope is an application plus a permission, checked on the way in rather than inside a query. An operator with one market and read-only rights cannot reach another market's rows by editing a request.

  4. Adapters

    • Read adapters

      MikroORM · MySQL · TypeScript

      Why

      One adapter per application per capability, each holding that application's schema. Nothing above this tier knows a column name, which is what lets two applications with different schemas answer the same question.

    • Call adapters

      TypeScript

  5. Services

    • The applications served

      PHP

      Built by another team

  6. State

    • Each application's own store

      MySQL

      Built by another team

    • The panel's own store

      MySQL · MikroORM

      Why

      The panel's own tables: staff accounts, scopes, audit. Kept apart from every application's store on purpose, because the panel outlives any one of them.

One spine, many applicationsTypeScriptFrom Use cases and ports
import { Inject, Injectable } from '@nestjs/common';import { CapabilityNotSupportedError, type AppId } from '@tp/shared';import {  VOICE_ROOM_LIVE_FEED,  type VoiceRoomLiveFeedPort,} from '../port/voice-room-live-feed.port.js';import {  VOICE_ROOM_REPOSITORY,  type VoiceRoomRepositoryPort,  type LiveRoomFilter,} from '../port/voice-room-repository.port.js';import type { VoiceRoom, VoiceRoomKpis, LiveRoomFeed } from '../domain/voice-room.js';export interface LiveRoomsResult {  rooms: VoiceRoom[];  kpis: VoiceRoomKpis;  /** Snapshot time of the live feed, or `null` when the feed was unavailable. */  liveCountsAtMs: number | null;  /** True when counts fell back to the denormalized DB columns (feed down). */  stale: boolean;}/** Overlay live counts (counts only) onto a DB room, keyed by roomId. */function overlay(room: VoiceRoom, feed: LiveRoomFeed | null): VoiceRoom {  const c = feed?.counts.get(room.roomId);  return c    ? {        ...room,        totalUserCount: c.totalUserCount,        listenerCount: c.listenerCount,        speakerCount: c.speakerCount,      }    : room;}/** Sum the feed's live totals (unfiltered) so KPI cards match the live table. */function sumLiveTotals(  feed: LiveRoomFeed,): Pick<VoiceRoomKpis, 'totalUsers' | 'totalSpeakers' | 'totalListeners'> {  let totalUsers = 0;  let totalSpeakers = 0;  let totalListeners = 0;  for (const c of feed.counts.values()) {    totalUsers += c.totalUserCount;    totalSpeakers += c.speakerCount;    totalListeners += c.listenerCount;  }  return { totalUsers, totalSpeakers, totalListeners };}@Injectable()export class ListLiveRoomsUseCase {  constructor(    @Inject(VOICE_ROOM_REPOSITORY) private readonly repos: ReadonlyMap<AppId, VoiceRoomRepositoryPort>,    @Inject(VOICE_ROOM_LIVE_FEED) private readonly feeds: ReadonlyMap<AppId, VoiceRoomLiveFeedPort>,  ) {}  async execute(input: { app: AppId; filter: LiveRoomFilter }): Promise<LiveRoomsResult> {    const repo = this.repos.get(input.app);    if (!repo) throw new CapabilityNotSupportedError(input.app, 'voiceRooms');    const feedPort = this.feeds.get(input.app);    const [dbRooms, dbKpis, feed] = await Promise.all([      repo.listLive(input.filter),      repo.kpis(),      feedPort ? feedPort.fetchLive() : Promise.resolve(null),    ]);    const rooms = dbRooms.map((r) => overlay(r, feed));    // Room/grace counts always from DB; live user/speaker/listener totals overlaid.    const kpis = feed ? { ...dbKpis, ...sumLiveTotals(feed) } : dbKpis;    return { rooms, kpis, liveCountsAtMs: feed?.generatedAtMs ?? null, stale: feed === null };  }}
Every application is served by this one use case. The ports arrive as a map keyed by application, so adding one is a registration rather than another panel, and an application that does not implement a capability is refused by name instead of half-rendered. Live counts are overlaid on the stored rows, and when the feed is down the answer says so rather than quietly ageing.

tp_panel83ed0235apps/api/src/voice-rooms/application/list-live-rooms.usecase.tsLines 1 to 7373 lines

import { Inject, Injectable } from '@nestjs/common';
import { CapabilityNotSupportedError, type AppId } from '@tp/shared';
import {
  VOICE_ROOM_LIVE_FEED,
  type VoiceRoomLiveFeedPort,
} from '../port/voice-room-live-feed.port.js';
import {
  VOICE_ROOM_REPOSITORY,
  type VoiceRoomRepositoryPort,
  type LiveRoomFilter,
} from '../port/voice-room-repository.port.js';
import type { VoiceRoom, VoiceRoomKpis, LiveRoomFeed } from '../domain/voice-room.js';

export interface LiveRoomsResult {
  rooms: VoiceRoom[];
  kpis: VoiceRoomKpis;
  /** Snapshot time of the live feed, or `null` when the feed was unavailable. */
  liveCountsAtMs: number | null;
  /** True when counts fell back to the denormalized DB columns (feed down). */
  stale: boolean;
}

/** Overlay live counts (counts only) onto a DB room, keyed by roomId. */
function overlay(room: VoiceRoom, feed: LiveRoomFeed | null): VoiceRoom {
  const c = feed?.counts.get(room.roomId);
  return c
    ? {
        ...room,
        totalUserCount: c.totalUserCount,
        listenerCount: c.listenerCount,
        speakerCount: c.speakerCount,
      }
    : room;
}

/** Sum the feed's live totals (unfiltered) so KPI cards match the live table. */
function sumLiveTotals(
  feed: LiveRoomFeed,
): Pick<VoiceRoomKpis, 'totalUsers' | 'totalSpeakers' | 'totalListeners'> {
  let totalUsers = 0;
  let totalSpeakers = 0;
  let totalListeners = 0;
  for (const c of feed.counts.values()) {
    totalUsers += c.totalUserCount;
    totalSpeakers += c.speakerCount;
    totalListeners += c.listenerCount;
  }
  return { totalUsers, totalSpeakers, totalListeners };
}

@Injectable()
export class ListLiveRoomsUseCase {
  constructor(
    @Inject(VOICE_ROOM_REPOSITORY) private readonly repos: ReadonlyMap<AppId, VoiceRoomRepositoryPort>,
    @Inject(VOICE_ROOM_LIVE_FEED) private readonly feeds: ReadonlyMap<AppId, VoiceRoomLiveFeedPort>,
  ) {}
  async execute(input: { app: AppId; filter: LiveRoomFilter }): Promise<LiveRoomsResult> {
    const repo = this.repos.get(input.app);
    if (!repo) throw new CapabilityNotSupportedError(input.app, 'voiceRooms');
    const feedPort = this.feeds.get(input.app);

    const [dbRooms, dbKpis, feed] = await Promise.all([
      repo.listLive(input.filter),
      repo.kpis(),
      feedPort ? feedPort.fetchLive() : Promise.resolve(null),
    ]);

    const rooms = dbRooms.map((r) => overlay(r, feed));
    // Room/grace counts always from DB; live user/speaker/listener totals overlaid.
    const kpis = feed ? { ...dbKpis, ...sumLiveTotals(feed) } : dbKpis;
    return { rooms, kpis, liveCountsAtMs: feed?.generatedAtMs ?? null, stale: feed === null };
  }
}
  • Not through the application. A read adapter holds the SQL for one application's own schema and runs it directly, because the reports an operations team needs are joins and windows no application exposes, and asking an application for them would put those queries on the server answering its users.

Written for the second developer

There is one developer on this. The repository still runs three streams, migration, features and platform, with capability milestones and tags that map straight onto GitHub Issue labels the day somebody else joins.

Building for a developer who has not arrived is the part I would defend hardest. The cost of onboarding is paid while writing, or it is paid by the person who arrives.

Contribution

Commits authored
1,759

19 of 20 weeks active · 20 Apr 2026 – 6 Sep 2026

Longest run · 14 weeks · Jun – Sep

Both panels run at once until each screen is migrated, so the work is measured in weeks that ended with something deployable.

Measured 2026-09-08

Where it reads from

For most screens the panel is a client of the same API the mobile applications use, and it gets no privileges they do not have.

Analytical queries go straight to the database instead. A report somebody runs across a quarter of history should not land on the server answering room joins, and the panel is the only client trusted enough to be allowed the shortcut.