Skip to content
Open
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
16 changes: 16 additions & 0 deletions behavior-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,22 @@
]
}
},
"GetBubbleUps": {
"readonly": true,
"pagination": {
"style": "link",
"maxPageSize": 50
},
"retry": {
"max": 3,
"base_delay_ms": 1000,
"backoff": "exponential",
"retry_on": [
429,
503
]
}
},
"GetCampfire": {
"readonly": true,
"retry": {
Expand Down
11 changes: 9 additions & 2 deletions conformance/runner/typescript/schema-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ describe("validateResponse — ListProjects (array root)", () => {
// =============================================================================

describe("validateResponse — GetMyNotifications (object root, array fields)", () => {
it("validates an empty payload (all arrays absent)", () => {
const result = validateResponse("GetMyNotifications", {});
// bubble_ups_count / scheduled_bubble_ups_count are @required on the output,
// so every valid payload carries them (the arrays remain optional).
const counts = { bubble_ups_count: 0, scheduled_bubble_ups_count: 0 };

it("validates a minimal payload (arrays absent, required counts present)", () => {
const result = validateResponse("GetMyNotifications", { ...counts });
expect(result.ok).toBe(true);
});

it("validates with empty arrays", () => {
const result = validateResponse("GetMyNotifications", {
...counts,
unreads: [],
reads: [],
memories: [],
Expand All @@ -87,6 +92,7 @@ describe("validateResponse — GetMyNotifications (object root, array fields)",

it("collects extras at the root", () => {
const result = validateResponse("GetMyNotifications", {
...counts,
unreads: [],
hypothetical_new_top_level: 42,
});
Expand All @@ -101,6 +107,7 @@ describe("validateResponse — GetMyNotifications (object root, array fields)",
updated_at: "2026-01-02T00:00:00Z",
};
const result = validateResponse("GetMyNotifications", {
...counts,
unreads: [{ ...minimalNotification, future_envelope_field: "BC5 addition" }],
});
expect(result.ok).toBe(true);
Expand Down
13 changes: 13 additions & 0 deletions conformance/tests/live-my-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@
{ "type": "liveSchemaValidate" }
]
},
{
"mode": "live",
"name": "GetBubbleUps returns a paginated bubble-ups list and validates against schema",
"description": "Exercises the dedicated bubble-ups endpoint (current + scheduled, 50/page) and validates each notification item against the schema. Live-dormant: validates statically until credentials are provisioned.",
"operation": "GetBubbleUps",
"method": "GET",
"path": "/my/readings/bubble_ups.json",
"tags": ["live", "read-only"],
"liveAssertions": [
{ "type": "liveCallSucceeds" },
{ "type": "liveSchemaValidate" }
]
},
{
"mode": "live",
"name": "ListProjects returns a list and validates against schema",
Expand Down
133 changes: 126 additions & 7 deletions go/pkg/basecamp/my_notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,26 @@ type NotificationsResult struct {
// integrations should prefer BubbleUps.
BubbleUps []Notification `json:"bubble_ups,omitempty"`
// ScheduledBubbleUps is the BC5-added list of scheduled Bubble Up
// notifications (resurface time in the future).
// notifications (resurface time in the future). Omitted from the response
// entirely when the request sets limit_bubble_ups=true.
ScheduledBubbleUps []Notification `json:"scheduled_bubble_ups,omitempty"`
// BubbleUpsCount is the total number of current bubble-ups, independent of
// the limit_bubble_ups cap on the BubbleUps array.
BubbleUpsCount int32 `json:"bubble_ups_count"`
// ScheduledBubbleUpsCount is the total number of scheduled bubble-ups,
// present even when limit_bubble_ups omits the ScheduledBubbleUps array.
ScheduledBubbleUpsCount int32 `json:"scheduled_bubble_ups_count"`
}

// BubbleUpsResult contains a page-followed list of bubble-up notifications
// with pagination metadata.
type BubbleUpsResult struct {
// BubbleUps is the combined list of current bubble-ups (first, ordered by
// most recently bubbled up) followed by scheduled bubble-ups (ordered by
// scheduled time).
BubbleUps []Notification
// Meta contains pagination metadata (total count, truncation).
Meta ListMeta
}

// MyNotificationsService handles notification operations for the current user.
Expand All @@ -84,9 +102,20 @@ func NewMyNotificationsService(client *AccountClient) *MyNotificationsService {
return &MyNotificationsService{client: client}
}

// MyNotificationsGetOption customizes a MyNotifications Get request.
type MyNotificationsGetOption func(*generated.GetMyNotificationsParams)

// WithLimitBubbleUps caps the bubble_ups array at 2 current bubble-ups and
// omits the scheduled_bubble_ups array from the response (the counts are still
// returned). Use the BubbleUps method to page through all bubble-ups.
func WithLimitBubbleUps() MyNotificationsGetOption {
return func(p *generated.GetMyNotificationsParams) { p.LimitBubbleUps = true }
}

// Get returns notifications for the current user.
// page is optional; pass 0 to use the default (page 1).
func (s *MyNotificationsService) Get(ctx context.Context, page int32) (result *NotificationsResult, err error) {
// page is optional; pass 0 to use the default (page 1). Optional functional
// options (e.g. WithLimitBubbleUps) tune the request.
func (s *MyNotificationsService) Get(ctx context.Context, page int32, opts ...MyNotificationsGetOption) (result *NotificationsResult, err error) {
op := OperationInfo{
Service: "MyNotifications", Operation: "Get",
ResourceType: "notification", IsMutation: false,
Expand All @@ -100,11 +129,12 @@ func (s *MyNotificationsService) Get(ctx context.Context, page int32) (result *N
ctx = s.client.parent.hooks.OnOperationStart(ctx, op)
defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()

var params *generated.GetMyNotificationsParams
params := &generated.GetMyNotificationsParams{}
if page > 0 {
params = &generated.GetMyNotificationsParams{
Page: page,
}
params.Page = page
}
for _, opt := range opts {
opt(params)
}

resp, err := s.client.parent.gen.GetMyNotificationsWithResponse(ctx, s.client.accountID, params)
Expand Down Expand Up @@ -158,3 +188,92 @@ func (s *MyNotificationsService) MarkAsRead(ctx context.Context, readables []str
}
return checkResponse(resp.HTTPResponse, resp.Body)
}

// BubbleUps returns the current user's current and scheduled bubble-ups.
// Current bubble-ups come first (ordered by most recently bubbled up), then
// scheduled bubble-ups (ordered by scheduled time). The list is paginated at
// 50 per page; by default this follows the Link header across all pages.
// Pass a positive page to disable auto-pagination and return only that page.
func (s *MyNotificationsService) BubbleUps(ctx context.Context, page int32) (result *BubbleUpsResult, err error) {
op := OperationInfo{
Service: "MyNotifications", Operation: "BubbleUps",
ResourceType: "notification", IsMutation: false,
}
if gater, ok := s.client.parent.hooks.(GatingHooks); ok {
if ctx, err = gater.OnOperationGate(ctx, op); err != nil {
return
}
}
start := time.Now()
ctx = s.client.parent.hooks.OnOperationStart(ctx, op)
defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()

var params *generated.GetBubbleUpsParams
if page > 0 {
params = &generated.GetBubbleUpsParams{Page: page}
}

resp, err := s.client.parent.gen.GetBubbleUpsWithResponse(ctx, s.client.accountID, params)
if err != nil {
return nil, err
}
if err = checkResponse(resp.HTTPResponse, resp.Body); err != nil {
return nil, err
}

items, err := decodeBubbleUpPage(resp.Body)
if err != nil {
return nil, err
}

totalCount := parseTotalCount(resp.HTTPResponse)

// A positive page disables auto-pagination (single page only).
if page > 0 {
return &BubbleUpsResult{BubbleUps: items, Meta: ListMeta{TotalCount: totalCount}}, nil
}

rawMore, truncated, err := s.client.parent.followPagination(ctx, resp.HTTPResponse, len(items), 0)
if err != nil {
return nil, err
}
for _, raw := range rawMore {
n, decErr := decodeNotificationItem(raw)
if decErr != nil {
return nil, decErr
}
items = append(items, n)
}

return &BubbleUpsResult{BubbleUps: items, Meta: ListMeta{TotalCount: totalCount, Truncated: truncated}}, nil
}

// decodeBubbleUpPage decodes a bubble-ups page (a bare JSON array of
// notifications) into clean Notification values, applying the embedded-people
// id normalization the Notification shape relies on.
func decodeBubbleUpPage(body []byte) ([]Notification, error) {
normalized, normErr := normalizeEmbeddedPeopleJSON(body)
if normErr != nil {
normalized = body // fallback to raw
}
var items []Notification
if err := json.Unmarshal(normalized, &items); err != nil {
return nil, fmt.Errorf("failed to parse bubble-ups: %w", err)
}
return items, nil
}

// decodeNotificationItem decodes a single notification item (a raw JSON object
// from a followed pagination page) into a clean Notification, applying the same
// embedded-people id normalization as the first page.
func decodeNotificationItem(raw json.RawMessage) (Notification, error) {
normalized, normErr := normalizeEmbeddedPeopleJSON(raw)
if normErr != nil {
normalized = raw // fallback to raw
}
var n Notification
if err := json.Unmarshal(normalized, &n); err != nil {
return Notification{}, fmt.Errorf("failed to parse bubble-up notification: %w", err)
}
return n, nil
}
125 changes: 125 additions & 0 deletions go/pkg/basecamp/my_notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,131 @@ func TestMyNotificationsService_Get_WithPage(t *testing.T) {
}
}

// TestMyNotificationsService_Get_LimitBubbleUps verifies that
// WithLimitBubbleUps sends limit_bubble_ups=true and that a response which omits
// the scheduled_bubble_ups key (per the documented cap) still decodes, with the
// counts preserved.
func TestMyNotificationsService_Get_LimitBubbleUps(t *testing.T) {
var capturedQuery string
svc := testMyNotificationsServer(t, func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
// scheduled_bubble_ups key intentionally omitted; counts still present.
w.Write([]byte(`{
"unreads": [], "reads": [], "memories": [],
"bubble_ups": [{"id": 10, "title": "Bubbled"}, {"id": 11, "title": "Bubbled 2"}],
"bubble_ups_count": 5,
"scheduled_bubble_ups_count": 3
}`))
})

result, err := svc.Get(context.Background(), 0, WithLimitBubbleUps())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedQuery != "limit_bubble_ups=true" {
t.Errorf("expected query limit_bubble_ups=true, got %q", capturedQuery)
}
if len(result.BubbleUps) != 2 {
t.Errorf("expected 2 bubble_ups, got %d", len(result.BubbleUps))
}
if result.ScheduledBubbleUps != nil {
t.Errorf("expected scheduled_bubble_ups omitted (nil), got %v", result.ScheduledBubbleUps)
}
if result.BubbleUpsCount != 5 {
t.Errorf("expected bubble_ups_count 5, got %d", result.BubbleUpsCount)
}
if result.ScheduledBubbleUpsCount != 3 {
t.Errorf("expected scheduled_bubble_ups_count 3, got %d", result.ScheduledBubbleUpsCount)
}
}

// TestMyNotificationsService_BubbleUps_MultiPage exercises the dedicated
// bubble-ups endpoint across multiple pages, verifying Link-header following and
// generated pagination metadata (X-Total-Count), not just a single-page decode.
// Current bubble-ups appear first, scheduled bubble-ups follow.
func TestMyNotificationsService_BubbleUps_MultiPage(t *testing.T) {
var serverURL string
var page1Hits, page2Hits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/99999/my/readings/bubble_ups.json" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Total-Count", "3")
if r.URL.Query().Get("page") == "2" {
page2Hits++
w.WriteHeader(200)
// scheduled bubble-up, ordered after the current ones
w.Write([]byte(`[{"id":30,"title":"Scheduled","bubble_up_at":"2026-08-01T00:00:00Z"}]`))
return
}
page1Hits++
// page 1: two current bubble-ups, with a Link header to page 2
w.Header().Set("Link", fmt.Sprintf(`<%s/99999/my/readings/bubble_ups.json?page=2>; rel="next"`, serverURL))
w.WriteHeader(200)
w.Write([]byte(`[{"id":10,"title":"Current A"},{"id":11,"title":"Current B"}]`))
}))
t.Cleanup(server.Close)
serverURL = server.URL

cfg := DefaultConfig()
cfg.BaseURL = server.URL
client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"})
svc := client.ForAccount("99999").MyNotifications()

result, err := svc.BubbleUps(context.Background(), 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if page1Hits != 1 || page2Hits != 1 {
t.Fatalf("expected one hit per page, got page1=%d page2=%d", page1Hits, page2Hits)
}
if len(result.BubbleUps) != 3 {
t.Fatalf("expected 3 bubble_ups across pages, got %d", len(result.BubbleUps))
}
// Ordering: current bubble-ups first, then scheduled.
if result.BubbleUps[0].ID != 10 || result.BubbleUps[1].ID != 11 || result.BubbleUps[2].ID != 30 {
t.Errorf("unexpected ordering: %d, %d, %d", result.BubbleUps[0].ID, result.BubbleUps[1].ID, result.BubbleUps[2].ID)
}
if result.BubbleUps[2].BubbleUpAt == nil {
t.Errorf("expected scheduled item to carry bubble_up_at")
}
if result.Meta.TotalCount != 3 {
t.Errorf("expected TotalCount 3, got %d", result.Meta.TotalCount)
}
}

// TestMyNotificationsService_BubbleUps_SinglePage verifies that a positive page
// disables auto-pagination and returns only that page (no Link-following).
func TestMyNotificationsService_BubbleUps_SinglePage(t *testing.T) {
var hits int
svc := testMyNotificationsServer(t, func(w http.ResponseWriter, r *http.Request) {
hits++
if r.URL.Query().Get("page") != "2" {
t.Errorf("expected page=2, got %q", r.URL.Query().Get("page"))
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Total-Count", "3")
// A Link header is present but must NOT be followed for an explicit page.
w.Header().Set("Link", `</99999/my/readings/bubble_ups.json?page=3>; rel="next"`)
w.WriteHeader(200)
w.Write([]byte(`[{"id":30,"title":"Scheduled"}]`))
})

result, err := svc.BubbleUps(context.Background(), 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hits != 1 {
t.Errorf("expected exactly 1 request for explicit page, got %d", hits)
}
if len(result.BubbleUps) != 1 {
t.Errorf("expected 1 bubble_up for single page, got %d", len(result.BubbleUps))
}
}

func TestMyNotificationsService_Get_SentinelCreatorID(t *testing.T) {
// The BC3 API returns system-generated notifications with creator.id: "basecamp"
// and personable_type: "LocalPerson". The normalize pass walks Person-shaped objects
Expand Down
13 changes: 13 additions & 0 deletions go/pkg/basecamp/url-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,19 @@
}
}
},
{
"pattern": "/{accountId}/my/readings/bubble_ups",
"resource": "MyNotifications",
"operations": {
"GET": "GetBubbleUps"
},
"params": {
"accountId": {
"role": "account",
"type": "string"
}
}
},
{
"pattern": "/{accountId}/my/unreads",
"resource": "MyNotifications",
Expand Down
Loading
Loading