package service import ( "context" "encoding/json" "errors" "fmt" "net/url" "strings" "time" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/realtime" "go.uber.org/zap" ) var ( ErrBroadcastTitle = errors.New("a title is required") ErrBroadcastBody = errors.New("a description is required") ErrBroadcastCTA = errors.New("that CTA link is not a valid URL") ErrBroadcastBanner = errors.New("that image was not uploaded for a broadcast") ) const ( maxBroadcastTitleRunes = 200 maxBroadcastBodyRunes = 4000 // bannerPrefix namespaces broadcast banners in the PUBLIC bucket. An // announcement is not private, and a signed URL would expire while the // modal sat open on someone's screen. bannerPrefix = "broadcasts/" ) // BroadcastChannel is the PUBLIC realtime channel every client watches for // announcements. // // Public is deliberate and safe because the event carries no content — just // "something was published". Each client then fetches /broadcasts/pending, // which is authenticated and per-user, so someone who has already dismissed it // gets nothing. That also keeps publishing to one Soketi call instead of one // per user. const BroadcastChannel = "broadcasts" type BroadcastService struct { repo *repository.BroadcastRepo push *PushSender pushes *repository.PushRepo prefs *repository.NotificationPrefsRepo rt *realtime.SoketiClient r2 *repository.R2 logger *zap.Logger } func NewBroadcastService( repo *repository.BroadcastRepo, push *PushSender, pushes *repository.PushRepo, prefs *repository.NotificationPrefsRepo, rt *realtime.SoketiClient, r2 *repository.R2, logger *zap.Logger, ) *BroadcastService { return &BroadcastService{ repo: repo, push: push, pushes: pushes, prefs: prefs, rt: rt, r2: r2, logger: logger, } } // CreateParams is an admin-authored announcement. type CreateParams struct { Title string Body string BannerKey *string CTALabel *string CTAURL *string ExpiresAt *time.Time } // Create stores a draft. Nothing is delivered until Publish. func (s *BroadcastService) Create(ctx context.Context, adminID string, p CreateParams) (*model.Broadcast, error) { title := strings.TrimSpace(p.Title) body := strings.TrimSpace(p.Body) if title == "" || len([]rune(title)) > maxBroadcastTitleRunes { return nil, ErrBroadcastTitle } if body == "" || len([]rune(body)) > maxBroadcastBodyRunes { return nil, ErrBroadcastBody } if p.CTAURL != nil && strings.TrimSpace(*p.CTAURL) != "" { clean, ok := safeCTAURL(*p.CTAURL) if !ok { return nil, ErrBroadcastCTA } p.CTAURL = &clean } else { p.CTAURL = nil } if p.BannerKey != nil && !strings.HasPrefix(*p.BannerKey, bannerPrefix) { return nil, ErrBroadcastBanner } // Same parser as the chat composer, so the admin writes *bold* / ~strike~ / // `code` and links are detected — and no HTML is ever stored. clean, entities := ParseEntities(body) encoded, err := json.Marshal(entities) if err != nil { return nil, fmt.Errorf("encode entities: %w", err) } if entities == nil { encoded = []byte(`[]`) } b, key, err := s.repo.Create(ctx, repository.CreateBroadcastParams{ Title: title, Body: clean, Entities: encoded, BannerKey: p.BannerKey, CTALabel: p.CTALabel, CTAURL: p.CTAURL, ExpiresAt: p.ExpiresAt, CreatedBy: adminID, }) if err != nil { return nil, err } s.withBanner(b, key) return b, nil } // Publish makes a broadcast live, then nudges everyone: connected clients over // realtime, absent ones by push. func (s *BroadcastService) Publish(ctx context.Context, id string) (*model.Broadcast, error) { b, key, err := s.repo.Publish(ctx, id) if err != nil { return nil, err } s.withBanner(b, key) // One trigger for the whole user base. Content-free on purpose — clients // re-fetch their own pending state. if s.rt != nil { if err := s.rt.Trigger(ctx, BroadcastChannel, "broadcast_published", map[string]any{}); err != nil { s.logger.Warn("broadcast: realtime nudge failed", zap.Error(err)) } } go s.fanOutPush(context.WithoutCancel(ctx), b) return b, nil } // fanOutPush notifies everyone with a push subscription who hasn't opted out // of announcements. // // Runs detached from the request: a large user base takes far longer than an // admin should wait on a button. func (s *BroadcastService) fanOutPush(ctx context.Context, b *model.Broadcast) { if s.push == nil || !s.push.Enabled() || s.pushes == nil { return } users, err := s.pushes.SubscribedUserIDs(ctx) if err != nil { s.logger.Warn("broadcast: listing subscribers failed", zap.Error(err)) return } sent := 0 for _, uid := range users { if s.prefs != nil { // Reuses the existing "Community" category, described in settings // as rare announcements — which is exactly this. Someone who // opted out of those stays opted out. if off, err := s.prefs.DisabledSet(ctx, uid); err == nil && prefOff(off, NotifDiscipleJoined, repository.ChannelPush) { continue } } s.push.SendToUser(ctx, uid, PushPayload{ Title: b.Title, Body: truncateRunes(b.Body, 160), URL: "/", // One tag per broadcast: a re-publish replaces rather than stacks. Tag: "broadcast:" + b.ID, }) sent++ } s.logger.Info("broadcast: push fan-out complete", zap.String("broadcast_id", b.ID), zap.Int("recipients", sent)) } // Pending returns the announcement waiting for this user, or nil. func (s *BroadcastService) Pending(ctx context.Context, userID string) (*model.Broadcast, error) { b, key, err := s.repo.Pending(ctx, userID) if err != nil || b == nil { return nil, err } s.withBanner(b, key) return b, nil } // MarkSeen records that a user dismissed it — the "at most once" half. func (s *BroadcastService) MarkSeen(ctx context.Context, id, userID string) error { return s.repo.MarkSeen(ctx, id, userID) } func (s *BroadcastService) List(ctx context.Context, limit int) ([]model.Broadcast, error) { if limit <= 0 || limit > 100 { limit = 50 } items, keys, err := s.repo.List(ctx, limit) if err != nil { return nil, err } for i := range items { s.withBanner(&items[i], keys[i]) } return items, nil } func (s *BroadcastService) Delete(ctx context.Context, id string) error { return s.repo.Delete(ctx, id) } // PresignBanner issues an upload URL for a banner image. func (s *BroadcastService) PresignBanner(ctx context.Context, contentType string) (uploadURL, key string, err error) { if s.r2 == nil { return "", "", errors.New("media uploads are not configured") } kind, ok := attachmentKindFor(contentType) if !ok || kind != model.AttachmentImage { return "", "", ErrChatBadMedia } // Returns the KEY: broadcasts.banner_key stores a key, and withBanner // builds the URL from it at read time. return presignImageKey(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType) } func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) { if key == nil || *key == "" || s.r2 == nil { return } url := s.r2.PublicURL(*key) b.BannerURL = &url } // safeCTAURL accepts an in-app path or an absolute http(s) URL. // // Admin-authored, so this is a typo guard more than a threat model — but // `javascript:` and `data:` still have no business in a link the whole user // base is invited to tap. func safeCTAURL(raw string) (string, bool) { raw = strings.TrimSpace(raw) if raw == "" || len(raw) > 2048 { return "", false } if strings.ContainsAny(raw, "\\") || strings.ContainsAny(raw, "\n\r\t") { return "", false } if strings.HasPrefix(raw, "/") { // Protocol-relative "//evil.example" is not an in-app path. if strings.HasPrefix(raw, "//") { return "", false } u, err := url.Parse(raw) if err != nil || u.Scheme != "" || u.Host != "" { return "", false } return raw, true } u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return "", false } return u.String(), true }