Skip to content

[bug] notifyUser has no duplicate suppression — the push tag hides the repetition in the tray while every alert still fires #361

Description

@serge-ivo

No floor under the notification layer

notifyUser (routes/push.ts:196-206) is the single funnel every notification passes through:

export async function notifyUser(env, userId, type, title, body, url?) {
	await createNotification(env.DB, userId, type, title, body, undefined, url).catch(() => undefined);
	await sendPushToUser(env, userId, { title, body, url, tag: type }).catch(() => undefined);
}

No coalescing, no rate limit, no recent-duplicate check. Whatever a caller asks for, every caller,
every time.

The tag: type argument makes this look solved and is not: a web-push tag collapses the OS
tray visually, so N identical notifications show as one entry — while each still fires its own
alert and sound. And createNotification keeps every copy, so the in-app bell list accumulates all
N. The tray is the only surface that hides the duplication, and it is the one nobody audits.

Why a floor, rather than only fixing the callers

#359 is a caller bug: the deploy watcher polls every workflow in the repo, so one push produced 2–4
notifications and a three-hour session produced 40–80. That bug should be fixed on its own terms and
is.

But it reached the user's phone at full volume because nothing between the bug and the device was
willing to say "I have already sent this"
. That is the fourth notification defect in this area
(#338, #344, #349, #359); a floor is what makes the next one an annoyance instead of an incident.

Shape

Suppress a push whose (user_id, type, title, body) matches one already sent inside a short window —
minutes, not hours. Two properties matter:

  • Suppress the push, keep the row. The in-app list is a log and should stay complete; it is the
    interruption that needs bounding. Same principle as the Push notifications: suppress when user is actively using the app (tab visible / online) #176 visibility gate, which suppresses the
    banner and still forwards the payload so the badge updates.
  • The window is a floor, not a policy. It exists to bound a malfunction. Legitimate repeated
    notifications — two different agents needing a CAPTCHA — differ in title/body and are unaffected.

Cheapest implementation is a read of the notifications table notifyUser is already writing to, so
there is no new store and no new state to keep consistent.

Relationship to the others

Noted originally as the closing section of #359 and split out so it is not lost when that one closes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions