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

Scroll for the rest of the page
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.
anyneeds a// reason:comment beside it.noUncheckedIndexedAccessandexactOptionalPropertyTypesare both on, which makes most of them unnecessary.- Tests run against a real schema, spun up per run, not against mocks.
System
Clients
Operator browser
Next.js · React · TypeScript
Contract
Panel API
NestJS · TypeScript
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.
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
Services
The applications served
PHP
Built by another team
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.
1import { Inject, Injectable } from '@nestjs/common';2import { CapabilityNotSupportedError, type AppId } from '@tp/shared';3import {4 VOICE_ROOM_LIVE_FEED,5 type VoiceRoomLiveFeedPort,6} from '../port/voice-room-live-feed.port.js';7import {8 VOICE_ROOM_REPOSITORY,9 type VoiceRoomRepositoryPort,10 type LiveRoomFilter,11} from '../port/voice-room-repository.port.js';12import type { VoiceRoom, VoiceRoomKpis, LiveRoomFeed } from '../domain/voice-room.js';1314export interface LiveRoomsResult {15 rooms: VoiceRoom[];16 kpis: VoiceRoomKpis;17 /** Snapshot time of the live feed, or `null` when the feed was unavailable. */18 liveCountsAtMs: number | null;19 /** True when counts fell back to the denormalized DB columns (feed down). */20 stale: boolean;21}2223/** Overlay live counts (counts only) onto a DB room, keyed by roomId. */24function overlay(room: VoiceRoom, feed: LiveRoomFeed | null): VoiceRoom {25 const c = feed?.counts.get(room.roomId);26 return c27 ? {28 ...room,29 totalUserCount: c.totalUserCount,30 listenerCount: c.listenerCount,31 speakerCount: c.speakerCount,32 }33 : room;34}3536/** Sum the feed's live totals (unfiltered) so KPI cards match the live table. */37function sumLiveTotals(38 feed: LiveRoomFeed,39): Pick<VoiceRoomKpis, 'totalUsers' | 'totalSpeakers' | 'totalListeners'> {40 let totalUsers = 0;41 let totalSpeakers = 0;42 let totalListeners = 0;43 for (const c of feed.counts.values()) {44 totalUsers += c.totalUserCount;45 totalSpeakers += c.speakerCount;46 totalListeners += c.listenerCount;47 }48 return { totalUsers, totalSpeakers, totalListeners };49}5051@Injectable()52export class ListLiveRoomsUseCase {53 constructor(54 @Inject(VOICE_ROOM_REPOSITORY) private readonly repos: ReadonlyMap<AppId, VoiceRoomRepositoryPort>,55 @Inject(VOICE_ROOM_LIVE_FEED) private readonly feeds: ReadonlyMap<AppId, VoiceRoomLiveFeedPort>,56 ) {}57 async execute(input: { app: AppId; filter: LiveRoomFilter }): Promise<LiveRoomsResult> {58 const repo = this.repos.get(input.app);59 if (!repo) throw new CapabilityNotSupportedError(input.app, 'voiceRooms');60 const feedPort = this.feeds.get(input.app);6162 const [dbRooms, dbKpis, feed] = await Promise.all([63 repo.listLive(input.filter),64 repo.kpis(),65 feedPort ? feedPort.fetchLive() : Promise.resolve(null),66 ]);6768 const rooms = dbRooms.map((r) => overlay(r, feed));69 // Room/grace counts always from DB; live user/speaker/listener totals overlaid.70 const kpis = feed ? { ...dbKpis, ...sumLiveTotals(feed) } : dbKpis;71 return { rooms, kpis, liveCountsAtMs: feed?.generatedAtMs ?? null, stale: feed === null };72 }73}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.