Kurumsal
Telpass Panel
Şirketin işlettiği her uygulama için tek operasyon paneli. Hâlâ üretimde olan PHP panelin yerine geçiyor.

Sayfanın devamı için kaydırın
Neyin yerine geçiyor
Tek uygulama varken tek uygulama için yazılmış bir PHP panel var ve hâlâ üretimde. Yenisi çok uygulamalı bir omurga üzerine kuruluyor ve tek panel olarak çalışıyor. Yeni bir uygulama, bakılacak ikinci bir panel değil, bir konfigürasyon.
Buraya backend geliştirici olarak alındım. Bu panel ise full stack, MySQL şemasından rapor ekranlarına kadar.
Önce dondurduğum kararlar
47 karar kaydı tuttum; pahalı olanlar özelliklerden önce geldi:
- TypeORM yerine MikroORM. Domain’in gerçek aggregate’lere ve Unit of Work’e ihtiyacı vardı. Table mapper bu mantığı servislere iter, entity’leri içi boş bırakırdı.
- Domain katmanı hiçbir framework’ü import etmiyor. Repository’ler ORM satırı değil domain entity’si döndürüyor; NestJS kurallara hiç değmiyor.
anyyanında// reason:yorumu istiyor.noUncheckedIndexedAccessveexactOptionalPropertyTypesaçık, zaten çoğunu gereksiz bırakıyor.- Testler mock’a değil, her koşuda ayağa kalkan gerçek şemaya bakıyor.
Sistem
İstemciler
Operatör tarayıcısı
Next.js · React · TypeScript
Sözleşme
Panel API
NestJS · TypeScript
Uygulama
Use case'ler ve portlar
TypeScript
Neden
Tek bir use case her uygulamaya hizmet ediyor. Portlar uygulamaya göre anahtarlanmış bir map olarak geliyor; yeni bir uygulama eklemek ikinci bir panel değil, bir kayıt işlemi oluyor. Bir yeteneği karşılamayan uygulama ise yarım çizilmek yerine adıyla reddediliyor.
Kapsam ve yetkiler
CASL · TypeScript
Neden
Kapsam, bir uygulama ile bir yetkinin birleşimi ve sorgunun içinde değil girişte denetleniyor. Tek pazara ve salt okuma yetkisine sahip bir operatör, isteği düzenleyerek başka bir pazarın satırlarına ulaşamıyor.
Adaptörler
Okuma adaptörleri
MikroORM · MySQL · TypeScript
Neden
Her uygulama ve her yetenek için bir adaptör var; her biri o uygulamanın şemasını taşıyor. Bu katmanın üstündeki hiçbir yer bir kolon adı bilmiyor, şemaları farklı iki uygulama aynı soruya bu sayede cevap verebiliyor.
Çağrı adaptörleri
TypeScript
Servisler
Hizmet verilen uygulamalar
PHP
Başka bir ekip geliştiriyor
Durum
Her uygulamanın kendi veritabanı
MySQL
Başka bir ekip geliştiriyor
Panelin kendi veritabanı
MySQL · MikroORM
Neden
Panelin kendi tabloları: personel hesapları, kapsamlar, denetim kaydı. Her uygulamanın veritabanından bilinçli olarak ayrı duruyor, çünkü panel hepsinden uzun yaşıyor.
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.tsSatır 1 – 7373 satır
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 };
}
}- Uygulamanın üzerinden değil. Bir okuma adaptörü, tek bir uygulamanın kendi şemasına ait SQL'i tutuyor ve onu doğrudan çalıştırıyor. Operasyon ekibinin istediği raporlar, hiçbir uygulamanın dışarı açmadığı join'ler ve pencere fonksiyonları; bunları uygulamadan istemek, o sorguları kullanıcılara cevap veren sunucunun sırtına yıkmak olurdu.
İkinci geliştirici için yazıldı
Projede tek geliştirici var. Depo yine de üç akış işletiyor: migration,
features, platform. Yetenek bazlı milestone’lar ve biri katıldığı gün
doğrudan GitHub Issue etiketine dönüşen tag’ler var.
Gelmemiş bir geliştirici için yazmayı en çok savunurum. Onboarding’in bedeli ya yazarken ödenir ya da gelen kişiye ödetilir.
Katkı
- Yazılan commit
- 1.759
20 haftanın 19 tanesi aktif · 20 Nis 2026 – 6 Eyl 2026
En uzun seri · 14 hafta · Haz – Eyl
Yeni panelin her ekranı taşınana kadar eski panel ayakta kalıyor. Bu yüzden her hafta deploy edilebilir bir şeyle kapanmak zorunda.
Ölçüm 2026-09-08
Nereden okuyor
Ekranların çoğunda panel, mobil uygulamaların kullandığı API’nin bir istemcisi ve onlarda olmayan bir yetkisi yok.
Analitik sorgular ise doğrudan veritabanına gidiyor. Bir çeyreklik geçmiş üzerinde çalıştırılan rapor, oda girişlerine cevap veren sunucuya düşmemeli. Bu kısayola izin verilecek kadar güvenilen tek istemci de panel.