mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-07 08:01:41 +00:00
246 lines
9.3 KiB
Go
246 lines
9.3 KiB
Go
// Package notification owns per-user notification preferences + the in-app feed.
|
|
// Notify() is the gated ingress: it checks the user's preference for a category
|
|
// and, when enabled, persists a feed row and pushes a realtime event. It is
|
|
// best-effort and must never fail the caller's hot path (it runs inside inbox
|
|
// ingest), so all errors are swallowed.
|
|
package notification
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// EmailSender delivers a notification to a user's account email. Satisfied by
|
|
// notify.EmailNotificationService.
|
|
type EmailSender interface {
|
|
Send(ctx context.Context, to, cc, bcc []string, subject, message string) error
|
|
}
|
|
|
|
// SlackNotifier posts to the org's connected Slack. Satisfied by the
|
|
// integration service (NotifySlack).
|
|
type SlackNotifier interface {
|
|
NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error
|
|
}
|
|
|
|
// UserLookup resolves a user's email + name for email delivery. Satisfied by
|
|
// the user repository.
|
|
type UserLookup interface {
|
|
GetUser(ctx context.Context, id uuid.UUID) (*models.User, error)
|
|
}
|
|
|
|
// MemberLookup enumerates an org's members so NotifyOrg can resolve a
|
|
// permission-targeted audience. Satisfied by the organization repository.
|
|
type MemberLookup interface {
|
|
GetMembers(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationMember, error)
|
|
}
|
|
|
|
type Service interface {
|
|
GetPreferences(ctx context.Context, userID uuid.UUID) (*models.NotificationPreferences, *errx.Error)
|
|
UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *models.NotificationPreferences) *errx.Error
|
|
List(ctx context.Context, userID uuid.UUID, limit int, unreadOnly bool) ([]models.Notification, *errx.Error)
|
|
UnreadCount(ctx context.Context, userID uuid.UUID) (int, *errx.Error)
|
|
MarkRead(ctx context.Context, userID, notifID uuid.UUID) *errx.Error
|
|
MarkAllRead(ctx context.Context, userID uuid.UUID) *errx.Error
|
|
|
|
// Notify is the gated ingress — best-effort, never errors out the caller.
|
|
Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any)
|
|
|
|
// NotifyOrg raises the same notification for every accepted org member
|
|
// holding perm (never the whole org blindly), excluding exclude when set.
|
|
// Rows share groupKey, so the email flush coalesces the event into one
|
|
// message with every recipient in To; Slack fires at most once. Each
|
|
// member's own preferences still gate their channels. Best-effort.
|
|
NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string)
|
|
|
|
// WireDelivery attaches the email + Slack + user/member-lookup
|
|
// dependencies (wired post-construction in both mains) and starts the
|
|
// email digest flush loop when the email channel is deliverable. Any
|
|
// dependency may be nil — the matching channel is then skipped.
|
|
WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup, members MemberLookup)
|
|
|
|
// WirePush attaches APNs + device-token storage + the Redis digest window
|
|
// and starts the digest loop. Any nil dependency leaves push disabled.
|
|
WirePush(sender PushSender, tokens repository.DeviceTokenRepository, rdb *redis.Client)
|
|
|
|
// RegisterDevice / UnregisterDevice manage the caller's APNs tokens.
|
|
RegisterDevice(ctx context.Context, userID uuid.UUID, platform, token, environment string) error
|
|
UnregisterDevice(ctx context.Context, userID uuid.UUID, token string) error
|
|
}
|
|
|
|
type service struct {
|
|
repo repository.NotificationRepository
|
|
publisher *pubsub.StreamingPublisher
|
|
email EmailSender
|
|
slack SlackNotifier
|
|
users UserLookup
|
|
members MemberLookup
|
|
|
|
push PushSender
|
|
deviceTokens repository.DeviceTokenRepository
|
|
pushRedis *redis.Client
|
|
}
|
|
|
|
func (s *service) WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup, members MemberLookup) {
|
|
s.email = email
|
|
s.slack = slack
|
|
s.users = users
|
|
s.members = members
|
|
if s.email != nil && s.users != nil {
|
|
go s.emailFlushLoop()
|
|
}
|
|
}
|
|
|
|
func NewService(repo repository.NotificationRepository, publisher *pubsub.StreamingPublisher) Service {
|
|
return &service{repo: repo, publisher: publisher}
|
|
}
|
|
|
|
func (s *service) GetPreferences(ctx context.Context, userID uuid.UUID) (*models.NotificationPreferences, *errx.Error) {
|
|
p, err := s.repo.GetPreferences(ctx, userID)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (s *service) UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *models.NotificationPreferences) *errx.Error {
|
|
if err := s.repo.UpdatePreferences(ctx, userID, prefs); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) List(ctx context.Context, userID uuid.UUID, limit int, unreadOnly bool) ([]models.Notification, *errx.Error) {
|
|
out, err := s.repo.List(ctx, userID, limit, unreadOnly)
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *service) UnreadCount(ctx context.Context, userID uuid.UUID) (int, *errx.Error) {
|
|
c, err := s.repo.CountUnread(ctx, userID)
|
|
if err != nil {
|
|
return 0, errx.InternalError()
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (s *service) MarkRead(ctx context.Context, userID, notifID uuid.UUID) *errx.Error {
|
|
if err := s.repo.MarkRead(ctx, userID, notifID); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) MarkAllRead(ctx context.Context, userID uuid.UUID) *errx.Error {
|
|
if err := s.repo.MarkAllRead(ctx, userID); err != nil {
|
|
return errx.InternalError()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Notify checks the user's preference for category and fans the enabled
|
|
// channels. Silent on any miss/error.
|
|
func (s *service) Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.notifyOne(ctx, userID, orgID, category, title, body, link, meta, "", false)
|
|
}
|
|
|
|
// NotifyOrg resolves the permission-targeted audience and raises the
|
|
// notification for each member. Slack posts to one org workspace, so it fires
|
|
// for the first member whose prefs allow it and stays suppressed for the rest.
|
|
func (s *service) NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string) {
|
|
if s == nil || s.members == nil || orgID == uuid.Nil {
|
|
return
|
|
}
|
|
members, err := s.members.GetMembers(ctx, orgID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
org := orgID
|
|
slackFired := false
|
|
for _, m := range members {
|
|
if m.AcceptedAt == nil || m.UserID == uuid.Nil || m.UserID == exclude {
|
|
continue
|
|
}
|
|
if perm != 0 && !m.Permissions.HasPermission(perm) {
|
|
continue
|
|
}
|
|
fired := s.notifyOne(ctx, m.UserID, &org, category, title, body, link, meta, groupKey, slackFired)
|
|
slackFired = slackFired || fired
|
|
}
|
|
}
|
|
|
|
// notifyOne is the per-user ingress behind Notify/NotifyOrg. The feed row is
|
|
// the delivery record for both the in-app and email channels: email-channel
|
|
// rows queue as pending with a due time from the user's digest cadence, and
|
|
// the flush loop bundles them later (see email.go). Returns whether the Slack
|
|
// channel fired, so org fan-outs post to the shared workspace only once.
|
|
func (s *service) notifyOne(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string, suppressSlack bool) bool {
|
|
if userID == uuid.Nil {
|
|
return false
|
|
}
|
|
prefs, err := s.repo.GetPreferences(ctx, userID)
|
|
if err != nil || prefs == nil {
|
|
return false
|
|
}
|
|
cat := prefs.CategoryPref(category)
|
|
if !cat.Enabled {
|
|
return false // category off — no channel fires
|
|
}
|
|
|
|
emailOn := cat.Channels.Email && s.email != nil && s.users != nil
|
|
if cat.Channels.InApp || emailOn {
|
|
n := &models.Notification{
|
|
UserID: userID,
|
|
OrganizationID: orgID,
|
|
Category: category,
|
|
Title: title,
|
|
Body: body,
|
|
Link: link,
|
|
Metadata: meta,
|
|
GroupKey: groupKey,
|
|
// In-app off but email on: keep the row as the email record
|
|
// without ringing the bell.
|
|
PreRead: !cat.Channels.InApp,
|
|
}
|
|
if emailOn {
|
|
due := time.Now().Add(emailHold(prefs.EmailDigestMinutes, category))
|
|
n.EmailState = "pending"
|
|
n.EmailDueAt = &due
|
|
}
|
|
created, cerr := s.repo.Create(ctx, n)
|
|
if cerr == nil && created != nil && cat.Channels.InApp && s.publisher != nil {
|
|
s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link)
|
|
}
|
|
}
|
|
|
|
// Slack: post to the org's connected workspace (detached, best-effort).
|
|
slackFired := false
|
|
if cat.Channels.Slack && !suppressSlack && s.slack != nil && orgID != nil {
|
|
slackFired = true
|
|
org := *orgID
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
defer cancel()
|
|
_ = s.slack.NotifySlack(ctx, org, title, body)
|
|
}()
|
|
}
|
|
|
|
// Push: immediate on a quiet window, digest-batched inside one (detached).
|
|
if cat.Channels.Push && s.push != nil && s.deviceTokens != nil && s.pushRedis != nil {
|
|
go s.deliverPush(userID, category, title, body, link)
|
|
}
|
|
return slackFired
|
|
}
|