Push notifications
Overview
Every push notification in Objectuve flows through one of two mechanisms:
UserNotification.generate(user_id:, kind:, content:, details: nil, actions: nil)(rails_api/app/models/user_notification.rb:89) — the canonical, preferred pattern. Writes aUserNotificationDB row, fires thenotificationUpdateGraphQL subscription (trigger_subscription), then callsSocial::SendPushNotification.call(...)to fan out to the user's registered devices.Social::GenerateNotificationJob— an async wrapper (perform_nowin dev/test,perform_laterin production) used by a handful of legacyUserNotification.add_*_notificationclass methods that need de-duplication/aggregation logic (e.g. collapsing multiple encouragements on the same goal into one notification) before calling.generateinternally.
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
| # | Kind | Description | Trigger criteria (incl. self-exclusion) | File:line |
|---|---|---|---|---|
| 1 | ally_request | Ally request received | Social::SendAllyRequest notifies the target user when a request is sent. Sender is never notified. | app/interactions/social/send_ally_request.rb:22 |
| 2 | ally_accepted | Ally request accepted | Social::AcceptAllyRequest notifies the original requester when their ally request is accepted. Accepter is never notified. | app/interactions/social/accept_ally_request.rb:18 |
| 3 | partner_accepted (ally-invite path) | Accountability-partner invite accepted | Social::AcceptAllyInvite notifies the inviter when their invite is accepted. Accepter is never notified. | app/interactions/social/accept_ally_invite.rb:115 |
| 4 | partner_request | Accountability-partner request received | Social::SendPartnerRequest notifies the target ally. Sender is never notified. | app/interactions/social/send_partner_request.rb:35 |
| 5 | partner_accepted (partner-request path) | Accountability-partner request accepted | Social::AcceptPartnerRequest notifies the original requester. Accepter is never notified. | app/interactions/social/accept_partner_request.rb:77 |
| 6 | partner_ended | Accountability partnership ended | Social::EndPartnership notifies the other side of the partnership. The user who ends it is never notified. | app/interactions/social/end_partnership.rb:48 |
| 7 | partner_nudge | "Your partner is rooting for you" nudge | Social::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 |
| 8 | encouragement | Encouragement on a goal | Social::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 |
| 9 | comment | Comment on a goal | UserNotification.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 |
| 10 | follow | New follower on a public goal | UserNotification.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 |
| 11 | reaction | Reaction on a goal progress update | UserNotification.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 |
| 12 | badge (community challenge) | Community challenge completed | Social::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 |
| 13 | community_invite | Invited to join a community | Social::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 |
| 14 | compassionate_break | Streak broken, repair window open | Gamification::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 |
| 15 | badge (level up) | Level up | UserNotification.add_level_up_notification (app/models/user_notification.rb:149), called from Gamification::CreateLevelUpNotificationJob. | app/jobs/gamification/create_level_up_notification_job.rb:8 |
| 16 | past_due | Goal past its due date | Social::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 |
| 17 | ai_check_in | Coach personalized check-in prompt | AiCoaching::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 |
| 18 | partner_missed_day | Accountability partner missed a check-in | Social::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 |
| 19 | feedback | Feedback post status changed | UserNotification.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 |
| 20 | welcome_back | Welcome-back streak-repair offer | Gamification::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 |
| 21 | partner_mutual_streak_milestone | Mutual accountability-partner streak crosses a tier | Gamification::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 |
| 22 | community_post | New post in a community | Social::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 |
| 23 | community_comment | New comment on a community post | Social::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 |
| 24 | mood_reminder | Daily mood check-in reminder | Social::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 |
| 25 | goal_milestone | Top-level goal completed | GoalTracking::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 |
| 26 | streak_milestone | Solo habit streak crosses a tier | Gamification::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 |
| 27 | habit_checkin_reminder | Daily habit due today, not yet checked in | Social::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.
PostReactionexists 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::GenerateMoodReminderJobis 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 is19:00in 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 fixed19:30UTC dispatch; that MVP cadence has been superseded by the per-user-timezone matching described here. - Goal completion vs.
Milestonecompletion (25): Deliberately scoped to top-level goals only, since roadmap-step completions already have their ownUserAction(roadmap_step_completed) and pushing on every step would be noisy. - Streak-milestone thresholds (26): Reuses
Gamification::AwardPartnerBonus::MILESTONE_TIERSverbatim ([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::GenerateHabitCheckinReminderJobis 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 is18:00in that user's own timezone — ahead of the19:00evening slots (mood_reminder,partner_missed_day). The per-goal "already due"/"already reminded" comparisons inside the job are also timezone-aware, viaGoal#due_today?anduser.timezone_or_default. Unlike the olderreminder/past_duekinds (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
- Add the text constant(s) and a new
KIND_ENUMSentry (append-only — never renumber) inrails_api/app/models/user_notification.rb. - Call
UserNotification.generate(user_id:, kind:, content:, details: nil)from the interaction/job that owns the trigger. Prefer this over hand-rollingSocial::SendPushNotification.calldirectly. - Document the self-exclusion / recipient-scope decision as a one-line comment at the call site.
- Add a happy-path spec asserting the notification is created for the right recipient(s) and NOT created for the actor.
- Add a row to the inventory table above.
Last updated: 2026-07-20