Berkan Türkoğlu
Projects
Java · Spring Boot

QuitQOS

An iOS app I built for people trying to quit IQOS. It runs a timer from the moment you stop and marks off the health milestones of the body's recovery one by one. Backend, mobile, design, deployment — the whole thing is my work.

Repositorystatus: live on the App Store

The problem

Someone who has just quit is motivated for a few days, and then that feeling fades. What keeps them going is seeing their progress — but nobody opens an app every day to check it. So the app has to work while it is closed: notice on its own that a health milestone has been reached, and tell the user. One more condition: none of this should require an account. Registration is only for people who want their data on another device or a place on the leaderboard.

Technical details

How I use Firebase Auth

Firebase does exactly one job for me: confirm that this person really owns this Google or Apple account. I verify the ID token it returns once, then issue my own access JWT and refresh token. Firebase is never called again. Refresh is one-time only, and the token itself never sits in the database — only its SHA-256 hash.

The database enforces the business rules

A user may have at most one ACTIVE quit attempt at a time. I don't check that with an if block in the service layer, because a race condition can slip between the check and the write; the rule lives in PostgreSQL as a partial unique index, so the database refuses the second row itself. The same rule holds when a guest's data is merged into a registered account: the earliest attempt stays active, the rest are closed as relapsed.

A scheduler that can't hand out the same reward twice

A @Scheduled job scans active attempts every 60 seconds: it creates the achievement record first, awards the badge, and only then sends the push. That order is deliberate. If the push went first and FCM failed transiently, the next tick would hand out the same badge a second time. When the user has notifications off or no device token on file, the push step is skipped entirely — the achievement is still recorded.

Migrations own the schema, not the ORM

Seven versioned Flyway migrations carry the schema from the first five tables to where it is today, per-user locale and translated milestone content included. Hibernate runs with ddl-auto=validate, so it can only ever confirm the shape it was given — it never alters it. Every environment replays the same files in the same order, so every environment ends up with an identical schema.

A public endpoint that exposes nobody

Guests were getting a 403 from the leaderboard, which left the ranking screen completely empty for them. So I wrote a small public endpoint that returns community totals and a top three. Its DTO deliberately has no userId field: someone who hasn't signed in can see the overall table without seeing who anyone is.

Writing it isn't the end — shipping it is

The app is live on the App Store. The backend runs on Hetzner behind a shared edge proxy, and GitHub Actions deploys it automatically on every push that touches it. Getting there threw up a pile of work with nothing to do with features: account deletion end to end because Apple requires it, privacy and support pages, static-framework linkage on iOS because of the Firebase pods, and a settings screen I rebuilt as a scrollable view after App Store review couldn't reach the delete button.

Tech stack
Backend
Java 21 · Spring Boot 4.1 · Spring JPA · Spring Security · Maven
Data
PostgreSQL (Aiven) · Flyway
Auth & push
Firebase Auth · JWT · FCM
Testing
JUnit · Mockito
Mobile
React Native 0.85 · Expo 56 · TypeScript · i18next
Infra
Hetzner · Docker Compose · GitHub Actions · EAS
By the numbers
20
REST endpoints
42
unit tests
7
Flyway migrations
13
health milestones

Architecture

QuitQOS — system architecture
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
The Firebase identity and my own token
token issue & rotation
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

The app

The live counter, running from the moment you quit
Sign-in — or carry on without an account if you'd rather
Streak stats, milestone notifications, Turkish and English support