Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions cmd/beebuzz-server/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,13 @@ type notificationEventTrackerAdapter struct {
}

// NotificationCreated translates notification-domain facts to event-domain recording.
func (a *notificationEventTrackerAdapter) NotificationCreated(ctx context.Context, userID, topic, source, deliveryMode string, attachmentBytes int64) {
func (a *notificationEventTrackerAdapter) NotificationCreated(ctx context.Context, userID, topic string, source notification.Source, deliveryMode string, attachmentBytes int64) {
var ab *int64
if attachmentBytes > 0 {
ab = &attachmentBytes
}

a.eventSvc.RecordNotificationCreated(ctx, userID, topic, source, deliveryMode, ab)
a.eventSvc.RecordNotificationCreated(ctx, userID, topic, string(source), deliveryMode, ab)
}

// DeviceDelivered records a successful push delivery.
Expand Down Expand Up @@ -272,7 +272,7 @@ func (a *systemNotificationDeliveryAdapter) SendSystemNotification(ctx context.C
TopicName: input.TopicName,
Title: input.Title,
Body: input.Body,
Source: event.SourceInternal,
Source: notification.SourceInternal,
DeliveryMode: notification.DeliveryModeServerTrusted,
}, a.log)
if err != nil {
Expand Down Expand Up @@ -302,7 +302,7 @@ func (a *webhookDispatcherAdapter) Dispatch(ctx context.Context, userID, topicID
Title: title,
Body: body,
Priority: priority,
Source: event.SourceWebhook,
Source: notification.SourceWebhook,
DeliveryMode: notification.DeliveryModeServerTrusted,
}, log)
if err != nil {
Expand Down
4 changes: 3 additions & 1 deletion cmd/beebuzz/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net/http"

"lucor.dev/beebuzz/internal/core"
)

// buildAuthorizedRequest creates an authenticated HTTP request for the BeeBuzz API.
Expand All @@ -16,7 +18,7 @@ func buildAuthorizedRequest(ctx context.Context, method, requestURL, apiToken st
}

req.Header.Set("Authorization", "Bearer "+apiToken)
req.Header.Set("User-Agent", "BeeBuzz-CLI/"+version)
req.Header.Set("User-Agent", core.CLIUserAgentPrefix+"/"+version)
return req, nil
}

Expand Down
6 changes: 6 additions & 0 deletions internal/core/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package core

// CLIUserAgentPrefix is the User-Agent prefix sent by the BeeBuzz CLI client.
// The CLI sets a User-Agent of the form "BeeBuzz-CLI/<version>" and the server
// uses this prefix to classify the source of incoming push requests.
const CLIUserAgentPrefix = "BeeBuzz-CLI"
6 changes: 5 additions & 1 deletion internal/notification/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
}

input.TopicName = topicName
input.Source = "api"
if strings.HasPrefix(r.Header.Get("User-Agent"), core.CLIUserAgentPrefix) {
input.Source = SourceCLI
} else {
input.Source = SourceAPI
}
if input.DeliveryMode == "" {
input.DeliveryMode = DeliveryModeServerTrusted
}
Expand Down
59 changes: 59 additions & 0 deletions internal/notification/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -897,3 +897,62 @@ func TestSendHandler_JSONAllowsMissingBody(t *testing.T) {
t.Fatalf("status: got %d, want %d — body: %s", w.Code, http.StatusOK, w.Body.String())
}
}

// sourceCaptureSender is a test double that records the SendInput it receives.
type sourceCaptureSender struct {
captured SendInput
report *SendReport
err error
}

func (s *sourceCaptureSender) Send(_ context.Context, _, _ string, input SendInput, _ *slog.Logger) (*SendReport, error) {
s.captured = input
return s.report, s.err
}

func (s *sourceCaptureSender) VAPIDPublicKey() string {
return ""
}

func TestSendHandler_SourceCLI(t *testing.T) {
sender := &sourceCaptureSender{report: &SendReport{}}
handler, rawToken, topicName := buildHandlerWithSender(t, sender)

req := httptest.NewRequest(http.MethodPost, "/v1/push/"+topicName, bytes.NewBufferString(`{"title":"Test","body":"Hello"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+rawToken)
req.Header.Set("User-Agent", core.CLIUserAgentPrefix+"/1.0.0")
req = withBearer(req)
req = withTopic(req, topicName)
w := httptest.NewRecorder()

handler.Send(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d — body: %s", w.Code, http.StatusOK, w.Body.String())
}
if sender.captured.Source != "cli" {
t.Fatalf("source: got %q, want %q", sender.captured.Source, "cli")
}
}

func TestSendHandler_SourceAPI(t *testing.T) {
sender := &sourceCaptureSender{report: &SendReport{}}
handler, rawToken, topicName := buildHandlerWithSender(t, sender)

req := httptest.NewRequest(http.MethodPost, "/v1/push/"+topicName, bytes.NewBufferString(`{"title":"Test","body":"Hello"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+rawToken)
req = withBearer(req)
req = withTopic(req, topicName)
w := httptest.NewRecorder()

handler.Send(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d — body: %s", w.Code, http.StatusOK, w.Body.String())
}
if sender.captured.Source != "api" {
t.Fatalf("source: got %q, want %q", sender.captured.Source, "api")
}
}
16 changes: 14 additions & 2 deletions internal/notification/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ const (
MaxNotificationBodyLen = 256
)

// Source identifies the origin of a push send for analytics.
// Values are kept in sync with the labels consumed by the event domain;
// the type lives here so the notification domain does not import event.
type Source string

const (
SourceAPI Source = "api"
SourceCLI Source = "cli"
SourceWebhook Source = "webhook"
SourceInternal Source = "internal"
)

var (
// ErrAttachmentProcessingFailed is returned when an attachment cannot be fetched, encrypted, or stored.
ErrAttachmentProcessingFailed = errors.New("attachment processing failed")
Expand Down Expand Up @@ -73,7 +85,7 @@ type SendInput struct {
Title string
Body string
Priority string
Source string // opaque source tag for analytics (e.g., "api", "webhook")
Source Source // origin of the send for analytics
DeliveryMode string // delivery mode (e.g., "server_trusted", "e2e")
Attachment *AttachmentInput // nil when no attachment
OpaqueBlob []byte // raw E2E-encrypted payload (octet-stream mode)
Expand Down Expand Up @@ -150,7 +162,7 @@ type AttachmentStorer interface {
// Implementations bridge to the event/analytics layer via adapters.
type EventTracker interface {
// NotificationCreated is called once per Send operation after the push loop completes.
NotificationCreated(ctx context.Context, userID, topic, source, deliveryMode string, attachmentBytes int64)
NotificationCreated(ctx context.Context, userID, topic string, source Source, deliveryMode string, attachmentBytes int64)
// DeviceDelivered is called for each successful push delivery.
DeviceDelivered(ctx context.Context, userID, deviceID string)
// DeviceFailed is called for each failed push delivery.
Expand Down