Notifications — Feature & Testing Guide
Overview
Objectuve's notification system keeps users informed about social activity, goal health, and gamification milestones without interrupting their flow. Notifications are scoped to a single UserNotification model with 38 distinct kinds (encouragements, reactions, badges, comments, reminders, ally/partner social events, community events, streak milestones, leaderboard recaps, habit-integration auto-check-ins, team events, etc.) and are delivered through three complementary channels: an in-app panel, real-time WebSocket push via ActionCable, and native device push via FCM (Android) and APNs (iOS).
The system is designed for low-noise, high-relevance delivery. Encouragement and comment notifications deduplicate — if an unacknowledged notification for the same goal already exists, the backend updates its content rather than creating a second one. Reminder and past-due notifications include the same guard: a new one is only generated if no unacknowledged notification for that goal already exists.
On the frontend, useNotifications holds module-level state shared across all consumers on the same page. Real-time updates arrive via NOTIFICATION_UPDATE_SUBSCRIPTION and are prepended to the list or merged in place. Level-up notifications receive special treatment — they bypass the generic toast and trigger a rarity-scaled confetti animation via useAchievementNotifications.
Notification Kinds
UserNotification::KIND_ENUMS (app/models/user_notification.rb) currently defines 38 kinds:
| Kind | Enum Integer | Trigger | Content Template |
|---|---|---|---|
encouragement | 0 | Another user encourages a goal (Social::SendEncouragement) | "You have a new encouragement!" / "One of your goals has new encouragements! Check it out!" |
badge | 1 | Badge unlock, level-up, or community challenge finalization | "You've earned a new badge! Click to check it out!" / "🎉 You've reached Level N! Keep it up!" |
general | 2 | Catch-all — community feature teaser (User#...), first-goal onboarding nudge, or flagged-content moderation notice | Varies by call site |
comment | 3 | Another user comments on a goal | "You have a new comment!" / "One of your goals has new comments! Check it out!" |
reminder | 4 | Goal has not been updated recently (also reused by the habit-streak and critical-path reminder jobs) | "<goal name> hasn't been updated in a while. Click here to track new progress!" |
past_due | 5 | Goal is past its target date | "<goal name> is past its due date. Let's reassess to reach your goal!" |
follow | 6 | User follows a public goal | "<full name> is now following your public goal "<goal name>"" |
ai_check_in | 7 | Coach generates a personalized check-in | "Your Coach has a personalized check-in for you!" |
reaction | 8 | Another user reacts to a goal event with an emoji | "Someone reacted to your progress update!" / "One of your goals has new reactions! Check it out!" |
feedback | 9 | Status change on a user's feedback submission | "Your feedback "<title>" is now <status>!" |
partner_request | 10 | Accountability partner request sent (Social::SendPartnerRequest) | "<name> wants to be your accountability partner!" |
partner_accepted | 11 | Accountability partner request accepted (direct request or ally invite path) | "<name> accepted your accountability partner request. You two are now partners!" |
partner_ended | 12 | Accountability partnership ended (Social::EndPartnership) | "<name> has ended your accountability partnership." |
partner_missed_day | 13 | Accountability partner missed a habit check-in (cron) | "<name> hasn't checked in on <goal> today. A quick encouragement could help." |
welcome_back | 14 | Streak-restore offer (WelcomeBackOfferResolver, AcceptStreakMercy) | "Welcome back! Your streak is waiting — one tap to restore it." |
compassionate_break | 15 | Streak broken; repair window opened (StreakBrokenJob) | "One missed day doesn't erase your real work. Your repair window is open — tap to bring it back." |
community | 16 | Community challenge created (FanoutChallengeCreatedJob) | "<community> just started a challenge: <challenge>" |
partner_mutual_streak_milestone | 17 | Partner pair hits a mutual streak milestone (AwardPartnerBonus) | "You and <name> hit a N-day streak — that's compounding." |
partner_nudge | 18 | Accountability partner sends a nudge (Social::SendPartnerNudge) | "<name> is rooting for you — quick check-in to keep your streak alive." |
community_invite | 19 | Ally invited to join a community (Social::InviteAllyToCommunity) | "<name> invited you to join <community>" |
ally_request | 20 | Ally request sent (Social::SendAllyRequest) | "<name> wants to be your ally!" |
ally_accepted | 21 | Ally request accepted (Social::AcceptAllyRequest) | "<name> accepted your ally request. You two are now connected!" |
community_post | 22 | New post in a community (Social::CreateCommunityPost) | "<name> posted in <community>" |
community_comment | 23 | Comment on a community post (Social::AddPostComment) | "<name> commented on a post you were part of in <community>" |
mood_reminder | 24 | Evening mood check-in reminder (cron) | "Haven't logged your mood today — one tap to check in." |
goal_milestone | 25 | Goal completed (GoalTracking::UpdateGoal) | "🎉 You completed "<goal name>"! Great work." |
streak_milestone | 26 | Streak milestone reached (Gamification::Subscribers) | "🔥 N-day streak on "<goal name>" — keep the momentum going!" |
habit_checkin_reminder | 27 | Habit due today, not yet checked in (cron) | "<habit> hasn't been checked in yet today. Keep your streak going!" |
leaderboard_recap | 28 | Weekly Teams leaderboard recap delivered per membership (Teams::SendWeeklyRecapJob) | "Your week with <team>" (title varies: "<team> · your week" or, on a gentle week, "A fresh week with <team>") |
integration_auto_checked_in | 29 | A linked integration (Strava, Chess.com, etc.) auto-checks off a goal (Integrations::Subscribers.handle_activity_checked_in) | "Linked to <provider>. <goal> checks itself off now." |
integration_proposed | 30 | A linked integration proposes a new habit match for the user to review (Integrations::Subscribers.handle_activity_proposed) | "New match from <provider> — review <goal> to link it." |
team_member_joined | 31 | A new member joins the user's team (Teams::AcceptTeamInvite) — team notification, in-app only | "<name> joined <team>" |
team_role_changed | 32 | The user's role on a team changes (Teams::PromoteTeamMember) — team notification, in-app only | "Your role on <team> is now <role>" |
team_collective_goal_created | 33 | A new team collective goal is created (Teams::CreateCollectiveGoal) — team notification, in-app only | "<name> set a new team goal on <team>" |
team_collective_goal_reached | 34 | A team collective goal hits its target (Teams::CollectiveGoalContributionUpsertJob) — team notification, in-app only. Only fires for check_ins-metric collective goals; other target metrics never reach and never notify. | "🎉 <team> hit its team goal!" |
team_seat_cap_reached | 35 | A team's seat cap is reached (Teams::AdjustTeamSeats) — team notification, in-app + push | "<team> is full — every seat is taken" |
team_billing_past_due | 36 | A team's invoice payment fails (Teams::ProcessInvoicePaymentFailed) — team notification, in-app + push | "<team>'s payment is past due. Update billing to keep things running." |
partner_declined | 37 | Accountability partner request declined (Social::DeclinePartnerRequest) — symmetric with partner_accepted/partner_ended, no toast (see § Frontend: useNotifications Composable) | "<name> passed on your accountability partner request. You can ask someone else whenever you're ready." |
Team notifications (Stage 3, OBJ-2406/OBJ-1819): kinds 28 and 31–36 above are collectively governed by the "Team notifications" preference. See § Team Notifications below for what the toggle controls, and GraphQL Reference § Notifications (Teams V1) for the full kind table, push split, and gate mechanics.
See Accountability partners for full details on the missed-day alert cron job and notification format.
Team Notifications (Teams V1)
A member controls every notification a team sends them with a single "Team notifications" toggle in Settings (TeamNotificationsSettingsSection.vue, desktop card + mobile row variants). Introduced in Stage 3 Task 8 (OBJ-2406, PR #2285).
What it covers — no exemptions
The toggle governs all seven team-related kinds, with no carve-outs: team_member_joined, team_role_changed, team_collective_goal_created, team_collective_goal_reached, team_seat_cap_reached, team_billing_past_due — and the weekly leaderboard_recap ("Monday recap"). Turning it off silences every one of them, in-app and push, across every team the member belongs to. There is no per-kind or per-team granularity, and no kind is exempt because it's "important" (billing past-due included).
On: "Roster changes, team goals, billing updates, and Monday recaps from the teams you're on." Off: "Off. Roster changes, team goals, billing updates, and Monday recaps all stop — in-app and push."
Two separate controls — don't conflate them
- "Team notifications" toggle (this section) — the member's own choice,
UserDetail#team_notifications_enabled. Applies to all seven kinds above. teams_leaderboards— an unrelated, admin-only kill switch (Teams::SendWeeklyRecapJob) that decides whether the Monday recap job runs at all, team-wide. It predates the toggle and isn't visible to members.
A member with the toggle on but whose team has teams_leaderboards off simply never gets a recap to begin with — nothing to silence. A member with the toggle off gets nothing regardless of teams_leaderboards. The two never need to agree with each other.
Not a notification off-switch: the per-membership "opt out of the leaderboard" setting (leaderboard_opted_out?) is a separate, older control that only blanks a member's own stats inside the recap they still receive — it does not stop the recap from arriving. Only the "Team notifications" toggle stops delivery.
Weekly digest is unrelated
The "Team notifications" toggle has no connection to the "Weekly digest" email preference (weekly_digest_enabled) — that setting governs a separate digest email only (Weekly Digest Email) and has never covered the Monday recap.
Live team badge & panel (OBJ-2410)
The bell badge on TeamHomeView and the team-scoped list in NotificationsPanel both update without navigation or a panel reopen — each registers a listener on the shared stream (see § Frontend: useNotificationStream) and refetches its own server-scoped query when a matching event arrives. The count is always server-authored via a refetch of the existing $teamId-scoped notificationHistory query (teamNotificationsBadge.refresh() / teamHistory.refresh()) — never derived client-side over the global tray, which is capped at 50 items (notifications(limit: 50) on the user query) and can't represent one team's true unread count on a busy account. Acknowledging a notification in team context still clears it from the global Dashboard tray — both routes go through the same shared acknowledgeNotification mutation.
The unscoped /notifications history page (NotificationHistory.vue) is deliberately not live — it's offset-paginated, and refetching the current page's variables on every event would tear apart a "load more"'d list. Making it live needs an insert-into-list strategy, not a refresh() call; tracked separately as OBJ-2835, which names useNotificationStream as the hook to build on.
Data Model
UserNotification inherits from PublicRecord — it has an auto-generated public_id, and GraphQL mutations (acknowledgeNotification, deleteNotification) take that public_id as their id argument, looked up via UserNotification.public_find. The integer id is never exposed to clients.
| Column | Type | Notes |
|---|---|---|
id | bigint | Internal primary key — not exposed to clients |
user_id | bigint | Foreign key to users |
kind | integer | Enum — see KIND_ENUMS above |
content | text | Human-readable notification text |
acknowledged | boolean | false on creation; set to true on dismiss/read |
details | jsonb | Structured payload — e.g., { goal_public_id: "...", level: 3, action_name: "level_up" } |
deleted_at | datetime | Soft-delete column (not backed by acts_as_paranoid — set via destroy in DeleteNotification) |
created_at | datetime | — |
updated_at | datetime | — |
Two convenience accessor methods are defined on the model:
created_at_time— returnscreated_atas a Unix timestamp stringupdated_at_time— returnsupdated_atas a Unix timestamp stringdetails_json— returnsdetails.to_json(used in GraphQL type)
Creating Notifications
UserNotification.generate (base method)
All notification creation eventually calls this class method:
UserNotification.generate(
user_id: user.id,
kind: :encouragement,
content: 'You have a new encouragement!',
details: { goal_public_id: goal.public_id }
)It creates the record, fires the notificationUpdate ActionCable subscription event, and calls SendPushNotification.call to deliver a device push. Note that user_id here is the integer database ID, not a public_id.
Specialized class methods
| Method | Arguments | Uses job? | Notes |
|---|---|---|---|
add_encouragement_notification(user_id, goal_public_id, sender_user_public_id: nil) | Integer user ID, goal public_id string, optional sender public_id | Yes, unless dedup triggers update | Deduplicates by kind: :encouragement; when provided, sender_user_public_id is stored in details |
add_comment_notification(user_id, goal_public_id) | Integer user ID, goal public_id string | Yes, unless dedup triggers update | Deduplicates by querying kind: :encouragement (bug — see Known Behaviors) |
add_level_up_notification(user_id, level) | Integer user ID, integer level | Yes | Kind :badge, details include { level:, action_name: 'level_up' } |
add_badge_notification(user_id) | Integer user ID | Yes | Kind :badge, no details payload |
add_follow_notification(user_id, following_user_id, goal_public_id) | Integer IDs + goal public_id | Yes | Builds content string from follower name and goal name |
All specialized methods use the environment-aware job dispatch pattern:
perform_timing = Rails.env.development? || Rails.env.test? ? :perform_now : :perform_later
GenerateNotificationJob.send(perform_timing, user_id: user_id, kind: ..., content: ..., details: ...)Deduplication Logic
Encouragement notifications
add_encouragement_notification checks for an existing unacknowledged notification matching:
user_id(integer)kind = KIND_ENUMS[:encouragement](0)details->>'goal_public_id' = goal_public_idacknowledged = false
If one exists, it updates content to MULTIPLE_ENCOURAGEMENT_TEXT and re-broadcasts the WebSocket event. If none exists, it enqueues GenerateNotificationJob.
Comment notifications
add_comment_notification uses the identical deduplication query — but queries against kind = KIND_ENUMS[:encouragement] (0) instead of kind = KIND_ENUMS[:comment] (3). This is a bug. See Known Behaviors item 1.
Reminder and past-due notifications
GenerateReminderNotificationsJob and GeneratePastDueNotificationJob both check goal.user.notifications in Ruby (after loading all records) for an unacknowledged notification with the matching kind and details['goal_public_id']. If one already exists, the job skips to the next goal with next.
Background Jobs
GenerateNotificationJob
The generic notification creation job. Called by all add_*_notification class methods.
GenerateNotificationJob.perform_later(
user_id: user_id,
kind: :badge,
content: 'You have a new badge!',
details: { level: 5, action_name: 'level_up' }
)Delegates directly to UserNotification.generate.
CreateLevelUpNotificationJob
Dedicated job for level-up events. Called from GamificationService when XP crosses a level threshold.
CreateLevelUpNotificationJob.perform_later(user.id, new_level)Delegates to UserNotification.add_level_up_notification(user_id, level).
All three jobs below run hourly (config/cronotab.rb) and match each user's local time against a target hour inside the job (via Time.current.in_time_zone(user.timezone_or_default).hour), rather than firing once at a fixed UTC time. This replaced an earlier fixed-UTC-time dispatch for each job.
GenerateReminderNotificationsJob
Runs hourly; dispatches at 09:00 local time per user (TARGET_HOUR = 9, previously a single fixed 09:30 UTC dispatch). Iterates over Goal.to_be_reminded and generates a reminder notification for each stale goal whose owner's local hour matches — subject to the deduplication guard (skips if an unacknowledged reminder for that goal already exists).
GeneratePastDueNotificationJob
Runs hourly; dispatches at 09:00 local time per user (TARGET_HOUR = 9, previously a single fixed 09:15 UTC dispatch). Iterates over Goal.all_past_due and generates a past-due notification for each overdue goal whose owner's local hour matches — same deduplication guard as the reminder job.
GenerateActivityReminderJob
Runs hourly; dispatches at 12:00 local time per user (TARGET_HOUR = 12, previously a single fixed 12:00 UTC dispatch). Iterates over User.no_recent_activity and calls SendPushNotification directly for users whose local hour matches — bypassing UserNotification.generate entirely. This sends a push-only nudge ("Progress takes persistence, <first_name>! Let's work on our goals together") without creating a persisted UserNotification record.
GenerateHabitCheckinReminderJob
Runs hourly; reminds users who have a habit due "today" (Goal#due_today?, evaluated in the user's own timezone) and haven't checked in yet. Dispatches at 18:00 local time per user by default (TARGET_HOUR = 18). Dedupes per-goal, per-calendar-day — not "until acknowledged" — so a missed habit is reminded again the next day rather than going silent forever. Generates a habit_checkin_reminder (kind 27) notification with a check_in action.
Smart notification timing (v4.6 Phase 5, item 35): behind smart_notification_timing_enabled (FeatureFlagService, fail-closed), the job retimes each user's dispatch hour toward their own learned check-in window instead of the fixed 18:00, via Notifications::OptimalTimeCalculator:
- Pulls the user's
HabitCompletiontimestamps over a 30-day lookback, converted to local hour-of-day. - Requires at least 5 samples and a standard deviation ≤ 1.5 hours — a wide spread of check-in times isn't a "pattern" even with plenty of samples.
- On a confident pattern, returns the mean check-in hour minus a 15-minute lead (e.g. a user who consistently checks in around 7:30am gets nudged at 7:15am). Because the job dispatches on hour granularity, the 15-minute lead only shifts observed behavior when it crosses an hour boundary — this is learned-hour retiming with a lead-time factor, not minute-exact nudging.
- Falls back to the static
TARGET_HOUR = 18whenever the flag is off, evaluation errors, or the pattern is too sparse/inconsistent — so this never regresses today's behavior.
No new personal data is collected or exposed — the calculation reads existing HabitCompletion records server-side only.
Key files: rails_api/app/jobs/social/generate_habit_checkin_reminder_job.rb, rails_api/app/services/notifications/optimal_time_calculator.rb.
Real-Time Delivery
Notifications are broadcast over ActionCable via GraphQL subscriptions at WS /cable. Three topics exist today:
| Topic | Triggered from | Payload type | Frontend consumer |
|---|---|---|---|
notificationUpdate | UserNotification.generate (app/models/user_notification.rb) | Types::UserNotificationType | useNotificationStream.ts — shared fan-out, see § Frontend: useNotificationStream |
achievementUnlocked | UserAction.generate, the single choke point for badge awards (app/models/user_action.rb) | Types::UserActionType | AchievementWatcher.vue, via the useLiveQuery substrate — see below |
feedUpdate | UserFeedItem#publish_feed_update (after_create_commit, app/models/user_feed_item.rb) and Social::FanOutFeedUpdateJob (app/jobs/social/fan_out_feed_update_job.rb) — both call Subscriptions::FeedUpdate.trigger, the single publish choke point (app/graphql/subscriptions/feed_update.rb) | Types::FeedUpdatePayloadType — an invalidation ping, not a rendered feed item | useUnifiedFeed.ts, via the useLiveQuery substrate — see Activity Feed § Real-Time Delivery |
All three topics are keyed on user_id (public_id) and share the same authorization shape — a topic-specific authorize! checking user_id == context[:current_user].public_id, not a generic can_subscribe_to? predicate (Decision B, OBJ-2486). All three triggers wrap the ActionCable broadcast in a rescue Redis::BaseError fail-soft so a Redis blip never blocks the record being created (UserNotification.trigger_subscription, UserAction.trigger_subscription, Subscriptions::FeedUpdate.trigger).
When UserNotification.generate is called, it fires:
RailsServerSchema.subscriptions.trigger(
'notificationUpdate',
{ user_id: user.public_id },
notification
)The subscription is scoped to a user_id (public_id), enforcing that users can only subscribe to their own notifications:
def subscribe(user_id:)
authorize!(user_id)
nil
end
private
def authorize!(user_id)
require_auth!
return if user_id == context[:current_user].public_id
raise GraphQL::ExecutionError, "Can't subscribe to this topic: #{user_id}"
endOn the frontend, exactly one useSubscription(NOTIFICATION_UPDATE_SUBSCRIPTION, { userId }) call exists for the whole app — it lives in useNotificationStream.ts, not in useNotifications.ts (extracted there in OBJ-2410 so more than one host can react to the same events without Apollo opening a second subscription). See § Frontend: useNotificationStream below. The subscription returns snake_case field aliases (details_json, created_at_time) which useNotifications.ts's listener normalises to camelCase before storing.
Incoming events that match an existing notification ID are merged in place (to handle content updates from deduplication). New notifications are prepended to the list.
Reconnect catch-up: useLiveQuery substrate (OBJ-2879)
ActionCableLink resubscribes a dropped channel automatically once the socket reconnects — that recovers the stream, not the events the server pushed while it was down. Before OBJ-2879, the only thing that recovered those was AchievementWatcher.vue's pollInterval: 10000 re-running USER_QUERY (which also selects notifications) every 10 seconds, for every open client.
src/composables/useLiveQuery.ts replaces that poll with an explicit catch-up contract, and is the substrate every future real-time topic on this app should pair its subscription with rather than reinventing a poll or a bespoke reconnect handler:
useLiveQuery(topicKey, subscriptionDoc, variables, refetch)starts oneuseSubscriptionpertopicKey, shared across every host that registers for it — the same double-delivery hazarduseNotificationStream.tsguards against (Apollo opens a second live subscription if two hosts each calluseSubscriptionindependently).- The caller's
refetchruns on every live subscription event. - Connection state is tracked once, globally, via
onCableConnectionChange(src/apollo-client.ts) —refetchruns one more time after a reconnect that followed a disconnect, which is the actual catch-up: any event missed while the socket was down is picked up by re-running the query rather than replaying missed broadcasts.
achievementUnlocked is the first (and currently only) topic on this substrate: AchievementWatcher.vue calls useLiveQuery('achievementUnlocked', ACHIEVEMENT_UNLOCKED_SUBSCRIPTION, { userId }, refetch), where refetch is the same USER_QUERY refetch the component already held. Because USER_QUERY selects both actions and notifications, this reconnect refetch is — incidentally, not by design — currently the only reconnect catch-up notifications get too. See dashboard.md's Known behaviors #6 for what that means for the notifications tray specifically. notificationUpdate itself is not yet on the useLiveQuery substrate.
Topics are keyed by topic name plus a stable, sorted-key serialization of variables (stableVariablesKey(), useLiveQuery.ts) — two hosts registering the same topic with different variables get two real subscriptions; two hosts with identical variables still share one, preserving the double-delivery guard above.
Identity-scoped subscriptions (OBJ-2889)
Module-level subscription state in useLiveQuery.ts and useNotificationStream.ts is scoped to the JS bundle lifetime, not the signed-in identity — a sign-out is an SPA router push with no page reload, so an Account A → Account B switch inside one bundle lifetime would otherwise leave every subscription still bound to A.
Both composables expose an identity-scoped reset:
resetLiveSubscriptions()(useLiveQuery.ts) andresetNotificationStream()(useNotificationStream.ts) each clear their listener set(s) first, then stop their detachedeffectScope— disposing the underlyinguseSubscriptionand sending the ActionCable unsubscribe (GraphqlChannel#unsubscribed, closing the server side too) — then create a fresh scope so a restart can happen.resetNotificationTray()(useNotifications.ts) clears the sharednotificationsandrecapNotificationsrefs in the same pass, so A's tray items don't sit in memory until B'sUSER_QUERYresolves.
All three are wired into useClerkSync.ts's account-switch watcher (watch(currentUserId, …, { flush: 'sync' })) as static imports, invoked synchronously before synced.value = true — this ordering is what keeps a [synced, currentUserId] restart watcher from racing ahead of the reset it depends on. They're also called from Session.logout() and Session.destroy(), closing the subscription at sign-out rather than only at the next sign-in.
Contract for any new consumer: restart on watch([synced, currentUserId], …), not watch(synced, …) — synced never flips back to false on an account switch, so a synced-only watcher never re-fires for the new identity. Hold the returned unregister function and invoke it before re-registering (see useNotificationTray.ts / AchievementWatcher.vue for the reference shape). A raw onNotificationEvent(listener) registration outside that pattern — e.g. TeamHomeView.vue's team-badge listener — is not identity-safe on its own: resetNotificationStream() clears the shared listener set on every account switch, and nothing re-registers a bare listener that isn't keyed on [synced, currentUserId]. Any such listener needs its own restart-or-unmount discipline if it can ever be mounted across a switch.
Push Notifications
SendPushNotification is an Interaction::Base subclass that delivers native push via FCM (Android) and APNs (iOS).
How it works
- Looks up the user by
public_idviaUser.public_find - Loads all
UserDevicerecords for that user - If the user or devices are not found, calls
result.failand returns early - Splits devices by
platformfield (iosvsandroid) - Routes iOS devices to
ApnsDelivery(HTTP/2 viaapnoticgem) - Routes Android devices to
FcmDelivery(FCM HTTP v1 API viagoogleauthgem) - Includes
kindandgoal_public_idin the data payload for deep-link routing
Payload structure
FCM (Android):
{
"message": {
"token": "<device_token>",
"notification": { "title": "Objectuve", "body": "<content>" },
"data": { "kind": "encouragement", "goal_public_id": "abc123" },
"android": { "priority": "high" }
}
}APNs (iOS): alert with title/body, sound: default, badge: 1, plus custom payload with kind and goal_public_id.
Delivery services
FcmDelivery(app/services/fcm_delivery.rb) — Authenticates via Firebase service account JSON, sends to FCM HTTP v1 API. Soft-deletesUserDeviceon UNREGISTERED/NOT_FOUND responses.ApnsDelivery(app/services/apns_delivery.rb) — Uses .p8 auth key viaapnoticgem. Uses development gateway in non-production. Soft-deletesUserDeviceon 410/BadDeviceToken.
Frontend registration
The usePushNotifications composable (src/composables/usePushNotifications.ts) handles:
- Permission check and request via
@capacitor/push-notifications - Token registration via
SEND_DEVICE_TOKEN_MUTATION - Notification tap routing based on
kindandgoal_public_id - Pre-prompt modal (
PushPermissionPrompt.vue) with coach-toned copy and 72-hour re-ask delay
Device tokens are stored in the UserDevice model with a platform field (ios or android).
GenerateActivityReminderJob calls SendPushNotification directly with kind: 'reminder' without going through UserNotification.generate, so activity reminder pushes do not create a persisted notification record and do not trigger the WebSocket subscription.
Frontend: useNotifications Composable
src/composables/useNotifications.ts
State is declared at module level (outside the composable function), making it shared across all component instances on the same page — a singleton pattern for notification state.
// Module-level — shared across all consumers
const notifications = ref<AppNotification[]>([])AppNotification interface
| Field | Type | Notes |
|---|---|---|
id | string | Integer ID cast to string |
kind | string | Enum string (e.g., "encouragement") |
content | string | Display text |
detailsJson | string | JSON string of details payload |
acknowledged | boolean | Read/dismissed state |
createdAtTime | string | Unix timestamp as string |
Methods
| Method | Description |
|---|---|
setNotifications(list) | Replaces the full list — called on initial page load from the user query result |
acknowledgeNotification(id) | Fires ACKNOWLEDGE_NOTIFICATION_MUTATION, removes item from list |
acknowledgeAll() | Fires ACKNOWLEDGE_NOTIFICATION_MUTATION for every unread notification in parallel, then filters the list to only acknowledged items |
deleteNotification(id) | Fires ACKNOWLEDGE_NOTIFICATION_MUTATION (bug — uses acknowledge mutation, not delete; see Known Behaviors item 2), removes item from list |
subscribeToNewNotifications(userId) | Registers the tray's mutation/toast/achievement handling as a listener on the shared stream (see below) and starts the stream if no host has yet — both idempotent, so any number of hosts can call this safely |
Computed values
unreadNotifications— filtered list of items whereacknowledged === falseunreadCount— length ofunreadNotifications
Subscription event handling
When a notification arrives via subscription:
- If
details.action_name === 'level_up'anddetails.levelis set: callsshowLevelUp(level)fromuseAchievementNotifications - If
notification.kindis inSILENT_TRAY_KINDS(currently justpartner_declined): lands in the tray with no toast — a declined partner request is the recipient's news to open on their own terms, not an interruption - Otherwise: shows an Ionic toast at the top of the screen (3s duration, dark color, Dismiss button)
Frontend: useNotificationStream (shared subscription fan-out)
src/composables/useNotificationStream.ts
The single live notificationUpdate subscription lives here, not in useNotifications.ts — extracted in OBJ-2410 so more than one host (Dashboard's tray, TeamHomeView's bell, NotificationsPanel's team-scoped list) can react to the same events without Apollo opening a second subscription and double-delivering every update.
startNotificationStream(userId)— starts the module-leveluseSubscription(NOTIFICATION_UPDATE_SUBSCRIPTION, { userId })the first time any host calls it (a module-levelstartedflag makes every later call a no-op). There is no "primary" host — Dashboard,TeamHomeView, or any future host can be the one that starts it, which is what lets a cold deep-link straight into/team-home(no Dashboard ever mounted) still receive live events.onNotificationEvent(listener)— registers a callback invoked with every incoming raw event; returns an unregister function. Callers must invoke it on scope dispose (onUnmounted) to avoid leaking listeners across mount/unmount cycles.
useNotifications.ts's subscribeToNewNotifications(userId) wraps both calls for the tray. TeamHomeView.vue and NotificationsPanel.vue's team-scoped branch each register their own listener directly instead — they want a refetch, not the tray's toast/achievement side effects.
Accountability-partner status (Phase 2, OBJ-2880): usePartnerStatus.ts and AlliesPage.vue each register their own onNotificationEvent listener and refetch() their partner-status query when the incoming event's kind is one of partner_request, partner_accepted, partner_declined, or partner_ended — this is what makes an open AlliesPage/Dashboard/Goal.vue screen reflect a partner accepting or declining without navigating away and back. Both key registration on currentUserId alone (watch(currentUserId, …, { immediate: true })) rather than the [synced, currentUserId] pair § Identity-scoped subscriptions names for a new consumer, and both hold the returned unregister function and invoke it before re-registering and on scope dispose. There is deliberately no separate subscription topic for partner-status changes — AcceptPartnerRequest, DeclinePartnerRequest, and EndPartnership already call UserNotification.generate, which fires notificationUpdate to the right recipient; a second topic would duplicate delivery for events the app already receives. See Accountability partners for the interaction-level detail.
Reconnect catch-up (OBJ-2882): notificationUpdate still isn't on the useLiveQuery substrate, so the live listener above recovers nothing missed while the socket was down. As of the v4.45 reconnect gate, usePartnerStatus.ts closes that gap on its own — it registers directly against onCableConnectionChange (src/apollo-client.ts), mirroring useLiveQuery.ts's disconnect-then-connect gating, and refetches once after a reconnect that followed a real disconnect. AlliesPage.vue's separate onNotificationEvent partnership listener does not have this catch-up yet — tracked as a follow-up, not fixed here.
Team-scoping contract: a team-scoped listener decides "is this event mine?" from the event's own payload — details.team_public_id, parsed from detailsJson — compared against the team it's scoped to. This is the same key Social::BuildNotificationHistory filters on server-side (details->>'team_public_id', rails_api/app/interactions/social/build_notification_history.rb). Any new team-notification kind must write team_public_id into its details payload, or its badge/panel silently stops updating live — the server-side history query still finds it on next navigation, but no listener ever refetches on it. Malformed or absent detailsJson is a no-op for every listener that parses it, never a throw.
Frontend: NotificationsPanel Component
src/components/ui/NotificationsPanel.vue
An IonPopover triggered by clicking the element with id="notifications-trigger". Width is fixed at 420px via CSS custom properties on the popover shadow DOM.
Layout
- Header — Bell icon, "Notifications" title, unread count badge (red pill, hidden when 0), "Mark all read" button (hidden when count is 0)
- Empty state — Shown when
notifications.length === 0: bell icon, "All caught up!" heading, "No new notifications" subtext - Notification list —
TransitionGroupwith slide-in/slide-out animations
Per-notification row
- Icon — Kind-specific Lucide icon with a colored circular background:
encouragement→ Heart, pinkbadge→ Award, ambercomment→ MessageCircle, bluereminder→ Clock, indigopast_due→ AlertCircle, redfollow→ Users, greengeneral/ unknown → Bell, slate
- Content — Notification text and relative timestamp (via
moment.unix().fromNow()) - Unread indicator — Blue tinted row background when
!acknowledged - Action buttons — Visible on hover only (CSS
opacity: 0default,opacity: 1on parent hover): checkmark to acknowledge, trash to delete
Click behavior
Clicking a notification row:
- Acknowledges it if not already acknowledged
- Parses
detailsJsonand navigates to/goal/<goal_public_id>if the detail is present
Frontend: Achievement Notifications
src/composables/useAchievementNotifications.ts
Handles the visual celebration layer for badge unlocks and level-ups. Uses a queue-based system so multiple rapid achievements don't overlap.
Queue behavior
- Achievements are pushed onto
notificationQueue processQueuechecksisShowingNotification— if a toast is already visible, it waits- Once the current toast dismisses,
processQueueis called again after a 1s buffer
Badge unlock flow (showAchievementUnlocked)
- Looks up badge metadata from
BadgeCollectionby key - Looks up rarity from the inline
getRarityMap()function - Pushes an
AchievementNotificationonto the queue - Calls
processQueue
Level-up flow (showLevelUp)
Called directly from useNotifications when a subscription event includes action_name: 'level_up'. Does not use the queue — fires immediately.
Rarity is derived from level:
- Levels 1–3 → common
- Levels 4–6 → rare
- Levels 7–9 → epic
- Level 10+ → legendary
Confetti (fireConfetti)
Each rarity has a distinct confetti configuration:
| Rarity | Colors | Particle count | Spread | Duration | Extra |
|---|---|---|---|---|---|
| common | Blues | 50 | 60° | 2s | — |
| rare | Blue/purple | 100 | 80° | 3s | — |
| epic | Purples | 150 | 100° | 4s | — |
| legendary | Golds | 200 | 120° | 5s | Star shapes from center |
Confetti fires from both lower corners simultaneously using requestAnimationFrame for the duration.
celebrateAchievement(badgeKey, skipToast?)
Fires confetti immediately without waiting for the queue. Optionally also calls showAchievementUnlocked. Used when the user is already viewing the Achievements page.
UI Components
| Component | Location | Description |
|---|---|---|
NotificationsPanel | src/components/ui/NotificationsPanel.vue | Popover panel with full notification list, mark-all-read, per-item acknowledge and delete |
The notification bell trigger (the button that opens NotificationsPanel) is implemented in the parent layout component and must have id="notifications-trigger" for the popover trigger binding to work.
Testing the Feature
Manual Testing Checklist
- [ ] Encouragement dedup — Send two encouragements on the same goal before acknowledging the first. Verify only one notification exists and its content changes to the "new encouragements" plural text.
- [ ] Comment notification — Add a comment on a goal. Verify a notification appears in the panel with the comment icon and blue background.
- [ ] Badge notification — Trigger a badge (e.g., create a first goal). Verify the badge unlock toast fires with confetti and the notification appears in the panel with the award icon.
- [ ] Level-up notification — Trigger a level-up. Verify the level-up toast fires (not the generic toast) with rarity-appropriate confetti and no badge toast on top of it.
- [ ] Reminder notification — Check that a stale goal (no updates in threshold period) generates a reminder. Verify clicking the notification navigates to
/goal/<id>. - [ ] Past-due notification — Verify a goal past its target date generates a past-due notification with the red alert icon.
- [ ] Follow notification — Follow another user's public goal. Verify the goal owner receives a notification naming the follower and goal.
- [ ] Mark all read — Open panel with multiple unread items. Click "Mark all read". Verify unread count badge disappears and all rows lose the blue background.
- [ ] Delete notification — Click the trash icon on a notification. Verify it animates out and disappears from the list.
- [ ] Real-time delivery — In two browser tabs logged in as the same user, trigger an action in one tab. Verify the notification appears in the other tab's panel without a page reload.
- [ ] Push notification — On a device with a registered
UserDevicetoken, trigger a notification. Verify a native push is received. - [ ] Empty state — Delete all notifications. Verify the "All caught up!" empty state is shown.
Backend Unit Tests
cd rails_api
# Model specs (enums, associations, custom methods)
bundle exec rspec spec/models/user_notification_spec.rb
# Interaction specs
bundle exec rspec spec/interactions/gamification/acknowledge_notification_spec.rb
bundle exec rspec spec/interactions/social/delete_notification_spec.rb
# GraphQL mutation specs
bundle exec rspec spec/requests/mutations/acknowledge_notification_spec.rb
bundle exec rspec spec/requests/mutations/delete_notification_spec.rb
# Job specs
bundle exec rspec spec/jobs/social/generate_notification_job_spec.rb
bundle exec rspec spec/jobs/social/generate_reminder_notifications_job_spec.rb
bundle exec rspec spec/jobs/social/generate_past_due_notification_job_spec.rb
bundle exec rspec spec/jobs/social/generate_activity_reminder_job_spec.rb
bundle exec rspec spec/jobs/gamification/create_level_up_notification_job_spec.rbFrontend Unit Tests
cd ionic_frontend
npm run test:unit -- --run -t "useNotifications"
npm run test:unit -- --run -t "useAchievementNotifications"
npm run test:unit -- --run -t "NotificationsPanel"GraphQL API Reference
Queries
Notifications are fetched as part of the user query:
query UserQuery($id: ID!) {
user(id: $id) {
notifications {
id
kind
content
detailsJson
acknowledged
createdAtTime
}
}
}Mutations
mutation AcknowledgeNotificationMutation($notificationId: ID!) {
acknowledgeNotification(id: $notificationId) {
result {
success
}
}
}
mutation DeleteNotificationMutation($notificationId: ID!) {
deleteNotification(id: $notificationId) {
result {
success
}
errors
}
}Both mutations take an id argument — the public_id of the UserNotification record, looked up via UserNotification.public_find. Both enforce ownership — a user can only acknowledge or delete their own notifications. Both return errors on failure with the message "Unable to find notification".
Subscriptions
subscription NotificationUpdateSubscription($userId: ID!) {
notificationUpdate(userId: $userId) {
id
kind
content
details_json
acknowledged
created_at_time
}
}userId is the user's public_id. The subscription fires when UserNotification.generate is called — including deduplication updates where an existing notification's content is changed.
Note: the subscription returns details_json and created_at_time as snake_case aliases. The useNotifications composable normalises these to camelCase (detailsJson, createdAtTime) before storing.
Known Behaviors & Edge Cases
add_comment_notificationdeduplication bug — The deduplication query inadd_comment_notificationusesKIND_ENUMS[:encouragement](0) instead ofKIND_ENUMS[:comment](3). This means: if an unacknowledged encouragement notification exists for the same goal, a new comment will incorrectly update that encouragement notification's content to the plural comment text instead of creating a new comment notification. If no encouragement notification exists, a new comment notification is created correctly.deleteNotificationuses the wrong mutation — InuseNotifications.ts,deleteNotificationMutationis wired toACKNOWLEDGE_NOTIFICATION_MUTATIONinstead of a delete mutation. CallingdeleteNotification(id)from the frontend acknowledges the notification on the backend rather than destroying it. The record is removed from the frontend list regardless.AcknowledgeNotificationandDeleteNotificationlook up bypublic_id— Both useUserNotification.public_find(notification_id). The GraphQL argument is namedid(notpublicId) but its value is thepublic_idtoken, consistent withPublicRecordconventions elsewhere in the codebase.GenerateActivityReminderJobdoes not persist notifications — It callsSendPushNotificationdirectly, creating noUserNotificationrecord. These push nudges do not appear in the in-app notifications panel and cannot be acknowledged or deleted.Reminder and past-due deduplication is Ruby-side —
GenerateReminderNotificationsJobandGeneratePastDueNotificationJobload all of a user's notifications into memory to check for duplicates, rather than using a database query. This is inefficient for users with many notifications.add_level_up_notificationuseskind: :badge— Level-up notifications are stored withkind = 1(badge) and distinguished bydetails['action_name'] == 'level_up'. The frontend checks this detail field insubscribeToNewNotificationsto route level-up events toshowLevelUprather than the generic toast.Subscription auth uses
context[:current_user], the same key as query resolvers — There is nocontext[:viewer];GraphqlChannel#executesets exactlycurrent_user,checkin_token, andchannel. Each subscription type guards itself with a topic-specificauthorize!(user_id)checkinguser_id == context[:current_user].public_id(Decision B, OBJ-2486) — see § Real-Time Delivery above.Push delivery is fire-and-forget —
SendPushNotificationdelegates toFcmDelivery(Android) andApnsDelivery(iOS), neither of which returns a delivery receipt. The interaction stores whatever response object is available inresult.detailsbut does not check the response status or retry on failure. If FCM or APNs rejects the request, the notification is still persisted and the WebSocket subscription still fires.Module-level notification state —
useNotificationsstoresnotificationsat module scope, not inside the composable function. This means the list persists across component mount/unmount cycles on the same page. As of OBJ-2889,resetNotificationTray()clears it automatically on sign-out and on an in-session account switch (see § Identity-scoped subscriptions) — new code should not need to callsetNotifications([])manually for that case.add_follow_notificationloads the goal —add_follow_notificationcallsGoal.public_find(goal_public_id)to build the notification content string. If the goal has been soft-deleted between the follow action and the notification job execution, this will raiseActiveRecord::RecordNotFound.
Last updated: 2026-08-24 — added partner_declined (kind 37) and the accountability-partner live-refresh listeners on the existing notificationUpdate stream (Phase 2, OBJ-2880); added the feedUpdate topic to the Real-Time Delivery inventory (Phase 3, OBJ-2881); corrected the stale context[:viewer] subscription-auth snippet/gotcha and documented usePartnerStatus.ts's direct reconnect catch-up (Phase 4, OBJ-2882).