feat: disciple link

This commit is contained in:
Krispire Admin
2026-08-07 22:46:08 +05:30
parent 4553725d4e
commit afcdd7ef19
21 changed files with 863 additions and 23 deletions
+91
View File
@@ -39,6 +39,12 @@ const (
NotifCommentReply = "comment_reply"
NotifCommentMention = "comment_mention"
// NotifDiscipleJoined is broadcast to the whole community when an admin
// recognises an account as one of Śrīla Prabhupāda's own initiated
// disciples. The actor is the disciple, so it renders and links exactly
// like the other actor-driven kinds.
NotifDiscipleJoined = "disciple_joined"
// Reminder kinds are delivered live (email + push + a websocket toast)
// but NOT persisted — they're ephemeral nudges, not history.
NotifChantReminder = "chant_reminder"
@@ -53,6 +59,7 @@ var PrefCategories = map[string][]string{
"follows": {NotifFollowRequest, NotifFollowAccepted},
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
"community": {NotifDiscipleJoined},
}
// PrefChannels is every delivery channel a matrix cell can address.
@@ -672,6 +679,90 @@ func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientI
return nil
}
// pushBroadcastDelay paces the per-user push fan-out. Web Push is one HTTP
// request per subscription to an external service (FCM/Mozilla/Apple), so a
// tight loop over hundreds of devotees would hammer those endpoints and spike
// outbound connections. A small gap keeps the broadcast a background trickle.
const pushBroadcastDelay = 50 * time.Millisecond
// BroadcastDiscipleJoined tells the community that `actor` — an account an
// admin has just recognised as one of Śrīla Prabhupāda's own initiated
// disciples — has joined.
//
// Two channels, deliberately decoupled by cost:
//
// - In-app: a single bulk INSERT for every user. Cheap and instant, so it
// happens inline and the row is durable the moment this returns.
// - Push + realtime: one call per recipient against external services, so it
// runs paced. The caller is expected to invoke this on a detached,
// time-limited context (not the admin's request context, which dies with
// the response).
//
// No email — announcements are in-app/push only by design; see the
// deliverability reasoning in the disciple-link notes.
//
// Errors from the fan-out are logged, not returned: the announcement is
// already durably recorded in-app, and a failed push to one devotee must not
// abort the rest.
func (s *NotificationService) BroadcastDiscipleJoined(ctx context.Context, actor *model.User) error {
if actor == nil {
return nil
}
written, err := s.notifs.BroadcastToAllUsers(ctx, NotifDiscipleJoined, actor.ID)
if err != nil {
return err
}
s.logger.Info("disciple-joined broadcast: in-app rows written",
zap.String("actor", actor.Handle), zap.Int64("recipients", written))
// Everything below is best-effort delivery on top of the durable row.
recipients, err := s.notifs.PushRecipientsForKind(ctx, NotifDiscipleJoined, actor.ID)
if err != nil {
s.logger.Warn("disciple-joined broadcast: push recipient lookup failed", zap.Error(err))
return nil
}
if len(recipients) == 0 || s.push == nil {
return nil
}
name := actorDisplay(actor)
payload := PushPayload{
Title: "A direct disciple has joined",
Body: name + " — an initiated disciple of Śrīla Prabhupāda — is now on Prabhupāda Says.",
URL: s.frontendURL + "/u/" + actor.Handle,
Icon: s.iconURL(),
Tag: NotifDiscipleJoined,
}
sent := 0
for _, uid := range recipients {
// Honour cancellation so a shutdown (or the caller's timeout) stops
// the fan-out cleanly rather than running to completion detached.
if ctx.Err() != nil {
s.logger.Warn("disciple-joined broadcast: push fan-out cut short",
zap.Int("sent", sent), zap.Int("total", len(recipients)), zap.Error(ctx.Err()))
return nil
}
if s.rt != nil {
if err := s.rt.Trigger(ctx, "notifications."+uid, "notification", map[string]any{
"kind": NotifDiscipleJoined,
"actor_name": name,
"actor_handle": actor.Handle,
}); err != nil {
s.logger.Debug("disciple-joined realtime publish failed",
zap.String("recipient", uid), zap.Error(err))
}
}
s.push.SendToUser(ctx, uid, payload)
sent++
time.Sleep(pushBroadcastDelay)
}
s.logger.Info("disciple-joined broadcast: push fan-out complete",
zap.String("actor", actor.Handle), zap.Int("recipients", sent))
return nil
}
// iconURL is the absolute URL of the notification icon (the app's square
// icon), so it renders regardless of the client's base path.
func (s *NotificationService) iconURL() string {