Kurumsal

Telpass Panel

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

Panelin coin ekonomi raporu. Üstte tarih aralığı, kullanıcı tipi ve cinsiyet filtreleri, altlarında beş özet rakam: basılan, harcanan, dolaşımdaki net değişim, dönemdeki işlem sayısı ve giriş çıkış oranı. Aşağıda coin'in nereden girip nereden çıktığını sıralayan iki döküm ve kümülatif arz grafiği.

Sayfanın devamı için kaydırın

Tek rapor, bir çeyreklik defter geçmişi. Veritabanını doğrudan okumayı savunan ekran bu.

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.
  • any yanında // reason: yorumu istiyor. noUncheckedIndexedAccess ve exactOptionalPropertyTypes açık, zaten çoğunu gereksiz bırakıyor.
  • Testler mock’a değil, her koşuda ayağa kalkan gerçek şemaya bakıyor.
GöstergeBaşka bir ekip geliştiriyorBenim sunucumda çalışıyorDoğrudan yol
  1. İstemciler

    • Operatör tarayıcısı

      Next.js · React · TypeScript

  2. Sözleşme

    • Panel API

      NestJS · TypeScript

  3. 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.

  4. 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

  5. Servisler

    • Hizmet verilen uygulamalar

      PHP

      Başka bir ekip geliştiriyor

  6. 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.

Tek omurga, çok uygulamaTypeScriptUse case'ler ve portlar düğümünden
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 };  }}
Her uygulamaya bu tek use case hizmet ediyor. Portlar uygulamaya göre anahtarlanmış bir map olarak geliyor; yeni bir uygulama eklemek bu yüzden ikinci bir panel değil, bir kayıt işlemi oluyor. Bir yeteneği karşılamayan uygulama, yarım çizilmek yerine adıyla reddediliyor. Canlı sayımlar kayıtlı satırların üzerine biniyor; besleme düştüğünde cevap bunu sessizce eskimek yerine söylüyor.

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.