Skip to content

Decision: AtramNotification as a Smart-Routed Notification Base Class

Before this change, Yaya Engine had exactly one notification class, PushNotification, and it fired unconditionally through FCM whether or not the user had a push subscription — silently no-op'ing when they didn't [@design-doc]. Issue #158 asked for 18 distinct PWA notification types (task reminders, verification results, product announcements, account issues), and building each of those the old way meant hand-rolling channel selection and delivery logging 18 separate times. The design explicitly scoped itself as the routing and logging foundation those 18 notifications would be built on, not as the implementation of any of them [@design-doc].

Context

A notification in this product can reach a user through four different channels — Telegram, PWA push, WhatsApp, SMS — and a given user might be reachable on some subset of them. Without a shared resolver, every new notification type would need to reimplement "which channel is this user actually on" and "did the send succeed" from scratch, and delivery bookkeeping would stay scattered per channel rather than living in one place ops tooling can query. The existing alert-dispatch ChannelResolver already solved a version of this problem for bulk alerts, but it carries alert-specific concerns — a preferred-channel hint from FspAlertConfig, an allow-fallback flag, coupling to ChannelManager — that don't belong on a general-purpose notification base class [@design-doc].

Decision

AtramNotification is an abstract Illuminate\Notifications\Notification subclass, ShouldQueue by default, whose via() method is declared final [@atram-notification]. Subclasses cannot override routing; they can only narrow which channels they support by overriding supportedChannels(), which defaults to all four (Telegram, Pwa, WhatsApp, Sms) [@atram-notification]. via() delegates to a new NotificationChannelResolver, deliberately not the alert system's ChannelResolver, which walks a fixed ladder — Telegram, then PWA push, then WhatsApp, then SMS — and returns the first channel in that ladder the user is reachable on, intersected with what the notification supports; it returns no channels at all if none match [@design-doc] [@atram-notification]. A resolved channel maps through a private CHANNEL_CLASS_MAP to one of three new thin Laravel channel classes (Channels\TelegramChannel, Channels\WhatsAppChannel, Channels\SmsChannel) or the existing NotificationChannels\Fcm\FcmChannel package for push, and each new channel class delegates the actual send to the existing app/Services/Messaging/Channels/* services rather than duplicating send logic [@atram-notification] [@design-doc].

Delivery bookkeeping is centralized the same way: a single RecordNotificationDelivery listener on Laravel's NotificationSent/NotificationFailed events writes a Message row and a MessageDeliveryLog row for any notification that is an instanceof AtramNotification, filtering out everything unrelated — Slack ops alerts, Laravel's own password-reset notifications — so they don't pollute the same tables alert delivery uses [@design-doc]. This required loosening messages.conversation_id to nullable, since a notification (unlike an alert or a conversational reply) doesn't belong to a conversation, and adding notification_type/notification_id columns so ops queries can ask "did user Y receive notification X" by class name, the same way they already query alert delivery [@design-doc]. PushNotification, the one pre-existing notification class, was refactored to extend AtramNotification and narrow to [Pwa, Telegram] — a deliberate behavior change, since it now only fires when the user is actually reachable on one of those channels instead of firing unconditionally and no-oping [@design-doc].

Status

Approved and implemented, and implemented beyond the original scope. The design doc explicitly says building the 18 notifications from issue #158 is a non-goal of this change [@design-doc], but the repository now contains four populated notification groups built directly on AtramNotificationEngagement (ActionDropoffReminder, PreAlertReengagement, SeasonalPreparednessKickoff, TaskReminder, TaskWindowClosing) [@notification-groups], plus Verification, Product, and Account groups — meaning the typology this architecture was built to carry has since been written on top of it. See Notification System: AtramNotification and Slack Ops Alerts for how those groups are organized today.

Consequences

Adding a new notification type is now a matter of writing one class with to{Channel}() methods for whichever channels it supports, not wiring channel selection and delivery tracking by hand. Ops gets a uniform query path: the same delivery-report tooling used for alerts now also answers "did this notification reach this user" for any AtramNotification subclass, without a new table or a new query shape per notification type.

The design also named what it deliberately left out, and those gaps are still open. There is no suppression mechanism on the base class — no "don't send this if we already sent three notifications with zero activations" — so per-notification suppression rules (like SuppressesDuringActiveAlert) are each subclass's own responsibility. There is no per-user channel preference override; the resolver always walks the same global ladder for every notification. And when via() resolves to no channel at all, Laravel never fires NotificationSent, so nothing gets logged — the design flagged this as a known blind spot ("skipped delivery visibility") rather than solving it, meaning ops cannot currently distinguish "we tried to reach this user and had nothing to send to" from "we never tried" [@design-doc]. This decision constrains Messaging Pipeline, whose batch alert sends deliberately continue to run through the older AlertDispatchServiceBatchMessagingServiceProcessMessageBatch path rather than being folded into AtramNotification, since that pipeline is built for fan-out to thousands of recipients rather than one-off, per-user sends [@design-doc].