Dentolize · Clear Push Tokens on Logout Walkthrough
On this pageBusiness viewTechnical view

How push tokens are registered and cleared

Business view

Every phone running the Dentolize clinic app registers itself with Apple/Google's push infrastructure (via Expo) and hands that registration — its "push token" — to the Dentolize server the moment someone logs in. From then on, the server uses that token to push appointment reminders, new invoices, task assignments, chat messages, and dozens of other real-time alerts to that phone, regardless of whether the app is open.

That token is a property of the device, but it's stored on the user's account. A user can be logged into the same account on more than one phone, and each one adds its own token to that account's list.

The problem this PR fixes: a token only ever got added at login. Nothing removed it except two things — the phone's own "Logout" button (which removes only that one token), or the "Logout All Users" admin action, which wiped every token in the clinic. Every other way a session gets terminated — a manager resets someone's password, an owner edits a permission group, an admin suspends a clinic — left the token behind. The phone had no session, but it kept receiving notifications meant for that account, because nothing had told the server the token was now stale.

Technical view

Data model

User.pushNotifications is a plain String[] column (packages/prisma/schema.prisma:430) — no relation, just a list of Expo push token strings. There's a separate, unrelated Patient.pushNotifications field (packages/prisma/schema.prisma:966) for the patient-facing portal; it is not touched by this PR.

Each login also writes a UserSession row (packages/prisma/schema.prisma:3401-3415) which — among other things — records the single push token (pushNotification, singular) that that login supplied, tied to the Redis session id.

Registration (unchanged by this PR)

The shared login helper checkUserForLogin (packages/server/src/utils/helpers.js:70-260) is called by every login-style mutation. On each login it:

  1. Looks up which of the user's existing sessions are already dead.
  2. Filters the user's pushNotifications array to drop tokens that belonged only to those dead sessions.
  3. Appends the newly-supplied token from this login, if any.
  4. Writes the result back with prisma.user.update({ data: { pushNotifications: { set: pushNotifications } } } }) (helpers.js:229-232).

So there was already some self-cleaning logic baked into login — but it only runs at login, and only prunes tokens tied to sessions that are already gone by other means. It does nothing at the moment a session is killed.

Consumption (unchanged by this PR)

The token list is read all over the codebase to fan out Expo push notifications — appointment reminders (packages/server/src/cronJobs/appointments/appointmentsCron.js), task reminders (packages/server/src/cronJobs/tasks/tasksCron.js), scheduled messages (packages/server/src/cronJobs/messages/sendMsgWhen.js), and dozens of mutation-triggered notifications in packages/server/src/resolvers/mutations/mutationUtils/notificationUtils.js and the various mutations/actions/** files (new invoices, operations, prescriptions, chat messages, and more). None of that fan-out logic changed — this PR only affects when the token list gets emptied, not how it's used.

Removal — before this PR

Two mechanisms existed:

  • logout (packages/server/src/resolvers/mutations/authMutations.js:614-642) — deletes the caller's own UserSession row and, if the client passes back its current Expo token as args.pushNotification, removes just that one token from whichever user account currently holds it (.filter(n => n !== args.pushNotification)). It is scoped to a single device, by design — it's the one flow where the client can tell the server exactly which token to drop.
  • logoutOutAllUsers (packages/server/src/resolvers/mutations/authMutations.js:1208-1240) — the "Logout All Users" admin action. It already did a full pushNotifications: { set: [] } } for every user in the company, alongside killing every Redis session and deleting every UserSession row. This was the existing prior art this PR generalizes.

Everything else that forcibly ends a session — password resets, permission-group edits, 2FA toggles, company suspension, editing a user's profile — did not touch pushNotifications at all. That's the gap this PR closes; see The mutations this PR changes for the full list with line references.

Removal — after this PR

The fix is mechanical: wherever a mutation already tears down sessions as a side effect, it now also runs pushNotifications: { set: [] } on the affected user(s), in the same Prisma transaction as the rest of the mutation's writes. No new query, no new round-trip — the field is just added to a data: object (or, in disableCompany, one new conditional statement in the existing transaction array) that was already being executed.

One subtlety worth calling out, because it shaped how the fix was written: in deleteGroup, the code reassigns affected users to a new group before it later runs a userSession.deleteMany filtered on the old group id — by the time that delete runs, no user matches the old group id anymore, so it's a no-op. The PR's author avoided repeating that mistake for push tokens by folding the pushNotifications: { set: [] } into the same user.updateMany that performs the reassignment (companyMutations.js:981-984), rather than adding a separate statement that would run after the reassignment and match nobody. See the deleteGroup row in The mutations this PR changes for the exact code.