Berkan Türkoğlu
Projeler
Java · Spring Boot

QuitQOS

IQOS'u bırakmak isteyenler için geliştirdiğim bir iOS uygulaması. Bıraktığın andan itibaren çalışan bir sayaç tutuyor, vücudun toparlanmasındaki sağlık eşiklerini sırayla işaretliyor. Backend'inden mobiline, tasarımından deploy'una kadar tüm geliştirmeler bana ait.

Repositorydurum: App Store'da yayında

Problem

Sigarayı bırakan biri ilk günlerde motivedir, sonra o his hızla söner. İnsanı asıl orada tutacak şey, ilerlemesini görmesi. Ama kimse ilerlemesini görmek için uygulamayı her gün açmaz. Yani uygulama kapalıyken de çalışmak zorunda: bir sağlık eşiğine ulaşıldığında bunu kendisi fark edip kullanıcıya bildirim göndermesi gerekiyor. Bir şart daha vardı: bunların hiçbiri hesap açmayı gerektirmemeli. Kayıt sadece verisini başka cihaza taşımak ya da leaderboard'a girmek isteyenler için.

Teknik detaylar

Firebase Auth'u nasıl kullandım

Firebase'e tek bir iş yaptırıyorum: bu kişi gerçekten bu Google ya da Apple hesabının sahibi mi? Dönen ID token'ı bir kez doğruluyorum, sonra kendi access JWT'mi ve refresh token'ımı üretiyorum. Firebase bir daha hiç çağrılmıyor. Refresh tek kullanımlık; token'ın kendisi veritabanında durmuyor, sadece SHA-256 hash'i saklanıyor.

İş kurallarını veritabanı garanti ediyor

Bir kullanıcının aynı anda tek bir ACTIVE denemesi olabilir. Bunu servis katmanında if ile kontrol etmiyorum, çünkü araya race condition girebilir; kural PostgreSQL'de partial unique index olarak duruyor, yani veritabanı zaten izin vermiyor. Guest verilerini kayıtlı hesaba aktarırken de aynı kural geçerli: en erken başlayan deneme aktif kalıyor, diğerleri relapsed olarak kapanıyor.

Aynı ödülü iki kez veremeyen scheduler

Bir @Scheduled job 60 saniyede bir aktif denemeleri tarıyor: önce achievement kaydını oluşturuyor, badge'i veriyor, push bildirimini ancak bundan sonra gönderiyor. Bu sıra bilinçli. Eğer önce push gönderilse ve FCM geçici bir hata verse, bir sonraki turda aynı badge ikinci kez dağıtılırdı. Kullanıcı bildirimleri kapatmışsa ya da kayıtlı bir device token yoksa push adımı tamamen atlanıyor, ama achievement gene de kaydediliyor.

Şemayı migration'lar yönetiyor, ORM değil

Yedi ayrı Flyway migration'ı şemayı ilk beş tablodan bugünkü haline taşıyor: kullanıcı bazlı locale desteği ve çevrilmiş milestone içerikleri dahil. Hibernate ddl-auto=validate ile çalışıyor, yani mevcut şemayı sadece doğrulayabiliyor, üzerinde değişiklik yapamıyor. Her ortam aynı migration dosyalarını aynı sırayla çalıştırdığı için hepsi birebir aynı şemaya sahip oluyor.

Kimseyi ifşa etmeyen public endpoint

Kayıt olmayan kullanıcılar leaderboard isteğinde 403 alıyordu, dolayısıyla sıralama ekranı onlar için tamamen boş kalıyordu. Bunun için küçük bir public endpoint yazdım: topluluğun genel toplamlarını ve ilk üçü dönüyor. Döndüğü DTO'da userId alanı bilerek yok. Yani giriş yapmamış biri genel tabloyu görebiliyor ama kimin kim olduğunu göremiyor.

Yazmakla bitmiyor, yayına almak da var

Uygulama App Store'da yayında. Backend Hetzner'da, ortak bir edge proxy arkasında çalışıyor; backend'e dokunan her push'ta GitHub Actions otomatik deploy ediyor. Buraya gelene kadar feature yazmakla hiç ilgisi olmayan bir sürü iş çıktı: Apple zorunlu tuttuğu için uçtan uca hesap silme akışı, gizlilik ve destek sayfaları, Firebase pod'ları yüzünden iOS tarafında static framework linkleme derdi ve App Store incelemesinde silme butonuna ulaşamadıkları için baştan scroll'lanabilir hale getirdiğim ayarlar ekranı.

Teknoloji
Backend
Java 21 · Spring Boot 4.1 · Spring JPA · Spring Security · Maven
Veri
PostgreSQL (Aiven) · Flyway
Auth & push
Firebase Auth · JWT · FCM
Test
JUnit · Mockito
Mobil
React Native 0.85 · Expo 56 · TypeScript · i18next
Altyapı
Hetzner · Docker Compose · GitHub Actions · EAS
Sayılarla
20
REST endpoint
42
unit test
7
Flyway migration
13
sağlık eşiği

Mimari

QuitQOS — sistem mimarisi
Java 21 · Spring Boot 4.1 · PostgreSQL · Firebase
MOBILE · REACT NATIVE 0.85 · EXPO 56Guest modeAsyncStorage · no backend13 local notificationsRegisteredserver-syncedleaderboard + FCM tokenFirebase Authanswers one question: is this really their account?called exactly once per loginsign-inID token · verified onceAuthorization: Bearer <our JWT>SPRING BOOT 4.1 · JAVA 21 · com.dayzerosoft.quitqos.backendSECURITY/ — FILTER CHAIN · STATELESSJwtAuthenticationFilteridentifies — never rejectsSecurityConfigpublic vs authenticatedWEB/ — REST API · /api/v119 endpoints/auth · /users · /quit-attempts/milestones · /badges · /leaderboard/leaderboard/summary — public, no userIdSERVICE/ — BUSINESS LOGIC7 services · 42 testsAuth · User · QuitAttemptAchievement · Leaderboard · SyncMilestoneNotification@SCHEDULED · EVERY 60sMilestoneNotificationScheduler1 · scan ACTIVE attempts2 · persist achievement + badge3 · then send push (never before)REPOSITORY/ — SPRING DATA JPAEntitiesUser · QuitAttempt · MilestoneBadge · UserMilestone · RefreshTokenddl-auto = validateFLYWAY — SCHEMA OWNER7 versioned migrationsV1 schema → V7 locale + en contentpartial unique index:one ACTIVE attempt per userPostgreSQLapp_user · quit_attempt · milestonebadge · user_milestone · refresh_tokenFirebase Cloud Messagingmilestone push to devicestub / real sender seampush notification
Firebase kimliği ile kendi token'ım
token üretimi & rotasyon
AppGoogle / Apple sign-in→ Firebase ID tokenPOSTPOST /auth/firebaseBackend verifies onceupsert User · derive usernameFirebase is never called againAccess JWT · ~1 hourself-contained, HMAC-signed — stored nowhereshort-lived, so a leak stays cheapRefresh token · ~180 daysstored in DB as SHA-256 hash — raw value never keptlong-lived but revocable on logoutPOST /auth/refreshOne-time rotationlook up by hash → delete old row→ issue a fresh pairuser never sees login again

Uygulama

Bıraktığın andan itibaren çalışan canlı sayaç
Giriş ekranı — istersen hesap açmadan da devam edebiliyorsun
Streak istatistikleri, eşik bildirimleri, Türkçe ve İngilizce dil desteği