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