Skip to content

Push notifications

Overview

Every push notification in Objectuve flows through one of two mechanisms:

  1. UserNotification.generate(user_id:, kind:, content:, details: nil, actions: nil) (rails_api/app/models/user_notification.rb:89) — the canonical, preferred pattern. Writes a UserNotification DB row, fires the notificationUpdate GraphQL subscription (trigger_subscription), then calls Social::SendPushNotification.call(...) to fan out to the user's registered devices.
  2. Social::GenerateNotificationJob — an async wrapper (perform_now in dev/test, perform_later in production) used by a handful of legacy UserNotification.add_*_notification class methods that need de-duplication/aggregation logic (e.g. collapsing multiple encouragements on the same goal into one notification) before calling .generate internally.

Social::SendPushNotification (rails_api/app/interactions/social/send_push_notification.rb) is the actual delivery interaction — it looks up the recipient's UserDevice records, splits them by platform (ios/android), and sends via APNs or FCM respectively, isolating and reporting per-device delivery errors to Sentry without failing the whole call.

Every notification kind must be registered in UserNotification::KIND_ENUMS (rails_api/app/models/user_notification.rb:63) — this is an append-only integer-backed enum; never renumber or remove an existing entry, only append new ones.

Milestone (rails_api/app/models/milestone.rb) is an STI subclass of Goal representing a roadmap sub-step — it is unrelated to the "streak milestone" concept below (a gamification threshold-crossing event, e.g. a 7/30/100/365-day streak). Do not conflate the two when reading this table.


Inventory

#KindDescriptionTrigger criteria (incl. self-exclusion)File:line
1ally_requestAlly request receivedSocial::SendAllyRequest notifies the target user when a request is sent. Sender is never notified.app/interactions/social/send_ally_request.rb:22
2ally_acceptedAlly request acceptedSocial::AcceptAllyRequest notifies the original requester when their ally request is accepted. Accepter is never notified.app/interactions/social/accept_ally_request.rb:18
3partner_accepted (ally-invite path)Accountability-partner invite acceptedSocial::AcceptAllyInvite notifies the inviter when their invite is accepted. Accepter is never notified.app/interactions/social/accept_ally_invite.rb:115
4partner_requestAccountability-partner request receivedSocial::SendPartnerRequest notifies the target ally. Sender is never notified.app/interactions/social/send_partner_request.rb:35
5partner_accepted (partner-request path)Accountability-partner request acceptedSocial::AcceptPartnerRequest notifies the original requester. Accepter is never notified.app/interactions/social/accept_partner_request.rb:77
6partner_endedAccountability partnership endedSocial::EndPartnership notifies the other side of the partnership. The user who ends it is never notified.app/interactions/social/end_partnership.rb:48
7partner_nudge"Your partner is rooting for you" nudgeSocial::SendPartnerNudge notifies the partner. Guarded by require_no_nudge_today (one nudge/day/partnership). Sender is never notified.app/interactions/social/send_partner_nudge.rb:21
8encouragementEncouragement on a goalSocial::SendEncouragement notifies the goal owner only (not all allies/followers). Guarded by must_not_have_sent_today (one encouragement/day/goal/sender). Sender is never notified. Repeat encouragements on an unacknowledged notification collapse into one row via UserNotification.add_encouragement_notification (app/models/user_notification.rb:106), switching the copy to the "multiple" variant.app/interactions/social/send_encouragement.rb:14
9commentComment on a goalUserNotification.add_comment_notification (app/models/user_notification.rb:129) notifies the goal owner only. Same collapse-to-"multiple" behavior as encouragement. Commenter is never notified.app/models/user_notification.rb:129
10followNew follower on a public goalUserNotification.add_follow_notification (app/models/user_notification.rb:166), called from Social::ToggleFollowGoal, notifies the goal owner when someone follows. Follower is never notified.app/interactions/social/toggle_follow_goal.rb:45
11reactionReaction on a goal progress updateUserNotification.add_reaction_notification (app/models/user_notification.rb:178) notifies the goal owner only. Same collapse-to-"multiple" behavior. Reactor is never notified.app/models/user_notification.rb:178
12badge (community challenge)Community challenge completedSocial::FinalizeCommunityChallenge notifies each participant who met target_goal_count when the challenge is finalized. Only qualifying participants are notified — non-qualifiers get nothing.app/interactions/social/finalize_community_challenge.rb:19
13community_inviteInvited to join a communitySocial::InviteAllyToCommunity notifies the invited ally. Guarded by a 7-day idempotency window (no duplicate invite pushes within 7 days). Inviter is never notified.app/interactions/social/invite_ally_to_community.rb:23
14compassionate_breakStreak broken, repair window openGamification::StreakBrokenJob notifies the goal owner once per broken streak (guarded by an existing-notification check on goal_public_id), skipped entirely if the user's coaching rhythm preference is when_needed. AI-generated copy via Ai::CoachService#generate_compassionate_break, falling back to COMPASSIONATE_BREAK_TEXT.app/jobs/gamification/streak_broken_job.rb:25
15badge (level up)Level upUserNotification.add_level_up_notification (app/models/user_notification.rb:149), called from Gamification::CreateLevelUpNotificationJob.app/jobs/gamification/create_level_up_notification_job.rb:8
16past_dueGoal past its due dateSocial::GeneratePastDueNotificationJob (Crono, hourly dispatch with per-user-timezone matching inside the job, target local hour 09:00) iterates Goal.all_past_due, notifying each goal's owner, skipped if an unacknowledged past_due notification already exists for that goal.app/jobs/social/generate_past_due_notification_job.rb
17ai_check_inCoach personalized check-in promptAiCoaching::GenerateAiCheckInPromptsJob (Crono, daily 10:00, allowlist-gated during ramp via AI_CHECK_IN_INTERNAL_ALLOWLIST) evaluates per-user eligibility (rhythm_eligible?, stuck_eligible?, milestone_eligible?) and notifies eligible users only.app/jobs/ai_coaching/generate_ai_check_in_prompts_job.rb:41
18partner_missed_dayAccountability partner missed a check-inSocial::GeneratePartnerMissedDayAlertsJob (Crono, hourly dispatch with per-partner-timezone matching inside the job, target local hour 19:00 in the notified partner's own timezone) iterates active partnerships where one side missed today's habit check-in, notifying the other (checked-in) partner — the partner who missed is never notified about their own miss via this path.app/jobs/social/generate_partner_missed_day_alerts_job.rb
19feedbackFeedback post status changedUserNotification.add_feedback_status_notification (app/models/user_notification.rb:198), called from Feedback::NotifyVotersJob, notifies voters on a feedback post, excluding the post author (handled upstream in the job).app/jobs/feedback/notify_voters_job.rb:15
20welcome_backWelcome-back streak-repair offerGamification::WelcomeBackOfferResolver#resolve notifies the lapsed user themselves when a mercy-eligible habit is found (≥5 days inactive, unexpired repair window). Guarded by welcome_back_consumed?, which scopes to the current lapse only (UserNotification.welcome_back.where(user_id: ...).where('created_at > ?', user.last_activity_date)) — not a lifetime guard — so the push fires once per welcome-back event even though resolve runs on every GraphQL query, and a user who recovers and later lapses again remains eligible for a new push.app/services/gamification/welcome_back_offer_resolver.rb:17 (guard call), method at welcome_back_offer_resolver.rb:59
21partner_mutual_streak_milestoneMutual accountability-partner streak crosses a tierGamification::AwardPartnerBonus#fire_milestone_notifications notifies both partners when their mutual check-in streak crosses one of MILESTONE_TIERS = [7, 30, 100, 365], gated by previous_streak == new_streak - 1 (must cross the tier on this exact check-in, not jump past it).app/interactions/gamification/award_partner_bonus.rb:62
22community_postNew post in a communitySocial::CreateCommunityPost#notify_members notifies every other member of the community (not the poster) when a new post is created. Assumption: broader scope than comments because a brand-new post is a single, discrete event per community, not a per-reply cascade.app/interactions/social/create_community_post.rb:46
23community_commentNew comment on a community postSocial::AddPostComment#notify_thread_participants notifies the post author + prior commenters (thread participants), excluding the new commenter. Participant list is computed before the new comment is created so the commenter never appears as their own participant. Assumption: narrower than community_post — a comment only concerns people already in the thread, not the whole community, to avoid spam on active posts.app/interactions/social/add_post_comment.rb:42
24mood_reminderDaily mood check-in reminderSocial::GenerateMoodReminderJob (Crono, hourly dispatch with per-user-timezone matching inside the job, target local hour 19:00) iterates all users via find_each, skipping anyone who already logged a MoodLog today (created_at within the user's local day) or who was already reminded today.app/jobs/social/generate_mood_reminder_job.rb, registered in config/cronotab.rb
25goal_milestoneTop-level goal completedGoalTracking::UpdateGoal#notify_goal_completed notifies the goal owner when a top-level goal (parent_goal_id.blank?) transitions to completed: true (goal.saved_change_to_completed?). Assumption: roadmap-step (Milestone) completions are excluded — those already surface via the existing roadmap_step_completed UserAction and would be redundant/noisy here.app/interactions/goal_tracking/update_goal.rb:292 (guard call), notify method at update_goal.rb:303
26streak_milestoneSolo habit streak crosses a tierGamification::Subscribers.notify_streak_milestone, invoked from handle_streak_advanced (subscribed to the gamification.streak_advanced domain event published by GoalTracking::CheckInHabit), notifies the checking-in user when their individual habit streak crosses one of the same Gamification::AwardPartnerBonus::MILESTONE_TIERS = [7, 30, 100, 365] tiers, gated by the identical crossing condition (prior_streak == current_streak - 1) reused from the partner-bonus logic rather than inventing new thresholds.app/interactions/gamification/subscribers.rb:32 (invocation in handle_streak_advanced), method at subscribers.rb:37
27habit_checkin_reminderDaily habit due today, not yet checked inSocial::GenerateHabitCheckinReminderJob (Crono, hourly dispatch with per-user-timezone matching inside the job, target local hour 18:00) iterates Goal.active_habits.includes(:user) via find_each, notifying the habit owner when Goal#due_today? is true (due per its recurrence schedule, in the user's own timezone, and not already checked in today) and the user hasn't already been reminded for that specific goal today. Includes a check_in quick action, same as the reminder kind.app/jobs/social/generate_habit_checkin_reminder_job.rb, registered in config/cronotab.rb

Notes on scope decisions (items 22–27)

  • Community posts vs. comments (22, 23): Posts notify the whole community (minus the poster) because a new post is a rare, discrete event. Comments notify only thread participants (post author + prior commenters, minus the new commenter) because a popular post could otherwise generate a comment-storm of pushes to everyone in a large community.
  • Reactions: Not implemented. PostReaction exists as a model but has no GraphQL mutation to create/toggle a community-post reaction today — there is no trigger site to hook without inventing new functionality outside this feature's scope.
  • Mood reminder cadence (24): Social::GenerateMoodReminderJob is dispatched hourly by Crono (anchored to worker boot time, not the top of the hour); the job itself matches each user's local hour and sends when it is 19:00 in that user's own timezone, so delivery lands within roughly an hour of that local time rather than at an exact instant. It was originally a single fixed 19:30 UTC dispatch; that MVP cadence has been superseded by the per-user-timezone matching described here.
  • Goal completion vs. Milestone completion (25): Deliberately scoped to top-level goals only, since roadmap-step completions already have their own UserAction (roadmap_step_completed) and pushing on every step would be noisy.
  • Streak-milestone thresholds (26): Reuses Gamification::AwardPartnerBonus::MILESTONE_TIERS verbatim ([7, 30, 100, 365]) and the same tier-crossing gate (previous_streak == new_streak - 1) as the existing mutual-partner-streak logic, per the explicit goal of not introducing a second, divergent threshold definition.
  • Habit check-in reminder cadence + dedupe (27): Social::GenerateHabitCheckinReminderJob is dispatched hourly by Crono (anchored to worker boot time, not the top of the hour); the job matches each user's local hour and sends when it is 18:00 in that user's own timezone — ahead of the 19:00 evening slots (mood_reminder, partner_missed_day). The per-goal "already due"/"already reminded" comparisons inside the job are also timezone-aware, via Goal#due_today? and user.timezone_or_default. Unlike the older reminder/past_due kinds (which dedupe on "an unacknowledged notification exists for this goal, ever," and so silently stop firing after the first miss until the user acknowledges it), this kind dedupes once per goal per calendar day — a missed habit is reminded again the next day rather than going silent indefinitely.

Adding a new notification type

  1. Add the text constant(s) and a new KIND_ENUMS entry (append-only — never renumber) in rails_api/app/models/user_notification.rb.
  2. Call UserNotification.generate(user_id:, kind:, content:, details: nil) from the interaction/job that owns the trigger. Prefer this over hand-rolling Social::SendPushNotification.call directly.
  3. Document the self-exclusion / recipient-scope decision as a one-line comment at the call site.
  4. Add a happy-path spec asserting the notification is created for the right recipient(s) and NOT created for the actor.
  5. Add a row to the inventory table above.

Last updated: 2026-07-20

Loading…