package service import ( "context" "encoding/json" "net/http" webpush "github.com/SherClockHolmes/webpush-go" "github.com/prabhupadasays/spsays-bff/internal/repository" "go.uber.org/zap" ) // PushPayload is the JSON the service worker receives in its `push` event. type PushPayload struct { Title string `json:"title"` Body string `json:"body"` URL string `json:"url,omitempty"` Icon string `json:"icon,omitempty"` Tag string `json:"tag,omitempty"` } // PushSender delivers Web Push messages via VAPID. Construct with // NewPushSender; it returns nil when VAPID isn't configured, and every method // is nil-safe so callers needn't branch. type PushSender struct { repo *repository.PushRepo pub string priv string subject string logger *zap.Logger } // NewPushSender returns nil if the VAPID keys are absent (push disabled). func NewPushSender(repo *repository.PushRepo, publicKey, privateKey, subject string, logger *zap.Logger) *PushSender { if publicKey == "" || privateKey == "" || repo == nil { return nil } return &PushSender{repo: repo, pub: publicKey, priv: privateKey, subject: subject, logger: logger} } // PublicKey is the VAPID application server key the browser subscribes with. func (p *PushSender) PublicKey() string { if p == nil { return "" } return p.pub } // Enabled reports whether push delivery is configured. func (p *PushSender) Enabled() bool { return p != nil } // SendToUser pushes a payload to every subscription the user has, pruning any // endpoint the push service reports as gone (404/410). Best-effort: failures // are logged, never returned. func (p *PushSender) SendToUser(ctx context.Context, userID string, payload PushPayload) { if p == nil { return } subs, err := p.repo.ListByUser(ctx, userID) if err != nil { p.logger.Warn("push: list subscriptions failed", zap.String("user", userID), zap.Error(err)) return } if len(subs) == 0 { return } body, err := json.Marshal(payload) if err != nil { return } for _, s := range subs { sub := &webpush.Subscription{ Endpoint: s.Endpoint, Keys: webpush.Keys{P256dh: s.P256dh, Auth: s.Auth}, } resp, err := webpush.SendNotification(body, sub, &webpush.Options{ Subscriber: p.subject, VAPIDPublicKey: p.pub, VAPIDPrivateKey: p.priv, TTL: 60, Urgency: webpush.UrgencyNormal, }) if err != nil { p.logger.Warn("push: send failed", zap.String("user", userID), zap.Error(err)) continue } status := resp.StatusCode _ = resp.Body.Close() // Subscription expired/gone — drop it so we stop trying. if status == http.StatusNotFound || status == http.StatusGone { if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil { p.logger.Warn("push: prune failed", zap.Error(derr)) } } else if status >= 400 { p.logger.Warn("push: non-2xx", zap.Int("status", status), zap.String("user", userID)) } } }