diff --git a/cmd/beebuzz-server/adapter.go b/cmd/beebuzz-server/adapter.go index 40fd10b..4fa3bf2 100644 --- a/cmd/beebuzz-server/adapter.go +++ b/cmd/beebuzz-server/adapter.go @@ -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. @@ -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 { @@ -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 { diff --git a/cmd/beebuzz/http_client.go b/cmd/beebuzz/http_client.go index bd64475..00e143e 100644 --- a/cmd/beebuzz/http_client.go +++ b/cmd/beebuzz/http_client.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + + "lucor.dev/beebuzz/internal/core" ) // buildAuthorizedRequest creates an authenticated HTTP request for the BeeBuzz API. @@ -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 } diff --git a/internal/core/client.go b/internal/core/client.go new file mode 100644 index 0000000..751894b --- /dev/null +++ b/internal/core/client.go @@ -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/" and the server +// uses this prefix to classify the source of incoming push requests. +const CLIUserAgentPrefix = "BeeBuzz-CLI" diff --git a/internal/notification/handler.go b/internal/notification/handler.go index ad7037e..d8310e5 100644 --- a/internal/notification/handler.go +++ b/internal/notification/handler.go @@ -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 } diff --git a/internal/notification/handler_test.go b/internal/notification/handler_test.go index 0300be3..cd0ddd8 100644 --- a/internal/notification/handler_test.go +++ b/internal/notification/handler_test.go @@ -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") + } +} diff --git a/internal/notification/notification.go b/internal/notification/notification.go index 5cf3074..5b4661a 100644 --- a/internal/notification/notification.go +++ b/internal/notification/notification.go @@ -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") @@ -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) @@ -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.