Bloga Dön
React NativeNestJSPush Notifications

Konum Tabanlı Push Notification: Sıfırdan Kendi Bildirim Altyapınızı Kurmak

27 Ağustos 202612 dk okuma
Konum Tabanlı Push Notification: Sıfırdan Kendi Bildirim Altyapınızı Kurmak

Mobil uygulamanızda push notification sistemi kurmak istediğinizde ilk akla gelen genellikle OneSignal, Firebase Cloud Messaging, Braze veya Dengage gibi third-party servisler oluyor. Bu araçlar hızlı başlangıç için cazip. Ama "kullanıcının anlık konumuna göre bildirim gönder" dediğiniz anda işler değişiyor; hiçbiri bunu hazır sunmuyor.

Kariyer, sipariş takibi, teslimat gibi Tam Konum (Precise Location) datasına sahip olması gereken uygulamalarda bu ihtiyaç kaçınılmaz: kullanıcı belirli bir bölgedeyken o bölgedeki yeni iş ilanını, yakınındaki kampanyayı ya da teslimat durumunu anında bildirmek istiyorsunuz. Bu yazıda APNs ve FCM API'lerine doğrudan bağlanan, cihazdan anlık konum toplayan ve PostGIS ile geo-targeted bildirim gönderebilen bir sistemi sıfırdan nasıl kurduğumu anlatacağım. Hem React Native (mobil) hem NestJS (backend) tarafındaki mimariyi, kodlarıyla birlikte inceleyeceğiz.

Hangi Durumlarda Kendi Notification Sistemimizi Kurmalıyız?

  • Konum tabanlı hedefleme: OneSignal ve Braze gibi servisler geofence tabanlı bildirim sunuyor: önceden bir alan çizersiniz, kullanıcı o alana girdiğinde tetiklenir. Ancak amacımız yeni bir içerik yayınlandığında (iş ilanı, kampanya, teslimat noktası) sunucu tarafında yakındaki kullanıcıları bulup otomatik push göndermekse, bu "içerik tetiklemeli, sunucu taraflı geo-matching" akışını hazır sunan bir servis yok.
  • Maliyet kontrolü: Third-party servislerde cihaz başına veya bildirim başına ücretlendirme var. APNs ve FCM ile kendi bildirim altyapınızı kurmak ise tamamen ücretsiz.
  • Veri sahipliği: Kullanıcı cihaz bilgileri, konum verileri ve bildirim tercihleri tamamen kendi veritabanımızda.
  • Esneklik: Sessiz (silent) bildirimler, çoklu uygulama desteği gibi ihtiyaçları üçüncü taraf arayüzlerinden bağımsız çözebiliyoruz.
  • Vendor lock-in yok: Altyapıyı istediğimiz zaman taşıyabilir veya değiştirebiliriz.

Mimari Genel Bakış

Bu yazıda sistemi React Native (mobil), GraphQL (API gateway) ve NestJS (bildirim mikroservisi) üzerinden anlatacağım; iletişim katmanı olarak da RabbitMQ kullanıyorum. Siz kendi teknoloji yığınınıza göre rahatlıkla uyarlayabilirsiniz.

Mimari Genel Bakış

Mobil Taraf: Cihaz Kaydı ve Token Yönetimi

Push notification sisteminin mobil tarafı üç temel sorumluluk üzerine kurulu: izin alma, platform token'ı edinme ve backend'e kayıt. Tüm bu işleri bir React Provider bileşeninde yönetiyoruz.

Stabil Cihaz Kimliği

Her cihazın backend'de benzersiz bir kaydı olması gerekiyor. Ama DeviceInfo.getUniqueId() bazı durumlarda uygulama silme/yükleme sonrası değişebiliyor. Bu yüzden iki katmanlı bir yaklaşım izliyoruz:

typescript
const KEYCHAIN_SERVICE = 'company.device.id';

export const getOrCreatePushDeviceId = async () => {
  // 1. Keychain'den dene (uygulama silinse bile kalır)
  const stored = await Keychain.getGenericPassword({
    service: KEYCHAIN_SERVICE
  });
  if (stored?.password) return stored.password;

  // 2. OS'ten al, Keychain'e kaydet
  const osDeviceId = await DeviceInfo.getUniqueId();
  if (osDeviceId) {
    await Keychain.setGenericPassword(KEYCHAIN_ACCOUNT, osDeviceId, {
      service: KEYCHAIN_SERVICE,
      accessible: Keychain.ACCESSIBLE.ALWAYS,
    });
    return osDeviceId;
  }
  return null;
};

iOS'ta Keychain uygulama silme sonrasında bile verileri korur (aynı provisioning profile ile yeniden yüklendiğinde). Android'de ise Keystore üzerinden benzer bir kalıcılık sağlanıyor. Bu sayede kullanıcı uygulamayı silip tekrar yüklese bile aynı cihaz ID'siyle tanınıyor.

Platform Token'ı Alma: APNs vs FCM

iOS ve Android'de push token alma süreci farklı çalışıyor. iOS'ta önce APNs'e kayıt olup APN token'ını almak gerekiyor. Bu token bazen hemen hazır olmadığı için retry mekanizması ekliyoruz:

typescript
export const getPushToken = async () => {
  if (Platform.OS === 'ios') {
    const status = await messaging().hasPermission();
    if (status === AuthorizationStatus.NOT_DETERMINED) {
      await messaging().requestPermission();
    }

    await messaging().registerDeviceForRemoteMessages();
    const apnsToken = await getApnsTokenWithRetry();
    return { pushProvider: 'APNS', pushToken: apnsToken };
  }

  // Android: FCM token'ı doğrudan alınır
  const fcmToken = await messaging().getToken();
  return { pushProvider: 'FCM', pushToken: fcmToken };
};

APNs token'ı neden hemen gelmez? iOS'ta registerDeviceForRemoteMessages() çağrısı asenkrondur ve token sistem tarafından hazırlandığında gelir. Soğuk başlangıçta (cold start) bu birkaç yüz milisaniye sürebilir. Bu yüzden [0, 400, 800, 1500, 2500] ms aralıklarla 5 deneme yapıyoruz.

PushProvider: Orkestrasyon Katmanı

Tüm bu parçaları bir araya getiren bileşen PushProvider. Uygulama açıldığında, token yenilendiğinde, uygulama ön plana geldiğinde veya oturum değiştiğinde cihaz kaydını tetikliyor:

tsx
export const PushProvider = ({ children }) => {
  const { tokens, user } = useAppSelector(state => state.auth);
  const location = useAppSelector(state => state.device.location);

  // Uygulama açılışında kayıt
  useEffect(() => {
    requestNotificationPermission();
    syncDevice(undefined, { reason: 'boot' });
  }, []);

  // Firebase token yenilendiğinde
  useEffect(() => {
    return messaging().onTokenRefresh(() => {
      syncDevice(undefined, { reason: 'token-refresh' });
    });
  }, []);

  // Oturum kapandığında userId'yi null yap
  useEffect(() => {
    if (!isAuthenticated) {
      syncDevice(null, { reason: 'auth-reset' });
    }
  }, [isAuthenticated]);

  // Uygulama ön plana geldiğinde (30sn cooldown ile)
  useEffect(() => {
    const sub = AppState.addEventListener('change', state => {
      if (state !== 'active') return;
      if (Date.now() - lastCheck < 30000) return;
      syncDevice(undefined, { reason: 'app-active' });
    });
    return () => sub.remove();
  }, []);

  return <>{children}</>;
};

Deduplikasyon: Gereksiz API Çağrılarını Engelleme

Her senkronizasyonda tüm cihaz bilgilerini (model, OS, izinler, konum, token) birleştirip bir "imza" oluşturuyoruz. Bu imza bir önceki kayıtla aynıysa API çağrısı yapılmıyor:

typescript
const signature = createPayloadSignature(payload);
const lastSignature = await storage.getItem(
  STORAGE_KEYS.PUSH_LAST_SYNC_SIGNATURE
);

if (lastSignature === signature && registrationId) {
  return payload; // Değişiklik yok, API çağrısı atlanır
}

İmza oluşturulurken syncedAt alanı sıfırlanıyor. Böylece sadece gerçek veri değişiklikleri (yeni token, konum güncellenmesi, izin değişikliği) kayda neden oluyor.

Apollo Client Entegrasyonu

Backend'e her GraphQL isteğinde cihaz kimliğini gönderiyoruz. Apollo Client'a özel bir middleware ekleyerek, her isteğe cx-device-id header'ı ekleniyor. Kayıt ID'si yoksa otomatik olarak cihaz senkronizasyonu tetikleniyor.

Retry Stratejisi

Ağ hataları veya token hazır olmama durumları için exponential backoff + jitter stratejisi uyguluyoruz. Bekleme süreleri: 5sn → 15sn → 45sn → 2dk → 5dk. Her süreye 0.85x–1.15x arası rastgele bir çarpan uygulanıyor. Bu, birden fazla cihazın aynı anda retry yapmasını (thundering herd) engelliyor.

Native Konfigürasyon

iOS Tarafı:

  1. Entitlements dosyası: aps-environment anahtarı development veya production olarak ayarlanmalı.
  2. AppDelegate'te Firebase başlatma: FirebaseApp.configure() çağrısı yeterli. APNs delegate metotlarını @react-native-firebase/messaging method swizzling ile otomatik yönetiyor.
  3. GoogleService-Info.plist: Firebase projesinden indirilen yapılandırma dosyası. GCM etkinleştirilmiş olmalı.

Not: Bildirim izni için react-native-permissions kullanmıyoruz. iOS tarafında Firebase Messaging kendi izin yönetimini yapıyor. Android 13+ (API 33) için ise PermissionsAndroid.request(POST_NOTIFICATIONS) yeterli.

Android Tarafı:

google-services.json dosyası ve birkaç manifest izni gerekiyor. WAKE_LOCK iznindeki tools:remove="android:maxSdkVersion" kritik: Firebase'in ReactNativeFirebaseMessagingReceiver bileşeninin arka planda bildirimleri işleyebilmesi için API 26+ üzerinde de bu iznin geçerli olması gerekiyor.

Backend: APNs ve FCM'e Doğrudan Bağlantı

Bildirim servisini ana uygulamadan bağımsız bir mikroservis olarak ayırdım. Ana uygulama ile iletişimi RabbitMQ üzerinden asenkron mesajlaşma ile sağlıyorum; böylece bildirim gönderimi ana iş akışını bloke etmiyor.

APNs Entegrasyonu: HTTP/2 ile Doğrudan Bağlantı

Apple'ın APNs servisine herhangi bir SDK veya wrapper kullanmadan, Node.js'in native http2 modülü ile bağlanıyoruz. Kimlik doğrulama için JWT (ES256) kullanıyoruz:

typescript
import * as http2 from 'http2';
import * as jwt from 'jsonwebtoken';

private getJWT(creds: ApnsCredentials): string {
  const privateKey = fs.readFileSync(creds.keyPath, 'utf8');
  return jwt.sign({}, privateKey, {
    algorithm: 'ES256',
    keyid: creds.keyId,
    issuer: creds.teamId,
    expiresIn: '1h',
  });
}

async send(deviceToken, payload) {
  const host = isProduction
    ? 'api.push.apple.com'
    : 'api.sandbox.push.apple.com';

  const client = http2.connect(`https://${host}`);

  const req = client.request({
    ':method': 'POST',
    ':path': `/3/device/${deviceToken}`,
    'authorization': `bearer ${jwt}`,
    'apns-topic': creds.bundleId,
    'apns-push-type': payload.silent ? 'background' : 'alert',
    'apns-priority': payload.silent ? '5' : '10',
  });
}

JWT caching: APNs JWT token'ları 1 saat geçerli. Biz 50 dakika boyunca cache'liyoruz, böylece her bildirimde yeniden imzalama yapılmıyor. Cache anahtarı keyId:teamId çifti; birden fazla uygulama için farklı credential'ları destekliyor.

FCM Entegrasyonu: v1 HTTP API

Android bildirimleri için FCM v1 API kullanıyoruz. Legacy API'den (sunucu anahtarı ile çalışan eski yöntem) farklı olarak, v1 API OAuth2 kimlik doğrulaması gerektiriyor ve daha zengin bir payload yapısı sunuyor:

typescript
import { GoogleAuth } from 'google-auth-library';

async send(deviceToken, payload) {
  const auth = new GoogleAuth({
    keyFile: creds.serviceAccountPath,
    scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
  });

  const accessToken = await auth.getClient().getAccessToken();

  await fetch(
    `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${accessToken}` },
      body: JSON.stringify({ message: { token: deviceToken, ... } }),
    }
  );
}

Payload Yapısı: iOS vs Android

Her iki platformun beklediği payload formatı farklı. Backend aynı içerikten iki farklı yapı üretiyor:

iOS (APNs):

json
{
  "aps": {
    "alert": {
      "title": "Bölgende yeni fırsat!",
      "body": "Sana yakın yeni içerikler var."
    },
    "sound": "default"
  }
}

Android (FCM v1):

json
{
  "message": {
    "token": "fcm_token...",
    "notification": {
      "title": "Bölgende yeni fırsat!",
      "body": "Sana yakın yeni içerikler var."
    },
    "android": {
      "priority": "high",
      "notification": { "sound": "default" }
    }
  }
}

Çoklu Uygulama Desteği: Credential Resolver

Sistemimiz birden fazla uygulamaya bildirim gönderebiliyor. Bunu PushCredentialResolver servisi ile yönetiyoruz. Çözümleme öncelik sırası: platform:appIdappId → global environment değişkenleri. Config dosyası yoksa veya bozuksa sistem eski davranışa (global env) geri dönüyor. Mevcut bildirimler hiçbir zaman bozulmuyor.

Cihaz Kaydında Toplanan Veriler

Her senkronizasyonda cihazdan kapsamlı bilgiler toplanıp backend'e gönderiliyor:

KategoriAlanlar
KimlikdeviceId, platform, pushProvider, pushToken, userId
UygulamaappId, appVersion, buildNumber
CihazphoneType, deviceModel, manufacturer, osName, osVersion, osApiLevel
timezone, locale
Bildirim İzinleripermissionStatus, appNotificationsEnabled + platform flags
Konumlatitude, longitude, accuracy, capturedAt

Backend bu verileri Prisma ile PostgreSQL'de saklıyor. Konum verisi zaman serisi olarak son 10 snapshot tutularak saklanıyor.

Bildirim Pipeline'ı: Uçtan Uca Akış

Bir bildirim tetiklenmesinden kullanıcının telefonuna ulaşmasına kadar geçen süreç:

  1. Domain event tetiklenir: Örneğin yeni bir kampanya yayınlanır veya kullanıcı belirli bir konuma girer. İlgili consumer servisi bu eventi yakalar.
  2. Template çözümlenir: Bildirim template'i dil ve tür bazında veritabanından çekilir. Mustache ile dinamik alanlar doldurulur.
  3. In-App kaydı oluşturulur: Veritabanına bir NotificationUser kaydı eklenir.
  4. Job kuyruğa eklenir: NotificationJob oluşturulur, öncelik hesaplanır (Urgent → 0, Low → 4) ve RabbitMQ'ya gönderilir.
  5. Queue worker işler: Mesaj alınır, kanal türüne göre (Email / SMS / InApp) yönlendirilir.
  6. Push gönderilir: Kullanıcının tüm kayıtlı cihazları çekilir. iOS'a APNs, Android'e FCM üzerinden Promise.allSettled() ile paralel gönderilir.
  7. Sonuç kaydedilir: Sent / PartiallySent / Failed / NoDevice durumu iş kaydına yazılır.

Retry Mekanizması

Başarısız gönderimler için backend'de 60 saniyede bir çalışan bir tarayıcı var. Atomic claiming ile çift işlemeyi önlüyor. Max 3 deneme ve 5 dakika aralıkla retry yapılıyor. 24 saatten eski job'lar expire ediliyor.

Konum Tabanlı Bildirimler: Sistemin Farkı

Bu sistemin en güçlü yanı, ve bizi third-party servislerden ayıran asıl özellik, anlık konum verisi toplama ve buna dayalı bildirim gönderme altyapısı.

Mobil tarafta her cihaz kaydı sırasında, kullanıcının izni varsa konum bilgisi de gönderiliyor. Uygulama her ön plana geldiğinde (30sn cooldown ile) konum güncelleniyor. Yani kullanıcı şehir değiştirse bile sistem bunu yakalar.

Backend tarafında PostgreSQL'de PostGIS eklentisi aktif. Konum snapshot'ları zaman serisi olarak saklanıyor (son 10 snapshot). Bu sayede sadece "şu an nerede" değil, "son X saatte nerelerde bulundu" bilgisine de erişebiliyoruz.

Pratikte nasıl çalışıyor? Uygulama zaten Elasticsearch üzerinde geo_distance sorguları kullanarak mesafeye göre içerik listeliyor. Bildirim tarafında da aynı mantık: yeni bir içerik yayınlandığında, o içeriğin konumuna yakın kullanıcılar filtreleniyor ve otomatik push gönderiliyor. Kullanıcı neredeyse, bulunduğu bölgedeki içeriklerden anında haberdar oluyor.

Bu akışı OneSignal, Braze, Dengage veya Firebase'in yönetilen katmanıyla kurmak mümkün değil. Çünkü hiçbiri cihazdan anlık konum toplayıp bunu bildirim hedeflemesiyle birleştiren bir pipeline sunmuyor.

Gerekli Dosyalar ve Credential'lar

DosyaNereden AlınırNe İşe Yarar
.p8 (APNs key)Apple Developer → KeysAPNs JWT imzalama
GoogleService-Info.plistFirebase Console → iOS appiOS Firebase bağlantısı
google-services.jsonFirebase Console → Android appAndroid Firebase bağlantısı
fcm.json (Service Account)Google Cloud Console → IAMFCM v1 API OAuth2 erişimi

.p8 vs .p12: Apple iki tip sertifika sunuyor. .p12 (certificate-based) eski yöntem ve her yıl yenilenmesi gerekiyor. .p8 (token-based / JWT) daha yeni, süresiz ve tüm uygulamalar için tek key ile çalışabiliyor. Biz .p8 kullanıyoruz.

Sonuç

Kendi push notification sisteminizi kurmak ilk bakışta göz korkutucu görünebilir ama aslında APNs ve FCM'in HTTP API'leri oldukça basit ve iyi belgelenmiş. Kritik olan kısımlar (token yönetimi, retry stratejileri, deduplikasyon ve çoklu cihaz desteği) iş mantığı seviyesinde çözülüyor.

Ama asıl kazanç şu: konum tabanlı bildirim gibi, hiçbir third-party servisin hazır sunmadığı özellikleri istediğiniz gibi inşa edebiliyorsunuz. Kullanıcı verileriniz sizde, pipeline tamamen sizin kontrolünüzde ve aylık cihaz başına ücret ödemiyorsunuz. Dezavantajı ise bakım sorumluluğunun tamamen size ait olması. Ama zaten production-grade bir uygulamada bu seviyede bir anlayış kaçınılmaz.

Eğer siz de konum verisini bildirimlerle birleştiren benzer bir sistem kurmayı düşünüyorsanız, bu yazının size iyi bir başlangıç noktası olmasını umuyorum. Sorularınız ya da önerileriniz için benimle iletişime geçebilirsiniz.