From 2643d8213f3826841ddd7e4f2f654c584f5bcf43 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 21:53:15 -0700 Subject: [PATCH] Absorb additive activity-timeline event fields (bc3 #11629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged doc/api/sections/timeline.md and the bc3 timeline event view (api/timelines/events/_event.json.jbuilder) carry event fields the SDK did not yet type. No new operations — GetProgressReport, GetProjectTimeline, and GetPersonProgress already model the three routes. Extend TimelineEvent: - kind: kept an open, non-exhaustive String (documentation only, no closed enum) — BC3 adds new kinds over time; document common values including the live-only project_access_changed, dock_created, google_document_created. - data: new TimelineEventData sub-struct for schedule_entry_* events. Per the bc3 view (starts_at_date_or_time), starts_at/ends_at are date-or-timestamp (a bare date when all_day is true), so they are NOT plain timestamps: modeled as ISO8601Timestamp mirroring ScheduleEntry, with a Go enhancement pass mapping them to types.FlexibleTime; other SDKs type them as strings. - avatars_sample: StringList (chat-rollup participants). - attachments: heterogeneous per the bc3 view — an upload-kind recording contributes its full Upload shape, every other recording a rich-text attachment/blob partial. These variants share no required field set, so reusing an existing attachment shape is not possible. Modeled as an optional-field superset struct (TimelineAttachment) so one element type decodes either variant (the untagged-polymorphism default; no union needed). width/height get the nullable *types.FlexInt Go enhancement (float-spelled 1024.0, null for non-image blobs), matching RichTextAttachment. Runtime decode proof: every SDK (Go, TS, Ruby, Python, Kotlin, Swift) has a non-empty, per-variant test decoding BOTH attachment variants in one array, plus the all-day date-only data payload and a non-empty avatars_sample. The Go wrapper's TimelineAttachment routes decoding through the generated FlexInt type via a custom UnmarshalJSON (mirroring RichTextAttachment). Add a GetProgressReport entry to the live-my-surface canary (validates statically; live canary dormant) and flip activity-timeline.md to absorbed-in-sdk with smithy_refs. --- conformance/tests/live-my-surface.json | 13 ++ go/pkg/basecamp/timeline.go | 170 +++++++++++++++-- go/pkg/basecamp/timeline_test.go | 179 ++++++++++++++++++ go/pkg/generated/client.gen.go | 145 ++++++++++++-- .../generated/models/TimelineAttachment.kt | 42 ++++ .../sdk/generated/models/TimelineEvent.kt | 5 +- .../sdk/generated/models/TimelineEventData.kt | 18 ++ .../com/basecamp/sdk/TimelineDecodeTest.kt | 122 ++++++++++++ openapi.json | 177 ++++++++++++++++- python/src/basecamp/generated/types.py | 36 ++++ python/tests/services/test_timeline.py | 119 ++++++++++++ ruby/lib/basecamp/generated/metadata.json | 2 +- ruby/lib/basecamp/generated/types.rb | 100 +++++++++- .../services/timeline_service_test.rb | 103 ++++++++++ scripts/enhance-openapi-go-types.sh | 50 +++++ spec/api-gaps/README.md | 2 +- spec/api-gaps/activity-timeline.md | 86 ++++++--- spec/basecamp.smithy | 107 +++++++++++ .../Generated/Models/TimelineAttachment.swift | 29 +++ .../Generated/Models/TimelineEvent.swift | 3 + .../Generated/Models/TimelineEventData.swift | 8 + .../BasecampTests/GeneratedServiceTests.swift | 115 +++++++++++ typescript/src/generated/metadata.ts | 2 +- .../src/generated/openapi-stripped.json | 177 ++++++++++++++++- typescript/src/generated/schema.d.ts | 108 +++++++++++ typescript/tests/services/timeline.test.ts | 111 +++++++++++ 26 files changed, 1970 insertions(+), 59 deletions(-) create mode 100644 kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineAttachment.kt create mode 100644 kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineEventData.kt create mode 100644 kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/TimelineDecodeTest.kt create mode 100644 python/tests/services/test_timeline.py create mode 100644 swift/Sources/Basecamp/Generated/Models/TimelineAttachment.swift create mode 100644 swift/Sources/Basecamp/Generated/Models/TimelineEventData.swift diff --git a/conformance/tests/live-my-surface.json b/conformance/tests/live-my-surface.json index e06ab16fa..1523adc07 100644 --- a/conformance/tests/live-my-surface.json +++ b/conformance/tests/live-my-surface.json @@ -1,4 +1,17 @@ [ + { + "mode": "live", + "name": "GetProgressReport account feed validates against the timeline schema", + "description": "Exercises the account activity feed and validates each timeline event against the schema, including the additive kind/avatars_sample/data/attachments fields. Live-dormant: validates statically until credentials are provisioned.", + "operation": "GetProgressReport", + "method": "GET", + "path": "/reports/progress.json", + "tags": ["live", "read-only"], + "liveAssertions": [ + { "type": "liveCallSucceeds" }, + { "type": "liveSchemaValidate" } + ] + }, { "mode": "live", "name": "ListProjects returns a list and validates against schema", diff --git a/go/pkg/basecamp/timeline.go b/go/pkg/basecamp/timeline.go index e79d39673..35979650f 100644 --- a/go/pkg/basecamp/timeline.go +++ b/go/pkg/basecamp/timeline.go @@ -8,6 +8,7 @@ import ( "time" "github.com/basecamp/basecamp-sdk/go/pkg/generated" + "github.com/basecamp/basecamp-sdk/go/pkg/types" ) // DefaultTimelineLimit is the default number of timeline events to return when no limit is specified. @@ -15,18 +16,86 @@ const DefaultTimelineLimit = 100 // TimelineEvent represents an activity event in the timeline. type TimelineEvent struct { - ID int64 `json:"id"` - CreatedAt time.Time `json:"created_at"` - Kind string `json:"kind"` - ParentRecordingID int64 `json:"parent_recording_id"` - URL string `json:"url"` - AppURL string `json:"app_url"` - Creator *Person `json:"creator,omitempty"` - Action string `json:"action"` - Target string `json:"target"` - Title string `json:"title"` - SummaryExcerpt string `json:"summary_excerpt"` - Bucket *Bucket `json:"bucket,omitempty"` + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + // Kind is an open, non-exhaustive vocabulary (BC3 adds new kinds over time); + // treat unrecognized values as valid. Common values include message_created, + // todo_created, todo_completed, upload_created, schedule_entry_created, + // chat_transcript_rollup, dock_created, and project_access_changed. + Kind string `json:"kind"` + ParentRecordingID int64 `json:"parent_recording_id"` + URL string `json:"url"` + AppURL string `json:"app_url"` + Creator *Person `json:"creator,omitempty"` + Action string `json:"action"` + Target string `json:"target"` + Title string `json:"title"` + SummaryExcerpt string `json:"summary_excerpt"` + // AvatarsSample holds avatar URLs of participants, populated for + // chat_transcript_rollup events and empty otherwise. + AvatarsSample []string `json:"avatars_sample,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + // Data carries schedule-entry timing, present only for schedule_entry_created + // and schedule_entry_rescheduled events. + Data *TimelineEventData `json:"data,omitempty"` + // Attachments holds files attached to the event's recording. It is + // heterogeneous: an upload-kind recording contributes its full Upload shape, + // other recordings contribute rich-text attachment/blob partials. Each + // element is an optional-field superset; only the fields of the variant it + // represents are populated. + Attachments []TimelineAttachment `json:"attachments,omitempty"` +} + +// TimelineEventData carries schedule-entry timing for schedule_entry_* events. +// StartsAt and EndsAt are date-or-timestamp (types.FlexibleTime): a full ISO +// 8601 timestamp for timed entries, or a bare date when AllDay is true. +type TimelineEventData struct { + AllDay bool `json:"all_day"` + StartsAt types.FlexibleTime `json:"starts_at"` + EndsAt types.FlexibleTime `json:"ends_at"` +} + +// TimelineAttachment is a single timeline-event attachment: an optional-field +// superset over two wire variants — a full Upload recording (upload-kind +// recordings) and a rich-text attachment/blob partial (all other recordings). +// Only the fields of the variant an instance represents are populated. +type TimelineAttachment struct { + ID int64 `json:"id,omitempty"` + + // Shared by both variants. + ContentType string `json:"content_type,omitempty"` + ByteSize int64 `json:"byte_size,omitempty"` + Filename string `json:"filename,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + // Width and Height are null for non-image blobs and may be float-spelled + // (1024.0) on the wire; narrowed to *int32 here (nil when absent/null). + Width *int32 `json:"width,omitempty"` + Height *int32 `json:"height,omitempty"` + + // Upload-recording variant. CreatedAt/UpdatedAt/VisibleToClients are + // pointers so an absent field (this instance is the attachment variant) + // stays nil and round-trips as omitted rather than a fabricated zero + // timestamp or a dropped explicit false. + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + RecordingURL string `json:"url,omitempty"` + AppURL string `json:"app_url,omitempty"` + AppDownloadURL string `json:"app_download_url,omitempty"` + VisibleToClients *bool `json:"visible_to_clients,omitempty"` + + // Rich-text attachment/blob variant. Previewable is a pointer for the same + // presence-faithful reason as VisibleToClients. + AttachableSGID string `json:"attachable_sgid,omitempty"` + SGID string `json:"sgid,omitempty"` + StatusURL string `json:"status_url,omitempty"` + Caption string `json:"caption,omitempty"` + Key string `json:"key,omitempty"` + Previewable *bool `json:"previewable,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` } // TimelineListOptions specifies options for listing timeline events. @@ -399,5 +468,82 @@ func timelineEventFromGenerated(ge generated.TimelineEvent) TimelineEvent { } } + if ge.AvatarsSample != nil { + e.AvatarsSample = append([]string(nil), ge.AvatarsSample...) + } + + // data is present only on schedule_entry_* events. Detect a populated + // payload via the timing fields so an absent object stays nil. + if !ge.Data.StartsAt.IsZero() || !ge.Data.EndsAt.IsZero() || ge.Data.AllDay { + e.Data = &TimelineEventData{ + AllDay: ge.Data.AllDay, + StartsAt: ge.Data.StartsAt, + EndsAt: ge.Data.EndsAt, + } + } + + if ge.Attachments != nil { + e.Attachments = make([]TimelineAttachment, 0, len(ge.Attachments)) + for _, ga := range ge.Attachments { + e.Attachments = append(e.Attachments, timelineAttachmentFromGenerated(ga)) + } + } + return e } + +// UnmarshalJSON routes decoding through the generated TimelineAttachment so the +// public struct handles the float-encoded integers (1024.0) and null dimensions +// the BC3 API emits for width/height. Mirrors RichTextAttachment.UnmarshalJSON. +// Because TimelineEvent has no custom UnmarshalJSON, its Attachments elements +// invoke this automatically on direct decode. +func (a *TimelineAttachment) UnmarshalJSON(data []byte) error { + var ga generated.TimelineAttachment + if err := json.Unmarshal(data, &ga); err != nil { + return err + } + *a = timelineAttachmentFromGenerated(ga) + return nil +} + +// timelineAttachmentFromGenerated converts a generated TimelineAttachment (the +// optional-field superset) to the clean public type. Width and Height are +// optional/nullable *types.FlexInt in the generated type; a nil pointer leaves +// the public *int32 nil, and a present value is narrowed to int32. +func timelineAttachmentFromGenerated(ga generated.TimelineAttachment) TimelineAttachment { + a := TimelineAttachment{ + ContentType: ga.ContentType, + ByteSize: ga.ByteSize, + Filename: ga.Filename, + DownloadURL: ga.DownloadUrl, + Type: ga.Type, + Title: ga.Title, + Status: ga.Status, + CreatedAt: ga.CreatedAt, + UpdatedAt: ga.UpdatedAt, + RecordingURL: ga.Url, + AppURL: ga.AppUrl, + AppDownloadURL: ga.AppDownloadUrl, + VisibleToClients: ga.VisibleToClients, + AttachableSGID: ga.AttachableSgid, + SGID: ga.Sgid, + StatusURL: ga.StatusUrl, + Caption: ga.Caption, + Key: ga.Key, + Previewable: ga.Previewable, + PreviewURL: ga.PreviewUrl, + ThumbnailURL: ga.ThumbnailUrl, + } + if ga.Id != nil { + a.ID = *ga.Id + } + if ga.Width != nil { + w := int32(*ga.Width) + a.Width = &w + } + if ga.Height != nil { + h := int32(*ga.Height) + a.Height = &h + } + return a +} diff --git a/go/pkg/basecamp/timeline_test.go b/go/pkg/basecamp/timeline_test.go index b4af519d2..2469eeec3 100644 --- a/go/pkg/basecamp/timeline_test.go +++ b/go/pkg/basecamp/timeline_test.go @@ -82,6 +82,185 @@ func TestTimelineEvent_Unmarshal(t *testing.T) { } } +// TestTimelineEvent_AdditiveFields exercises runtime decode of the additive +// fields: a non-empty avatars_sample, the schedule-entry data payload with a +// date-only (all_day) starts_at/ends_at, and BOTH heterogeneous attachment +// variants — a full Upload recording and a rich-text attachment/blob partial — +// in a single non-empty array. Empty arrays / shape-only tests do not prove +// polymorphic decode, so both variants carry real per-variant fields here. +func TestTimelineEvent_AdditiveFields(t *testing.T) { + data := `[ + { + "id": 1, + "created_at": "2024-03-15T10:30:00Z", + "kind": "chat_transcript_rollup", + "avatars_sample": [ + "https://3.basecampapi.com/1/people/aaa/avatar", + "https://3.basecampapi.com/1/people/bbb/avatar" + ] + }, + { + "id": 2, + "created_at": "2024-03-15T10:31:00Z", + "kind": "schedule_entry_created", + "avatars_sample": [], + "data": { + "all_day": true, + "starts_at": "2025-10-30", + "ends_at": "2025-10-30" + } + }, + { + "id": 3, + "created_at": "2024-03-15T10:32:00Z", + "kind": "upload_created", + "avatars_sample": [], + "attachments": [ + { + "id": 900, + "type": "Upload", + "status": "active", + "visible_to_clients": false, + "title": "Diagram", + "filename": "diagram.png", + "content_type": "image/png", + "byte_size": 20480, + "width": 1024.0, + "height": 768.0, + "url": "https://3.basecampapi.com/1/buckets/2/uploads/900.json", + "app_url": "https://3.basecamp.com/1/buckets/2/uploads/900", + "download_url": "https://3.basecampapi.com/1/buckets/2/uploads/900/download/diagram.png", + "app_download_url": "https://3.basecamp.com/1/buckets/2/uploads/900/download" + } + ] + }, + { + "id": 4, + "created_at": "2024-03-15T10:33:00Z", + "kind": "comment_created", + "avatars_sample": [], + "attachments": [ + { + "id": 500, + "attachable_sgid": "sgid-attachable-500", + "sgid": "sgid-500", + "status_url": "https://3.basecampapi.com/1/attachments/sgid-500/status.json", + "caption": "See attached", + "filename": "notes.pdf", + "content_type": "application/pdf", + "byte_size": 4096, + "key": "blobkey500", + "width": null, + "height": null, + "previewable": true, + "download_url": "https://3.basecampapi.com/1/blobs/blobkey500/download/notes.pdf", + "preview_url": "https://3.basecampapi.com/1/blobs/blobkey500/previews/full", + "thumbnail_url": "https://3.basecampapi.com/1/blobs/blobkey500/previews/card" + } + ] + } + ]` + + var events []TimelineEvent + if err := json.Unmarshal([]byte(data), &events); err != nil { + t.Fatalf("failed to unmarshal timeline events: %v", err) + } + if len(events) != 4 { + t.Fatalf("expected 4 events, got %d", len(events)) + } + + // avatars_sample non-empty + if got := events[0].AvatarsSample; len(got) != 2 || got[0] == "" { + t.Errorf("expected 2 avatar URLs, got %v", got) + } + + // data with date-only all-day timing (FlexibleTime accepts date-only) + ev := events[1] + if ev.Data == nil { + t.Fatal("expected data on schedule_entry_created event") + } + if !ev.Data.AllDay { + t.Error("expected all_day true") + } + wantDate := time.Date(2025, 10, 30, 0, 0, 0, 0, time.UTC) + if !ev.Data.StartsAt.Equal(wantDate) { + t.Errorf("expected StartsAt %v, got %v", wantDate, ev.Data.StartsAt) + } + if !ev.Data.EndsAt.Equal(wantDate) { + t.Errorf("expected EndsAt %v, got %v", wantDate, ev.Data.EndsAt) + } + + // Upload-recording attachment variant + up := events[2].Attachments + if len(up) != 1 { + t.Fatalf("expected 1 upload attachment, got %d", len(up)) + } + if up[0].Type != "Upload" || up[0].Filename != "diagram.png" || up[0].AppDownloadURL == "" { + t.Errorf("upload variant fields not decoded: %+v", up[0]) + } + if up[0].Width == nil || *up[0].Width != 1024 { + t.Errorf("expected float-spelled width 1024, got %v", up[0].Width) + } + if up[0].AttachableSGID != "" { + t.Errorf("upload variant should not carry attachable_sgid, got %q", up[0].AttachableSGID) + } + + // Rich-text attachment/blob variant + att := events[3].Attachments + if len(att) != 1 { + t.Fatalf("expected 1 blob attachment, got %d", len(att)) + } + if att[0].AttachableSGID != "sgid-attachable-500" || att[0].Caption != "See attached" || att[0].Key != "blobkey500" { + t.Errorf("attachment variant fields not decoded: %+v", att[0]) + } + if att[0].Previewable == nil || !*att[0].Previewable || att[0].ThumbnailURL == "" { + t.Errorf("attachment variant preview fields not decoded: %+v", att[0]) + } + if att[0].Width != nil || att[0].Height != nil { + t.Errorf("expected nil width/height for non-image blob, got w=%v h=%v", att[0].Width, att[0].Height) + } + // Presence-faithful: the attachment variant carries no upload timestamps or + // visibility, so those pointers stay nil (and re-marshal omits them). + if att[0].CreatedAt != nil || att[0].VisibleToClients != nil { + t.Errorf("expected nil upload-variant fields on attachment, got created_at=%v visible=%v", att[0].CreatedAt, att[0].VisibleToClients) + } +} + +// TestTimelineAttachment_PresenceFaithfulRoundTrip verifies that re-marshaling a +// decoded attachment-variant superset omits the absent upload-variant fields +// (no fabricated zero timestamp, no dropped explicit false) — the reason the +// optional timestamps/booleans are pointers. +func TestTimelineAttachment_PresenceFaithfulRoundTrip(t *testing.T) { + // Attachment variant: explicit previewable:false, no created_at/updated_at, + // no visible_to_clients. + src := `{"id":500,"attachable_sgid":"sgid-500","filename":"notes.pdf","previewable":false}` + var a TimelineAttachment + if err := json.Unmarshal([]byte(src), &a); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if a.Previewable == nil || *a.Previewable != false { + t.Fatalf("expected explicit previewable=false preserved, got %v", a.Previewable) + } + out, err := json.Marshal(a) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var round map[string]any + if err := json.Unmarshal(out, &round); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + // Absent upload-variant fields must NOT be fabricated on re-marshal. + for _, k := range []string{"created_at", "updated_at", "visible_to_clients"} { + if _, present := round[k]; present { + t.Errorf("re-marshal fabricated absent field %q: %s", k, out) + } + } + // Explicit false must survive the round trip (not dropped by omitempty). + if v, ok := round["previewable"]; !ok || v != false { + t.Errorf("re-marshal dropped explicit previewable=false: %s", out) + } +} + // wrappedPaginationHandler serves wrapped {person, events} responses with Link headers. type wrappedPaginationHandler struct { pageSize int diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index a9864f398..8ccc25a43 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -2091,20 +2091,141 @@ type Template struct { Url string `json:"url,omitempty"` } +// TimelineAttachment A single timeline-event attachment. This is an optional-field superset over +// two wire variants — a full Upload recording (upload-kind recordings) and a +// rich-text attachment/blob partial (all other recordings) — so one element +// type decodes either. Every field is optional; a given instance populates +// only the fields of the variant it represents. Unknown fields are ignored by +// every SDK decoder, so the superset need not enumerate every Upload field. +type TimelineAttachment struct { + // AppDownloadUrl Web download URL (upload-recording variant). + AppDownloadUrl string `json:"app_download_url,omitempty"` + + // AppUrl Web URL of the upload recording. + AppUrl string `json:"app_url,omitempty"` + + // AttachableSgid Signed global id of the attachable (attachment variant). + AttachableSgid string `json:"attachable_sgid,omitempty"` + + // ByteSize Size of the file in bytes. + ByteSize int64 `json:"byte_size,omitempty"` + + // Caption Caption text, if any (attachment variant). + Caption string `json:"caption,omitempty"` + + // ContentType MIME type of the file. + ContentType string `json:"content_type,omitempty"` + + // CreatedAt When the upload recording was created. + CreatedAt *time.Time `json:"created_at,omitempty"` + + // DownloadUrl Authenticated download URL for the file. + DownloadUrl string `json:"download_url,omitempty"` + + // Filename Original filename. + Filename string `json:"filename,omitempty"` + + // Height Pixel height; null for non-image blobs and may be float-spelled (1024.0). + Height *types.FlexInt `json:"height,omitempty"` + + // Id Attachment or upload-recording id. + Id *int64 `json:"id,omitempty"` + + // Key Storage key of the underlying blob (attachment variant). + Key string `json:"key,omitempty"` + + // PreviewUrl Full-size preview URL (attachment variant). + PreviewUrl string `json:"preview_url,omitempty"` + + // Previewable Whether the blob can be previewed (attachment variant). + Previewable *bool `json:"previewable,omitempty"` + + // Sgid Signed global id of the attachment (attachment variant). + Sgid string `json:"sgid,omitempty"` + + // Status Publication status of the upload recording (e.g. "active"). + Status string `json:"status,omitempty"` + + // StatusUrl URL to poll attachment processing status (attachment variant). + StatusUrl string `json:"status_url,omitempty"` + + // ThumbnailUrl Thumbnail preview URL (attachment variant). + ThumbnailUrl string `json:"thumbnail_url,omitempty"` + + // Title Title of the upload recording. + Title string `json:"title,omitempty"` + + // Type Recording type, e.g. "Upload" (upload-recording variant). + Type string `json:"type,omitempty"` + + // UpdatedAt When the upload recording was last updated. + UpdatedAt *time.Time `json:"updated_at,omitempty"` + + // Url API URL of the upload recording. + Url string `json:"url,omitempty"` + + // VisibleToClients Whether the upload recording is visible to clients. + VisibleToClients *bool `json:"visible_to_clients,omitempty"` + + // Width Pixel width; null for non-image blobs and may be float-spelled (1024.0). + Width *types.FlexInt `json:"width,omitempty"` +} + // TimelineEvent defines model for TimelineEvent. type TimelineEvent struct { - Action string `json:"action,omitempty"` - AppUrl string `json:"app_url,omitempty"` - Bucket TodoBucket `json:"bucket,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - Creator Person `json:"creator,omitempty"` - Id *int64 `json:"id,omitempty"` - Kind string `json:"kind,omitempty"` - ParentRecordingId *int64 `json:"parent_recording_id,omitempty"` - SummaryExcerpt string `json:"summary_excerpt,omitempty"` - Target string `json:"target,omitempty"` - Title string `json:"title,omitempty"` - Url string `json:"url,omitempty"` + Action string `json:"action,omitempty"` + AppUrl string `json:"app_url,omitempty"` + + // Attachments Files attached to the event's recording, when it has any. Heterogeneous: + // an upload-kind recording contributes its full Upload shape, while other + // recordings contribute rich-text attachment/blob partials. Modeled as an + // optional-field superset so a single element type decodes either variant; + // consumers should treat the per-variant fields as present-or-absent. + Attachments []TimelineAttachment `json:"attachments,omitempty"` + + // AvatarsSample Avatar URLs of participants — populated for chat_transcript_rollup events + // (the people summarized in the rollup); an empty array otherwise. + AvatarsSample []string `json:"avatars_sample,omitempty"` + Bucket TodoBucket `json:"bucket,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + Creator Person `json:"creator,omitempty"` + + // Data Schedule-entry timing carried on schedule_entry_* timeline events. starts_at + // and ends_at are date-or-timestamp: a full ISO 8601 timestamp for timed + // entries, or a bare date (YYYY-MM-DD) when all_day is true. Modeled as + // ISO8601Timestamp (mirroring ScheduleEntry), with the Go enhancement pass + // mapping them to types.FlexibleTime so date-only values decode; the other + // SDKs type them as plain strings. + Data TimelineEventData `json:"data,omitempty"` + Id *int64 `json:"id,omitempty"` + + // Kind What kind of activity the event records. Open, non-exhaustive vocabulary — + // BC3 documents "common values include" and adds new kinds over time, so + // treat unrecognized values as valid. Common values include message_created, + // comment_created, todo_created, todo_completed, upload_created, + // document_created, google_document_created, schedule_entry_created, + // schedule_entry_rescheduled, question_created, question_answer_created, + // chat_transcript_rollup, kanban_card_created, kanban_card_completed, + // inbox_forward_created, client_correspondence_created, dock_created, and + // project_access_changed. + Kind string `json:"kind,omitempty"` + ParentRecordingId *int64 `json:"parent_recording_id,omitempty"` + SummaryExcerpt string `json:"summary_excerpt,omitempty"` + Target string `json:"target,omitempty"` + Title string `json:"title,omitempty"` + Url string `json:"url,omitempty"` +} + +// TimelineEventData Schedule-entry timing carried on schedule_entry_* timeline events. starts_at +// and ends_at are date-or-timestamp: a full ISO 8601 timestamp for timed +// entries, or a bare date (YYYY-MM-DD) when all_day is true. Modeled as +// ISO8601Timestamp (mirroring ScheduleEntry), with the Go enhancement pass +// mapping them to types.FlexibleTime so date-only values decode; the other +// SDKs type them as plain strings. +type TimelineEventData struct { + AllDay bool `json:"all_day,omitempty"` + EndsAt types.FlexibleTime `json:"ends_at,omitempty"` + StartsAt types.FlexibleTime `json:"starts_at,omitempty"` } // TimesheetEntry defines model for TimesheetEntry. diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineAttachment.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineAttachment.kt new file mode 100644 index 000000000..3c3222062 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineAttachment.kt @@ -0,0 +1,42 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import com.basecamp.sdk.serialization.FlexibleIntSerializer + +/** + * TimelineAttachment entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class TimelineAttachment( + val id: Long = 0L, + @SerialName("content_type") val contentType: String? = null, + @SerialName("byte_size") val byteSize: Long = 0L, + val filename: String? = null, + @SerialName("download_url") val downloadUrl: String? = null, + @Serializable(with = FlexibleIntSerializer::class) + val width: Int? = null, + @Serializable(with = FlexibleIntSerializer::class) + val height: Int? = null, + val type: String? = null, + val title: String? = null, + val status: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + val url: String? = null, + @SerialName("app_url") val appUrl: String? = null, + @SerialName("app_download_url") val appDownloadUrl: String? = null, + @SerialName("visible_to_clients") val visibleToClients: Boolean = false, + @SerialName("attachable_sgid") val attachableSgid: String? = null, + val sgid: String? = null, + @SerialName("status_url") val statusUrl: String? = null, + val caption: String? = null, + val key: String? = null, + val previewable: Boolean = false, + @SerialName("preview_url") val previewUrl: String? = null, + @SerialName("thumbnail_url") val thumbnailUrl: String? = null +) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineEvent.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineEvent.kt index 9a72dfa2f..fee3eb8dd 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineEvent.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineEvent.kt @@ -23,5 +23,8 @@ data class TimelineEvent( val target: String? = null, val title: String? = null, @SerialName("summary_excerpt") val summaryExcerpt: String? = null, - val bucket: TodoBucket? = null + @SerialName("avatars_sample") val avatarsSample: List = emptyList(), + val bucket: TodoBucket? = null, + val data: TimelineEventData? = null, + val attachments: List = emptyList() ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineEventData.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineEventData.kt new file mode 100644 index 000000000..69832d0c5 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TimelineEventData.kt @@ -0,0 +1,18 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * TimelineEventData entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class TimelineEventData( + @SerialName("all_day") val allDay: Boolean = false, + @SerialName("starts_at") val startsAt: String? = null, + @SerialName("ends_at") val endsAt: String? = null +) diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/TimelineDecodeTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/TimelineDecodeTest.kt new file mode 100644 index 000000000..0ccdbfb51 --- /dev/null +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/TimelineDecodeTest.kt @@ -0,0 +1,122 @@ +package com.basecamp.sdk + +import com.basecamp.sdk.generated.models.TimelineEvent +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TimelineDecodeTest { + + private val json = Json { ignoreUnknownKeys = true } + + private val fixtureJson = """ + [ + { + "id": 1, + "created_at": "2024-03-15T10:30:00Z", + "kind": "chat_transcript_rollup", + "avatars_sample": [ + "https://3.basecampapi.com/1/people/aaa/avatar", + "https://3.basecampapi.com/1/people/bbb/avatar" + ] + }, + { + "id": 2, + "created_at": "2024-03-15T10:31:00Z", + "kind": "schedule_entry_created", + "avatars_sample": [], + "data": { + "all_day": true, + "starts_at": "2025-10-30", + "ends_at": "2025-10-30" + } + }, + { + "id": 3, + "created_at": "2024-03-15T10:32:00Z", + "kind": "upload_created", + "avatars_sample": [], + "attachments": [ + { + "id": 900, + "type": "Upload", + "status": "active", + "visible_to_clients": false, + "title": "Diagram", + "filename": "diagram.png", + "content_type": "image/png", + "byte_size": 20480, + "width": 1024.0, + "height": 768.0, + "url": "https://3.basecampapi.com/1/buckets/2/uploads/900.json", + "app_url": "https://3.basecamp.com/1/buckets/2/uploads/900", + "download_url": "https://3.basecampapi.com/1/buckets/2/uploads/900/download/diagram.png", + "app_download_url": "https://3.basecamp.com/1/buckets/2/uploads/900/download" + } + ] + }, + { + "id": 4, + "created_at": "2024-03-15T10:33:00Z", + "kind": "comment_created", + "avatars_sample": [], + "attachments": [ + { + "id": 500, + "attachable_sgid": "sgid-attachable-500", + "sgid": "sgid-500", + "status_url": "https://3.basecampapi.com/1/attachments/sgid-500/status.json", + "caption": "See attached", + "filename": "notes.pdf", + "content_type": "application/pdf", + "byte_size": 4096, + "key": "blobkey500", + "width": null, + "height": null, + "previewable": true, + "download_url": "https://3.basecampapi.com/1/blobs/blobkey500/download/notes.pdf", + "preview_url": "https://3.basecampapi.com/1/blobs/blobkey500/previews/full", + "thumbnail_url": "https://3.basecampapi.com/1/blobs/blobkey500/previews/card" + } + ] + } + ] + """.trimIndent() + + @Test + fun decodesAdditiveTimelineFields() { + val events = json.decodeFromString>(fixtureJson) + + assertEquals(4, events.size) + + // Event 0: non-empty avatars_sample. + assertEquals(2, events[0].avatarsSample.size) + + // Event 1: schedule-entry timing payload, all-day date-only bounds. + val data = events[1].data + assertNotNull(data) + assertEquals(true, data.allDay) + assertEquals("2025-10-30", data.startsAt) + assertEquals("2025-10-30", data.endsAt) + + // Event 2: full Upload recording attachment; FlexibleIntSerializer decodes 1024.0 -> 1024. + val upload = events[2].attachments + assertEquals(1, upload.size) + assertEquals("Upload", upload[0].type) + assertEquals("diagram.png", upload[0].filename) + assertEquals("https://3.basecamp.com/1/buckets/2/uploads/900/download", upload[0].appDownloadUrl) + assertEquals(1024, upload[0].width) + + // Event 3: rich-text attachment/blob partial variant. + val blob = events[3].attachments + assertEquals(1, blob.size) + assertEquals("sgid-attachable-500", blob[0].attachableSgid) + assertEquals("See attached", blob[0].caption) + assertEquals("blobkey500", blob[0].key) + assertTrue(blob[0].previewable) + assertNull(blob[0].width) + } +} diff --git a/openapi.json b/openapi.json index 6e6db0db9..5e4e71847 100644 --- a/openapi.json +++ b/openapi.json @@ -27285,6 +27285,138 @@ "updated_at" ] }, + "TimelineAttachment": { + "type": "object", + "description": "A single timeline-event attachment. This is an optional-field superset over\ntwo wire variants — a full Upload recording (upload-kind recordings) and a\nrich-text attachment/blob partial (all other recordings) — so one element\ntype decodes either. Every field is optional; a given instance populates\nonly the fields of the variant it represents. Unknown fields are ignored by\nevery SDK decoder, so the superset need not enumerate every Upload field.", + "properties": { + "id": { + "type": "integer", + "description": "Attachment or upload-recording id.", + "format": "int64", + "x-go-type-skip-optional-pointer": false + }, + "content_type": { + "type": "string", + "description": "MIME type of the file." + }, + "byte_size": { + "type": "integer", + "description": "Size of the file in bytes.", + "format": "int64" + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "download_url": { + "type": "string", + "description": "Authenticated download URL for the file.", + "x-basecamp-auth-routable-url": {} + }, + "width": { + "type": "integer", + "description": "Pixel width; null for non-image blobs and may be float-spelled (1024.0).", + "format": "int32", + "nullable": true, + "x-go-type": "types.FlexInt", + "x-go-type-import": { + "path": "github.com/basecamp/basecamp-sdk/go/pkg/types" + }, + "x-go-type-skip-optional-pointer": false + }, + "height": { + "type": "integer", + "description": "Pixel height; null for non-image blobs and may be float-spelled (1024.0).", + "format": "int32", + "nullable": true, + "x-go-type": "types.FlexInt", + "x-go-type-import": { + "path": "github.com/basecamp/basecamp-sdk/go/pkg/types" + }, + "x-go-type-skip-optional-pointer": false + }, + "type": { + "type": "string", + "description": "Recording type, e.g. \"Upload\" (upload-recording variant)." + }, + "title": { + "type": "string", + "description": "Title of the upload recording." + }, + "status": { + "type": "string", + "description": "Publication status of the upload recording (e.g. \"active\")." + }, + "created_at": { + "type": "string", + "description": "When the upload recording was created.", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + }, + "x-go-type-skip-optional-pointer": false + }, + "updated_at": { + "type": "string", + "description": "When the upload recording was last updated.", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + }, + "x-go-type-skip-optional-pointer": false + }, + "url": { + "type": "string", + "description": "API URL of the upload recording." + }, + "app_url": { + "type": "string", + "description": "Web URL of the upload recording." + }, + "app_download_url": { + "type": "string", + "description": "Web download URL (upload-recording variant)." + }, + "visible_to_clients": { + "type": "boolean", + "description": "Whether the upload recording is visible to clients.", + "x-go-type-skip-optional-pointer": false + }, + "attachable_sgid": { + "type": "string", + "description": "Signed global id of the attachable (attachment variant)." + }, + "sgid": { + "type": "string", + "description": "Signed global id of the attachment (attachment variant)." + }, + "status_url": { + "type": "string", + "description": "URL to poll attachment processing status (attachment variant)." + }, + "caption": { + "type": "string", + "description": "Caption text, if any (attachment variant)." + }, + "key": { + "type": "string", + "description": "Storage key of the underlying blob (attachment variant)." + }, + "previewable": { + "type": "boolean", + "description": "Whether the blob can be previewed (attachment variant).", + "x-go-type-skip-optional-pointer": false + }, + "preview_url": { + "type": "string", + "description": "Full-size preview URL (attachment variant)." + }, + "thumbnail_url": { + "type": "string", + "description": "Thumbnail preview URL (attachment variant)." + } + } + }, "TimelineEvent": { "type": "object", "properties": { @@ -27302,7 +27434,8 @@ "x-go-type-skip-optional-pointer": true }, "kind": { - "type": "string" + "type": "string", + "description": "What kind of activity the event records. Open, non-exhaustive vocabulary —\nBC3 documents \"common values include\" and adds new kinds over time, so\ntreat unrecognized values as valid. Common values include message_created,\ncomment_created, todo_created, todo_completed, upload_created,\ndocument_created, google_document_created, schedule_entry_created,\nschedule_entry_rescheduled, question_created, question_answer_created,\nchat_transcript_rollup, kanban_card_created, kanban_card_completed,\ninbox_forward_created, client_correspondence_created, dock_created, and\nproject_access_changed." }, "parent_recording_id": { "type": "integer", @@ -27330,8 +27463,50 @@ "summary_excerpt": { "type": "string" }, + "avatars_sample": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Avatar URLs of participants — populated for chat_transcript_rollup events\n(the people summarized in the rollup); an empty array otherwise." + }, "bucket": { "$ref": "#/components/schemas/TodoBucket" + }, + "data": { + "$ref": "#/components/schemas/TimelineEventData" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimelineAttachment" + }, + "description": "Files attached to the event's recording, when it has any. Heterogeneous:\nan upload-kind recording contributes its full Upload shape, while other\nrecordings contribute rich-text attachment/blob partials. Modeled as an\noptional-field superset so a single element type decodes either variant;\nconsumers should treat the per-variant fields as present-or-absent." + } + } + }, + "TimelineEventData": { + "type": "object", + "description": "Schedule-entry timing carried on schedule_entry_* timeline events. starts_at\nand ends_at are date-or-timestamp: a full ISO 8601 timestamp for timed\nentries, or a bare date (YYYY-MM-DD) when all_day is true. Modeled as\nISO8601Timestamp (mirroring ScheduleEntry), with the Go enhancement pass\nmapping them to types.FlexibleTime so date-only values decode; the other\nSDKs type them as plain strings.", + "properties": { + "all_day": { + "type": "boolean" + }, + "starts_at": { + "type": "string", + "x-go-type": "types.FlexibleTime", + "x-go-type-import": { + "path": "github.com/basecamp/basecamp-sdk/go/pkg/types" + }, + "x-go-type-skip-optional-pointer": true + }, + "ends_at": { + "type": "string", + "x-go-type": "types.FlexibleTime", + "x-go-type-import": { + "path": "github.com/basecamp/basecamp-sdk/go/pkg/types" + }, + "x-go-type-skip-optional-pointer": true } } }, diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index 1bc59baf5..adbb4bdad 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -1371,12 +1371,42 @@ class Template(TypedDict): url: NotRequired[str] +class TimelineAttachment(TypedDict): + app_download_url: NotRequired[str] + app_url: NotRequired[str] + attachable_sgid: NotRequired[str] + byte_size: NotRequired[int] + caption: NotRequired[str] + content_type: NotRequired[str] + created_at: NotRequired[str] + download_url: NotRequired[str] + filename: NotRequired[str] + height: NotRequired[Optional[int | float]] + id: NotRequired[int] + key: NotRequired[str] + preview_url: NotRequired[str] + previewable: NotRequired[bool] + sgid: NotRequired[str] + status: NotRequired[str] + status_url: NotRequired[str] + thumbnail_url: NotRequired[str] + title: NotRequired[str] + type: NotRequired[str] + updated_at: NotRequired[str] + url: NotRequired[str] + visible_to_clients: NotRequired[bool] + width: NotRequired[Optional[int | float]] + + class TimelineEvent(TypedDict): action: NotRequired[str] app_url: NotRequired[str] + attachments: NotRequired[list[TimelineAttachment]] + avatars_sample: NotRequired[list[str]] bucket: NotRequired[TodoBucket] created_at: NotRequired[str] creator: NotRequired[Person] + data: NotRequired[TimelineEventData] id: NotRequired[int] kind: NotRequired[str] parent_recording_id: NotRequired[int] @@ -1386,6 +1416,12 @@ class TimelineEvent(TypedDict): url: NotRequired[str] +class TimelineEventData(TypedDict): + all_day: NotRequired[bool] + ends_at: NotRequired[str] + starts_at: NotRequired[str] + + class TimesheetEntry(TypedDict): app_url: str bookmark_url: NotRequired[str] diff --git a/python/tests/services/test_timeline.py b/python/tests/services/test_timeline.py new file mode 100644 index 000000000..763f76149 --- /dev/null +++ b/python/tests/services/test_timeline.py @@ -0,0 +1,119 @@ +"""Tests for generated timeline service routes.""" + +from __future__ import annotations + +import httpx +import respx + +from basecamp import Client + + +def _timeline_events() -> list[dict]: + return [ + { + "id": 1, + "created_at": "2024-03-15T10:30:00Z", + "kind": "chat_transcript_rollup", + "avatars_sample": [ + "https://3.basecampapi.com/1/people/aaa/avatar", + "https://3.basecampapi.com/1/people/bbb/avatar", + ], + }, + { + "id": 2, + "created_at": "2024-03-15T10:31:00Z", + "kind": "schedule_entry_created", + "avatars_sample": [], + "data": { + "all_day": True, + "starts_at": "2025-10-30", + "ends_at": "2025-10-30", + }, + }, + { + "id": 3, + "created_at": "2024-03-15T10:32:00Z", + "kind": "upload_created", + "avatars_sample": [], + "attachments": [ + { + "id": 900, + "type": "Upload", + "status": "active", + "visible_to_clients": False, + "title": "Diagram", + "filename": "diagram.png", + "content_type": "image/png", + "byte_size": 20480, + "width": 1024.0, + "height": 768.0, + "url": "https://3.basecampapi.com/1/buckets/2/uploads/900.json", + "app_url": "https://3.basecamp.com/1/buckets/2/uploads/900", + "download_url": "https://3.basecampapi.com/1/buckets/2/uploads/900/download/diagram.png", + "app_download_url": "https://3.basecamp.com/1/buckets/2/uploads/900/download", + } + ], + }, + { + "id": 4, + "created_at": "2024-03-15T10:33:00Z", + "kind": "comment_created", + "avatars_sample": [], + "attachments": [ + { + "id": 500, + "attachable_sgid": "sgid-attachable-500", + "sgid": "sgid-500", + "status_url": "https://3.basecampapi.com/1/attachments/sgid-500/status.json", + "caption": "See attached", + "filename": "notes.pdf", + "content_type": "application/pdf", + "byte_size": 4096, + "key": "blobkey500", + "width": None, + "height": None, + "previewable": True, + "download_url": "https://3.basecampapi.com/1/blobs/blobkey500/download/notes.pdf", + "preview_url": "https://3.basecampapi.com/1/blobs/blobkey500/previews/full", + "thumbnail_url": "https://3.basecampapi.com/1/blobs/blobkey500/previews/card", + } + ], + }, + ] + + +class TestSyncTimeline: + @respx.mock + def test_get_project_timeline_decodes_additive_event_fields(self): + route = respx.get("https://3.basecampapi.com/12345/projects/456/timeline.json").mock( + return_value=httpx.Response(200, json=_timeline_events()) + ) + + account = Client(access_token="test-token").for_account("12345") + result = account.timeline.get_project_timeline(project_id=456) + + assert route.called + assert len(result) == 4 + + # avatars_sample: non-empty sample of avatar URLs. + assert len(result[0]["avatars_sample"]) == 2 + + # data: schedule-entry timing with all-day, date-only starts_at/ends_at. + assert result[1]["data"]["all_day"] is True + assert result[1]["data"]["starts_at"] == "2025-10-30" + assert result[1]["data"]["ends_at"] == "2025-10-30" + + # attachments variant 1: full Upload recording with per-variant fields. + upload = result[2]["attachments"][0] + assert upload["type"] == "Upload" + assert upload["filename"] == "diagram.png" + assert upload["app_download_url"] == "https://3.basecamp.com/1/buckets/2/uploads/900/download" + assert upload["width"] == 1024.0 + + # attachments variant 2: rich-text attachment/blob partial with per-variant fields. + blob = result[3]["attachments"][0] + assert blob["attachable_sgid"] == "sgid-attachable-500" + assert blob["caption"] == "See attached" + assert blob["key"] == "blobkey500" + assert blob["previewable"] is True + assert blob["width"] is None diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 888c22ba6..66dc4e7f8 100644 --- a/ruby/lib/basecamp/generated/metadata.json +++ b/ruby/lib/basecamp/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-07-25T06:50:19Z", + "generated": "2026-07-25T07:00:12Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 7b8d36410..882fde9e9 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-07-25T06:50:19Z +# Generated: 2026-07-25T07:00:13Z require "json" require "time" @@ -3418,17 +3418,86 @@ def to_json(*args) end end + # TimelineAttachment + class TimelineAttachment + include TypeHelpers + attr_accessor :app_download_url, :app_url, :attachable_sgid, :byte_size, :caption, :content_type, :created_at, :download_url, :filename, :height, :id, :key, :preview_url, :previewable, :sgid, :status, :status_url, :thumbnail_url, :title, :type, :updated_at, :url, :visible_to_clients, :width + + def initialize(data = {}) + @app_download_url = data["app_download_url"] + @app_url = data["app_url"] + @attachable_sgid = data["attachable_sgid"] + @byte_size = parse_integer(data["byte_size"]) + @caption = data["caption"] + @content_type = data["content_type"] + @created_at = parse_datetime(data["created_at"]) + @download_url = data["download_url"] + @filename = data["filename"] + @height = parse_integer(data["height"]) + @id = parse_integer(data["id"]) + @key = data["key"] + @preview_url = data["preview_url"] + @previewable = parse_boolean(data["previewable"]) + @sgid = data["sgid"] + @status = data["status"] + @status_url = data["status_url"] + @thumbnail_url = data["thumbnail_url"] + @title = data["title"] + @type = data["type"] + @updated_at = parse_datetime(data["updated_at"]) + @url = data["url"] + @visible_to_clients = parse_boolean(data["visible_to_clients"]) + @width = parse_integer(data["width"]) + end + + def to_h + { + "app_download_url" => @app_download_url, + "app_url" => @app_url, + "attachable_sgid" => @attachable_sgid, + "byte_size" => @byte_size, + "caption" => @caption, + "content_type" => @content_type, + "created_at" => @created_at, + "download_url" => @download_url, + "filename" => @filename, + "height" => @height, + "id" => @id, + "key" => @key, + "preview_url" => @preview_url, + "previewable" => @previewable, + "sgid" => @sgid, + "status" => @status, + "status_url" => @status_url, + "thumbnail_url" => @thumbnail_url, + "title" => @title, + "type" => @type, + "updated_at" => @updated_at, + "url" => @url, + "visible_to_clients" => @visible_to_clients, + "width" => @width, + }.compact + end + + def to_json(*args) + to_h.to_json(*args) + end + end + # TimelineEvent class TimelineEvent include TypeHelpers - attr_accessor :action, :app_url, :bucket, :created_at, :creator, :id, :kind, :parent_recording_id, :summary_excerpt, :target, :title, :url + attr_accessor :action, :app_url, :attachments, :avatars_sample, :bucket, :created_at, :creator, :data, :id, :kind, :parent_recording_id, :summary_excerpt, :target, :title, :url def initialize(data = {}) @action = data["action"] @app_url = data["app_url"] + @attachments = parse_array(data["attachments"], "TimelineAttachment") + @avatars_sample = data["avatars_sample"] @bucket = parse_type(data["bucket"], "TodoBucket") @created_at = parse_datetime(data["created_at"]) @creator = parse_type(data["creator"], "Person") + @data = parse_type(data["data"], "TimelineEventData") @id = parse_integer(data["id"]) @kind = data["kind"] @parent_recording_id = parse_integer(data["parent_recording_id"]) @@ -3442,9 +3511,12 @@ def to_h { "action" => @action, "app_url" => @app_url, + "attachments" => @attachments, + "avatars_sample" => @avatars_sample, "bucket" => @bucket, "created_at" => @created_at, "creator" => @creator, + "data" => @data, "id" => @id, "kind" => @kind, "parent_recording_id" => @parent_recording_id, @@ -3460,6 +3532,30 @@ def to_json(*args) end end + # TimelineEventData + class TimelineEventData + include TypeHelpers + attr_accessor :all_day, :ends_at, :starts_at + + def initialize(data = {}) + @all_day = parse_boolean(data["all_day"]) + @ends_at = data["ends_at"] + @starts_at = data["starts_at"] + end + + def to_h + { + "all_day" => @all_day, + "ends_at" => @ends_at, + "starts_at" => @starts_at, + }.compact + end + + def to_json(*args) + to_h.to_json(*args) + end + end + # TimesheetEntry class TimesheetEntry include TypeHelpers diff --git a/ruby/test/basecamp/services/timeline_service_test.rb b/ruby/test/basecamp/services/timeline_service_test.rb index 0b4ea3fc0..4605f39b5 100644 --- a/ruby/test/basecamp/services/timeline_service_test.rb +++ b/ruby/test/basecamp/services/timeline_service_test.rb @@ -36,6 +36,109 @@ def test_get_project_timeline assert_equal "updated", result[0]["action"] end + def test_get_project_timeline_decodes_additive_activity_fields + events = [ + { + "id" => 1, + "created_at" => "2024-03-15T10:30:00Z", + "kind" => "chat_transcript_rollup", + "avatars_sample" => [ + "https://3.basecampapi.com/1/people/aaa/avatar", + "https://3.basecampapi.com/1/people/bbb/avatar" + ] + }, + { + "id" => 2, + "created_at" => "2024-03-15T10:31:00Z", + "kind" => "schedule_entry_created", + "avatars_sample" => [], + "data" => { + "all_day" => true, + "starts_at" => "2025-10-30", + "ends_at" => "2025-10-30" + } + }, + { + "id" => 3, + "created_at" => "2024-03-15T10:32:00Z", + "kind" => "upload_created", + "avatars_sample" => [], + "attachments" => [ + { + "id" => 900, + "type" => "Upload", + "status" => "active", + "visible_to_clients" => false, + "title" => "Diagram", + "filename" => "diagram.png", + "content_type" => "image/png", + "byte_size" => 20480, + "width" => 1024.0, + "height" => 768.0, + "url" => "https://3.basecampapi.com/1/buckets/2/uploads/900.json", + "app_url" => "https://3.basecamp.com/1/buckets/2/uploads/900", + "download_url" => "https://3.basecampapi.com/1/buckets/2/uploads/900/download/diagram.png", + "app_download_url" => "https://3.basecamp.com/1/buckets/2/uploads/900/download" + } + ] + }, + { + "id" => 4, + "created_at" => "2024-03-15T10:33:00Z", + "kind" => "comment_created", + "avatars_sample" => [], + "attachments" => [ + { + "id" => 500, + "attachable_sgid" => "sgid-attachable-500", + "sgid" => "sgid-500", + "status_url" => "https://3.basecampapi.com/1/attachments/sgid-500/status.json", + "caption" => "See attached", + "filename" => "notes.pdf", + "content_type" => "application/pdf", + "byte_size" => 4096, + "key" => "blobkey500", + "width" => nil, + "height" => nil, + "previewable" => true, + "download_url" => "https://3.basecampapi.com/1/blobs/blobkey500/download/notes.pdf", + "preview_url" => "https://3.basecampapi.com/1/blobs/blobkey500/previews/full", + "thumbnail_url" => "https://3.basecampapi.com/1/blobs/blobkey500/previews/card" + } + ] + } + ] + + stub_get("/12345/projects/456/timeline.json", response_body: events) + + result = @account.timeline.get_project_timeline(project_id: 456).to_a + + assert_equal 4, result.length + + # Event 0: non-empty avatars_sample + assert_equal 2, result[0]["avatars_sample"].length + + # Event 1: schedule-entry timing payload, all-day date-only bounds + assert_equal true, result[1]["data"]["all_day"] + assert_equal "2025-10-30", result[1]["data"]["starts_at"] + assert_equal "2025-10-30", result[1]["data"]["ends_at"] + + # Event 2: full Upload recording attachment variant + upload = result[2]["attachments"][0] + assert_equal "Upload", upload["type"] + assert_equal "diagram.png", upload["filename"] + assert_not_nil upload["app_download_url"] + assert_equal 1024.0, upload["width"] + + # Event 3: rich-text attachment/blob partial variant + blob = result[3]["attachments"][0] + assert_equal "sgid-attachable-500", blob["attachable_sgid"] + assert_equal "See attached", blob["caption"] + assert_equal "blobkey500", blob["key"] + assert_equal true, blob["previewable"] + assert_nil blob["width"] + end + # Note: progress(), person_progress(), person_progress_events() methods # not available in generated service (spec-conformant) end diff --git a/scripts/enhance-openapi-go-types.sh b/scripts/enhance-openapi-go-types.sh index b4d23d447..09b316af1 100755 --- a/scripts/enhance-openapi-go-types.sh +++ b/scripts/enhance-openapi-go-types.sh @@ -176,6 +176,56 @@ walk( "x-go-type-skip-optional-pointer": true } | +# Fifth-b pass: override starts_at/ends_at on TimelineEventData to use +# types.FlexibleTime. Same all-day date-only wire form as ScheduleEntry: the +# schedule_entry_* timeline events carry a date ("2006-01-02") when all_day is +# true, which time.Time cannot parse. Overrides the first pass _at to time.Time. +.components.schemas.TimelineEventData.properties.starts_at += { + "x-go-type": "types.FlexibleTime", + "x-go-type-import": {"path": "github.com/basecamp/basecamp-sdk/go/pkg/types"}, + "x-go-type-skip-optional-pointer": true +} +| +.components.schemas.TimelineEventData.properties.ends_at += { + "x-go-type": "types.FlexibleTime", + "x-go-type-import": {"path": "github.com/basecamp/basecamp-sdk/go/pkg/types"}, + "x-go-type-skip-optional-pointer": true +} +| +# Fifth-c pass: TimelineAttachment width/height → nullable *types.FlexInt +# The attachment/blob variant serializes pixel dimensions float-spelled (1024.0) +# and null for non-image blobs, exactly like RichTextAttachment. Keep the +# optional pointer and mark nullable so the present-null value types faithfully. +.components.schemas.TimelineAttachment.properties |= ( + (.width // empty) += { + "nullable": true, + "x-go-type": "types.FlexInt", + "x-go-type-import": {"path": "github.com/basecamp/basecamp-sdk/go/pkg/types"}, + "x-go-type-skip-optional-pointer": false + } | + (.height // empty) += { + "nullable": true, + "x-go-type": "types.FlexInt", + "x-go-type-import": {"path": "github.com/basecamp/basecamp-sdk/go/pkg/types"}, + "x-go-type-skip-optional-pointer": false + } +) +| +# Fifth-e pass: TimelineAttachment presence-faithful optional scalars. +# The optional-field superset populates only one variant per instance, so its +# optional timestamps and booleans must round-trip presence: a plain time.Time +# re-marshals absent fields as the zero time (0001-01-01T00:00:00Z) and a plain +# bool with omitempty drops an explicit false. Make them pointers so nil (absent) +# omits and an explicit value (including false) is preserved. created_at/updated_at +# keep the time.Time x-go-type but drop skip-optional-pointer so they become +# *time.Time. +.components.schemas.TimelineAttachment.properties |= ( + (.created_at // empty) += { "x-go-type-skip-optional-pointer": false } | + (.updated_at // empty) += { "x-go-type-skip-optional-pointer": false } | + (.visible_to_clients // empty) += { "x-go-type-skip-optional-pointer": false } | + (.previewable // empty) += { "x-go-type-skip-optional-pointer": false } +) +| # Sixth pass: Person.id → types.FlexibleInt64 # The API sometimes returns person IDs as JSON strings (e.g. in notification # responses); Go rejects those into int64 fields. Scoped to Person schema only. diff --git a/spec/api-gaps/README.md b/spec/api-gaps/README.md index 0df965145..7e242f506 100644 --- a/spec/api-gaps/README.md +++ b/spec/api-gaps/README.md @@ -40,7 +40,7 @@ making the absorption journey publicly auditable. | [scratchpad](scratchpad.md) | addressed-in-bc3-pr-12322 | 3b | medium | | [step-top-level](step-top-level.md) | absorbed-in-sdk | 3b | low | | [everything-aggregates](everything-aggregates.md) | addressed-in-bc3-pr-11627 | 3c | high | -| [activity-timeline](activity-timeline.md) | addressed-in-bc3-pr-11629 | 3d | high | +| [activity-timeline](activity-timeline.md) | absorbed-in-sdk | 3d | high | | [recordable-subtypes-doc](recordable-subtypes-doc.md) | partial-coverage | 3a | medium | | [stack-doc-and-smithy](stack-doc-and-smithy.md) | confirmed-not-api-resource | 3b | medium | | [search-filter-additions](search-filter-additions.md) | absorbed-in-sdk | 3e | medium | diff --git a/spec/api-gaps/activity-timeline.md b/spec/api-gaps/activity-timeline.md index fb526f2d2..de82a521e 100644 --- a/spec/api-gaps/activity-timeline.md +++ b/spec/api-gaps/activity-timeline.md @@ -1,9 +1,13 @@ --- gap: activity-timeline -status: addressed-in-bc3-pr-11629 +status: absorbed-in-sdk detected: 2026-05-01 sdk_demand: high bc3_pr: 11629 +smithy_refs: + - "TimelineEvent.kind/avatars_sample/data/attachments (spec/basecamp.smithy:7479)" + - "TimelineEventData (spec/basecamp.smithy:7527)" + - "TimelineAttachment (spec/basecamp.smithy:7543)" bc3_refs: introduced_in: five bc3_plan_phase: 3d @@ -51,31 +55,49 @@ timeline rewrite and were never modeled here. Activity feeds are a primary integration surface for dashboards, audit logs, and "what's new since I last checked" tooling. The SDK already models all -three routes (`GetProgressReport`, `GetProjectTimeline`, `GetPersonProgress` -at `spec/basecamp.smithy:7084`, `:7105`, `:7130`, including the person-route -`{person, events}` object wrapper), so consumers can call them today — but the -event payload's typed surface lags the merged doc, leaving fields to be -consumed untyped. +three routes (operations `GetProgressReport`, `GetProjectTimeline`, +`GetPersonProgress` in `spec/basecamp.smithy`, including the person-route +`{person, events}` object wrapper), so consumers can call them today. This +absorption closes the remaining gap: the event payload's typed surface now +matches the merged doc. ## Suggested API shape The remaining absorption is additive fields on the event shape, per the merged -`doc/api/sections/timeline.md`: - -- `kind` — a 15-value vocabulary: `message_created`, `comment_created`, - `todo_created`, `todo_completed`, `upload_created`, `document_created`, - `schedule_entry_created`, `schedule_entry_rescheduled`, `question_created`, - `question_answer_created`, `chat_transcript_rollup`, `kanban_card_created`, - `kanban_card_completed`, `inbox_forward_created`, - `client_correspondence_created`. -- `data` — event-specific payload; for `schedule_entry_created` / - `schedule_entry_rescheduled` it carries `{all_day, starts_at, ends_at}`. -- `avatars_sample` — array of avatar URLs (used by chat rollups to show - participants). -- `attachments` — array of attached files, if any. +`doc/api/sections/timeline.md` (the doc table is explicitly non-exhaustive — +"common values include" — and the live payload emits kinds the table omits, +e.g. `project_access_changed`, `dock_created`, `google_document_created`): + +- `kind` — kept as an **open, non-exhaustive string** (documentation only, no + closed enum). BC3 adds new kinds over time, so a closed enum would reject + valid future values. Documented common values include `message_created`, + `comment_created`, `todo_created`, `todo_completed`, `upload_created`, + `document_created`, `google_document_created`, `schedule_entry_created`, + `schedule_entry_rescheduled`, `question_created`, `question_answer_created`, + `chat_transcript_rollup`, `kanban_card_created`, `kanban_card_completed`, + `inbox_forward_created`, `client_correspondence_created`, `dock_created`, and + `project_access_changed`. +- `data` — event-specific payload; present only for `schedule_entry_created` / + `schedule_entry_rescheduled`, carrying `{all_day, starts_at, ends_at}`. Per + the bc3 view (`starts_at_date_or_time`), `starts_at`/`ends_at` are + **date-or-timestamp**: a full ISO 8601 timestamp for timed entries, or a bare + date (`YYYY-MM-DD`) when `all_day` is true — so they are **not** plain + timestamps. Modeled as `ISO8601Timestamp` (mirroring `ScheduleEntry`), with + the Go enhancement mapping them to `types.FlexibleTime`; the other SDKs type + them as plain strings. +- `avatars_sample` — array of avatar URLs (populated for chat rollups). +- `attachments` — **heterogeneous**, per the bc3 view + (`api/timelines/events/_event.json.jbuilder`): an upload-kind recording + contributes its full `uploads/upload` shape, while every other recording + contributes a rich-text `attachments/attachment` (+ `blobs/blob`) partial. + These variants share no required field set, so "reuse the existing attachment + shape" is false. Modeled as an **optional-field superset struct** + (`TimelineAttachment`) whose per-variant fields are all optional, so one + element type decodes either variant (the cross-cutting untagged-polymorphism + default; a union was not needed). - Plus the documented envelope fields (`parent_recording_id`, `action`, `target`, `title`, `summary_excerpt`, `bucket`, `creator`, `url`, - `app_url`). + `app_url`), already modeled. ## Implementation notes for BC3 @@ -86,12 +108,22 @@ against live BC5 by the doc tooling from #11629. ## SDK absorption plan when this lands +Absorbed (basecamp-sdk PR-2 of the post-#401 follow-up program). + - No new operations — `GetProgressReport`, `GetProjectTimeline`, and - `GetPersonProgress` already exist with the correct paths and the + `GetPersonProgress` already existed with the correct paths and the person-route object wrapper. -- Extend the timeline event shape with the additive fields above: the `kind` - vocabulary (model as an open string enum — BC3 says "common values - include", so treat it as non-exhaustive), the `data` struct for - schedule-entry events, `avatars_sample`, and `attachments`. -- Canary fixture: `GetProgressReport` exercises the account feed; pairwise - check is structural (the routes exist on both BC4 and BC5). +- Extended `TimelineEvent` with the additive fields: documented `kind` as an + open string (no closed enum), added `TimelineEventData` (`ISO8601Timestamp` + starts_at/ends_at + Go `FlexibleTime` enhancement for all-day date-only + values), `avatars_sample` (`StringList`), and the heterogeneous + `attachments` array as the optional-field superset `TimelineAttachment`. +- **Runtime decode proof:** every SDK has a non-empty, per-variant response + test that decodes BOTH attachment variants (a full Upload recording and a + rich-text attachment/blob partial) in one array, plus the `data` all-day + date-only payload and a non-empty `avatars_sample` — Go (`timeline_test.go`), + TS, Ruby, Python, Kotlin, and Swift. Empty-array / generator-shape checks + were treated as insufficient. +- Canary fixture: `GetProgressReport` exercises the account feed and validates + statically (the live canary is dormant); the pairwise check is structural + (the routes exist on both BC4 and BC5). diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index 8d293fcc3..fa007fe5b 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -7640,7 +7640,18 @@ list TimelineEventList { structure TimelineEvent { id: Long created_at: ISO8601Timestamp + + /// What kind of activity the event records. Open, non-exhaustive vocabulary — + /// BC3 documents "common values include" and adds new kinds over time, so + /// treat unrecognized values as valid. Common values include message_created, + /// comment_created, todo_created, todo_completed, upload_created, + /// document_created, google_document_created, schedule_entry_created, + /// schedule_entry_rescheduled, question_created, question_answer_created, + /// chat_transcript_rollup, kanban_card_created, kanban_card_completed, + /// inbox_forward_created, client_correspondence_created, dock_created, and + /// project_access_changed. kind: String + parent_recording_id: Long url: String app_url: String @@ -7649,7 +7660,103 @@ structure TimelineEvent { target: String title: String summary_excerpt: String + + /// Avatar URLs of participants — populated for chat_transcript_rollup events + /// (the people summarized in the rollup); an empty array otherwise. + avatars_sample: StringList + bucket: TodoBucket + + /// Event-specific payload. Present only for schedule_entry_created and + /// schedule_entry_rescheduled events, where it carries the entry's timing. + data: TimelineEventData + + /// Files attached to the event's recording, when it has any. Heterogeneous: + /// an upload-kind recording contributes its full Upload shape, while other + /// recordings contribute rich-text attachment/blob partials. Modeled as an + /// optional-field superset so a single element type decodes either variant; + /// consumers should treat the per-variant fields as present-or-absent. + attachments: TimelineAttachmentList +} + +/// Schedule-entry timing carried on schedule_entry_* timeline events. starts_at +/// and ends_at are date-or-timestamp: a full ISO 8601 timestamp for timed +/// entries, or a bare date (YYYY-MM-DD) when all_day is true. Modeled as +/// ISO8601Timestamp (mirroring ScheduleEntry), with the Go enhancement pass +/// mapping them to types.FlexibleTime so date-only values decode; the other +/// SDKs type them as plain strings. +structure TimelineEventData { + all_day: Boolean + starts_at: ISO8601Timestamp + ends_at: ISO8601Timestamp +} + +list TimelineAttachmentList { + member: TimelineAttachment +} + +/// A single timeline-event attachment. This is an optional-field superset over +/// two wire variants — a full Upload recording (upload-kind recordings) and a +/// rich-text attachment/blob partial (all other recordings) — so one element +/// type decodes either. Every field is optional; a given instance populates +/// only the fields of the variant it represents. Unknown fields are ignored by +/// every SDK decoder, so the superset need not enumerate every Upload field. +structure TimelineAttachment { + /// Attachment or upload-recording id. + id: Long + + // ----- shared by both variants ----- + /// MIME type of the file. + content_type: String + /// Size of the file in bytes. + byte_size: Long + /// Original filename. + filename: String + /// Authenticated download URL for the file. + @basecampAuthRoutableUrl + download_url: String + /// Pixel width; null for non-image blobs and may be float-spelled (1024.0). + width: Integer + /// Pixel height; null for non-image blobs and may be float-spelled (1024.0). + height: Integer + + // ----- upload-recording variant ----- + /// Recording type, e.g. "Upload" (upload-recording variant). + type: String + /// Title of the upload recording. + title: String + /// Publication status of the upload recording (e.g. "active"). + status: String + /// When the upload recording was created. + created_at: ISO8601Timestamp + /// When the upload recording was last updated. + updated_at: ISO8601Timestamp + /// API URL of the upload recording. + url: String + /// Web URL of the upload recording. + app_url: String + /// Web download URL (upload-recording variant). + app_download_url: String + /// Whether the upload recording is visible to clients. + visible_to_clients: Boolean + + // ----- rich-text attachment/blob variant ----- + /// Signed global id of the attachable (attachment variant). + attachable_sgid: String + /// Signed global id of the attachment (attachment variant). + sgid: String + /// URL to poll attachment processing status (attachment variant). + status_url: String + /// Caption text, if any (attachment variant). + caption: String + /// Storage key of the underlying blob (attachment variant). + key: String + /// Whether the blob can be previewed (attachment variant). + previewable: Boolean + /// Full-size preview URL (attachment variant). + preview_url: String + /// Thumbnail preview URL (attachment variant). + thumbnail_url: String } // ===== Reports Shapes ===== diff --git a/swift/Sources/Basecamp/Generated/Models/TimelineAttachment.swift b/swift/Sources/Basecamp/Generated/Models/TimelineAttachment.swift new file mode 100644 index 000000000..48c391e81 --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/TimelineAttachment.swift @@ -0,0 +1,29 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct TimelineAttachment: Codable, Sendable { + public var appDownloadUrl: String? + public var appUrl: String? + public var attachableSgid: String? + public var byteSize: Int? + public var caption: String? + public var contentType: String? + public var createdAt: String? + public var downloadUrl: String? + public var filename: String? + public var height: Int32? + public var id: Int? + public var key: String? + public var previewUrl: String? + public var previewable: Bool? + public var sgid: String? + public var status: String? + public var statusUrl: String? + public var thumbnailUrl: String? + public var title: String? + public var type: String? + public var updatedAt: String? + public var url: String? + public var visibleToClients: Bool? + public var width: Int32? +} diff --git a/swift/Sources/Basecamp/Generated/Models/TimelineEvent.swift b/swift/Sources/Basecamp/Generated/Models/TimelineEvent.swift index 1831f813c..c8e925a44 100644 --- a/swift/Sources/Basecamp/Generated/Models/TimelineEvent.swift +++ b/swift/Sources/Basecamp/Generated/Models/TimelineEvent.swift @@ -4,9 +4,12 @@ import Foundation public struct TimelineEvent: Codable, Sendable { public var action: String? public var appUrl: String? + public var attachments: [TimelineAttachment]? + public var avatarsSample: [String]? public var bucket: TodoBucket? public var createdAt: String? public var creator: Person? + public var data: TimelineEventData? public var id: Int? public var kind: String? public var parentRecordingId: Int? diff --git a/swift/Sources/Basecamp/Generated/Models/TimelineEventData.swift b/swift/Sources/Basecamp/Generated/Models/TimelineEventData.swift new file mode 100644 index 000000000..cab5015c3 --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/TimelineEventData.swift @@ -0,0 +1,8 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct TimelineEventData: Codable, Sendable { + public var allDay: Bool? + public var endsAt: String? + public var startsAt: String? +} diff --git a/swift/Tests/BasecampTests/GeneratedServiceTests.swift b/swift/Tests/BasecampTests/GeneratedServiceTests.swift index d227e7cf2..fc8c33dd2 100644 --- a/swift/Tests/BasecampTests/GeneratedServiceTests.swift +++ b/swift/Tests/BasecampTests/GeneratedServiceTests.swift @@ -774,4 +774,119 @@ final class GeneratedServiceTests: XCTestCase { XCTAssertNotNil(sentJSON["visible_to_clients"], "explicit false must be sent, not dropped") XCTAssertEqual(sentJSON["visible_to_clients"] as? Bool, false) } + + // MARK: - Activity timeline additive fields (avatars_sample, data, heterogeneous attachments) + + // Proves runtime decode of the additive TimelineEvent fields through the full + // service lifecycle (.convertFromSnakeCase): a populated avatars_sample, a + // schedule-entry `data` payload with all-day date-only bounds, and BOTH + // attachment shapes — a full Upload recording and a rich-text blob partial — + // in a single heterogeneous array, each carrying real per-variant fields. + func testProjectTimelineDecodesAdditiveFields() async throws { + let fixture = """ + [ + { + "id": 1, + "created_at": "2024-03-15T10:30:00Z", + "kind": "chat_transcript_rollup", + "avatars_sample": [ + "https://3.basecampapi.com/1/people/aaa/avatar", + "https://3.basecampapi.com/1/people/bbb/avatar" + ] + }, + { + "id": 2, + "created_at": "2024-03-15T10:31:00Z", + "kind": "schedule_entry_created", + "avatars_sample": [], + "data": { + "all_day": true, + "starts_at": "2025-10-30", + "ends_at": "2025-10-30" + } + }, + { + "id": 3, + "created_at": "2024-03-15T10:32:00Z", + "kind": "upload_created", + "avatars_sample": [], + "attachments": [ + { + "id": 900, + "type": "Upload", + "status": "active", + "visible_to_clients": false, + "title": "Diagram", + "filename": "diagram.png", + "content_type": "image/png", + "byte_size": 20480, + "width": 1024.0, + "height": 768.0, + "url": "https://3.basecampapi.com/1/buckets/2/uploads/900.json", + "app_url": "https://3.basecamp.com/1/buckets/2/uploads/900", + "download_url": "https://3.basecampapi.com/1/buckets/2/uploads/900/download/diagram.png", + "app_download_url": "https://3.basecamp.com/1/buckets/2/uploads/900/download" + } + ] + }, + { + "id": 4, + "created_at": "2024-03-15T10:33:00Z", + "kind": "comment_created", + "avatars_sample": [], + "attachments": [ + { + "id": 500, + "attachable_sgid": "sgid-attachable-500", + "sgid": "sgid-500", + "status_url": "https://3.basecampapi.com/1/attachments/sgid-500/status.json", + "caption": "See attached", + "filename": "notes.pdf", + "content_type": "application/pdf", + "byte_size": 4096, + "key": "blobkey500", + "width": null, + "height": null, + "previewable": true, + "download_url": "https://3.basecampapi.com/1/blobs/blobkey500/download/notes.pdf", + "preview_url": "https://3.basecampapi.com/1/blobs/blobkey500/previews/full", + "thumbnail_url": "https://3.basecampapi.com/1/blobs/blobkey500/previews/card" + } + ] + } + ] + """ + let transport = MockTransport(statusCode: 200, data: Data(fixture.utf8), + headers: ["X-Total-Count": "4"]) + let account = makeTestAccountClient(transport: transport) + + let events = try await account.timeline.projectTimeline(projectId: 2) + XCTAssertEqual(events.count, 4) + + // events[0]: populated avatars_sample + XCTAssertEqual(events[0].avatarsSample?.count, 2) + + // events[1]: schedule-entry data payload with all-day date-only bounds + XCTAssertNotNil(events[1].data) + XCTAssertEqual(events[1].data?.allDay, true) + XCTAssertEqual(events[1].data?.startsAt, "2025-10-30") + XCTAssertEqual(events[1].data?.endsAt, "2025-10-30") + + // events[2]: full Upload recording attachment + XCTAssertEqual(events[2].attachments?.count, 1) + let upload = events[2].attachments![0] + XCTAssertEqual(upload.type, "Upload") + XCTAssertEqual(upload.filename, "diagram.png") + XCTAssertNotNil(upload.appDownloadUrl) + XCTAssertEqual(upload.width, 1024) + + // events[3]: rich-text attachment/blob partial (distinct per-variant fields) + XCTAssertEqual(events[3].attachments?.count, 1) + let blob = events[3].attachments![0] + XCTAssertEqual(blob.attachableSgid, "sgid-attachable-500") + XCTAssertEqual(blob.caption, "See attached") + XCTAssertEqual(blob.key, "blobkey500") + XCTAssertEqual(blob.previewable, true) + XCTAssertNil(blob.width) + } } diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index c86eb2bf3..76c12dfbb 100644 --- a/typescript/src/generated/metadata.ts +++ b/typescript/src/generated/metadata.ts @@ -37,7 +37,7 @@ export interface MetadataOutput { const metadata: MetadataOutput = { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-07-25T06:50:18.441Z", + "generated": "2026-07-25T07:00:12.083Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 1a05eb63f..d22828dbc 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -24907,6 +24907,138 @@ "updated_at" ] }, + "TimelineAttachment": { + "type": "object", + "description": "A single timeline-event attachment. This is an optional-field superset over\ntwo wire variants — a full Upload recording (upload-kind recordings) and a\nrich-text attachment/blob partial (all other recordings) — so one element\ntype decodes either. Every field is optional; a given instance populates\nonly the fields of the variant it represents. Unknown fields are ignored by\nevery SDK decoder, so the superset need not enumerate every Upload field.", + "properties": { + "id": { + "type": "integer", + "description": "Attachment or upload-recording id.", + "format": "int64", + "x-go-type-skip-optional-pointer": false + }, + "content_type": { + "type": "string", + "description": "MIME type of the file." + }, + "byte_size": { + "type": "integer", + "description": "Size of the file in bytes.", + "format": "int64" + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "download_url": { + "type": "string", + "description": "Authenticated download URL for the file.", + "x-basecamp-auth-routable-url": {} + }, + "width": { + "type": "integer", + "description": "Pixel width; null for non-image blobs and may be float-spelled (1024.0).", + "format": "int32", + "nullable": true, + "x-go-type": "types.FlexInt", + "x-go-type-import": { + "path": "github.com/basecamp/basecamp-sdk/go/pkg/types" + }, + "x-go-type-skip-optional-pointer": false + }, + "height": { + "type": "integer", + "description": "Pixel height; null for non-image blobs and may be float-spelled (1024.0).", + "format": "int32", + "nullable": true, + "x-go-type": "types.FlexInt", + "x-go-type-import": { + "path": "github.com/basecamp/basecamp-sdk/go/pkg/types" + }, + "x-go-type-skip-optional-pointer": false + }, + "type": { + "type": "string", + "description": "Recording type, e.g. \"Upload\" (upload-recording variant)." + }, + "title": { + "type": "string", + "description": "Title of the upload recording." + }, + "status": { + "type": "string", + "description": "Publication status of the upload recording (e.g. \"active\")." + }, + "created_at": { + "type": "string", + "description": "When the upload recording was created.", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + }, + "x-go-type-skip-optional-pointer": false + }, + "updated_at": { + "type": "string", + "description": "When the upload recording was last updated.", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + }, + "x-go-type-skip-optional-pointer": false + }, + "url": { + "type": "string", + "description": "API URL of the upload recording." + }, + "app_url": { + "type": "string", + "description": "Web URL of the upload recording." + }, + "app_download_url": { + "type": "string", + "description": "Web download URL (upload-recording variant)." + }, + "visible_to_clients": { + "type": "boolean", + "description": "Whether the upload recording is visible to clients.", + "x-go-type-skip-optional-pointer": false + }, + "attachable_sgid": { + "type": "string", + "description": "Signed global id of the attachable (attachment variant)." + }, + "sgid": { + "type": "string", + "description": "Signed global id of the attachment (attachment variant)." + }, + "status_url": { + "type": "string", + "description": "URL to poll attachment processing status (attachment variant)." + }, + "caption": { + "type": "string", + "description": "Caption text, if any (attachment variant)." + }, + "key": { + "type": "string", + "description": "Storage key of the underlying blob (attachment variant)." + }, + "previewable": { + "type": "boolean", + "description": "Whether the blob can be previewed (attachment variant).", + "x-go-type-skip-optional-pointer": false + }, + "preview_url": { + "type": "string", + "description": "Full-size preview URL (attachment variant)." + }, + "thumbnail_url": { + "type": "string", + "description": "Thumbnail preview URL (attachment variant)." + } + } + }, "TimelineEvent": { "type": "object", "properties": { @@ -24924,7 +25056,8 @@ "x-go-type-skip-optional-pointer": true }, "kind": { - "type": "string" + "type": "string", + "description": "What kind of activity the event records. Open, non-exhaustive vocabulary —\nBC3 documents \"common values include\" and adds new kinds over time, so\ntreat unrecognized values as valid. Common values include message_created,\ncomment_created, todo_created, todo_completed, upload_created,\ndocument_created, google_document_created, schedule_entry_created,\nschedule_entry_rescheduled, question_created, question_answer_created,\nchat_transcript_rollup, kanban_card_created, kanban_card_completed,\ninbox_forward_created, client_correspondence_created, dock_created, and\nproject_access_changed." }, "parent_recording_id": { "type": "integer", @@ -24952,8 +25085,50 @@ "summary_excerpt": { "type": "string" }, + "avatars_sample": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Avatar URLs of participants — populated for chat_transcript_rollup events\n(the people summarized in the rollup); an empty array otherwise." + }, "bucket": { "$ref": "#/components/schemas/TodoBucket" + }, + "data": { + "$ref": "#/components/schemas/TimelineEventData" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimelineAttachment" + }, + "description": "Files attached to the event's recording, when it has any. Heterogeneous:\nan upload-kind recording contributes its full Upload shape, while other\nrecordings contribute rich-text attachment/blob partials. Modeled as an\noptional-field superset so a single element type decodes either variant;\nconsumers should treat the per-variant fields as present-or-absent." + } + } + }, + "TimelineEventData": { + "type": "object", + "description": "Schedule-entry timing carried on schedule_entry_* timeline events. starts_at\nand ends_at are date-or-timestamp: a full ISO 8601 timestamp for timed\nentries, or a bare date (YYYY-MM-DD) when all_day is true. Modeled as\nISO8601Timestamp (mirroring ScheduleEntry), with the Go enhancement pass\nmapping them to types.FlexibleTime so date-only values decode; the other\nSDKs type them as plain strings.", + "properties": { + "all_day": { + "type": "boolean" + }, + "starts_at": { + "type": "string", + "x-go-type": "types.FlexibleTime", + "x-go-type-import": { + "path": "github.com/basecamp/basecamp-sdk/go/pkg/types" + }, + "x-go-type-skip-optional-pointer": true + }, + "ends_at": { + "type": "string", + "x-go-type": "types.FlexibleTime", + "x-go-type-import": { + "path": "github.com/basecamp/basecamp-sdk/go/pkg/types" + }, + "x-go-type-skip-optional-pointer": true } } }, diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 1594ec534..82d84e400 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -4265,10 +4265,91 @@ export interface components { app_url?: string; dock?: components["schemas"]["DockItem"][]; }; + /** + * @description A single timeline-event attachment. This is an optional-field superset over + * two wire variants — a full Upload recording (upload-kind recordings) and a + * rich-text attachment/blob partial (all other recordings) — so one element + * type decodes either. Every field is optional; a given instance populates + * only the fields of the variant it represents. Unknown fields are ignored by + * every SDK decoder, so the superset need not enumerate every Upload field. + */ + TimelineAttachment: { + /** + * Format: int64 + * @description Attachment or upload-recording id. + */ + id?: number; + /** @description MIME type of the file. */ + content_type?: string; + /** + * Format: int64 + * @description Size of the file in bytes. + */ + byte_size?: number; + /** @description Original filename. */ + filename?: string; + /** @description Authenticated download URL for the file. */ + download_url?: string; + /** + * Format: int32 + * @description Pixel width; null for non-image blobs and may be float-spelled (1024.0). + */ + width?: number | null; + /** + * Format: int32 + * @description Pixel height; null for non-image blobs and may be float-spelled (1024.0). + */ + height?: number | null; + /** @description Recording type, e.g. "Upload" (upload-recording variant). */ + type?: string; + /** @description Title of the upload recording. */ + title?: string; + /** @description Publication status of the upload recording (e.g. "active"). */ + status?: string; + /** @description When the upload recording was created. */ + created_at?: string; + /** @description When the upload recording was last updated. */ + updated_at?: string; + /** @description API URL of the upload recording. */ + url?: string; + /** @description Web URL of the upload recording. */ + app_url?: string; + /** @description Web download URL (upload-recording variant). */ + app_download_url?: string; + /** @description Whether the upload recording is visible to clients. */ + visible_to_clients?: boolean; + /** @description Signed global id of the attachable (attachment variant). */ + attachable_sgid?: string; + /** @description Signed global id of the attachment (attachment variant). */ + sgid?: string; + /** @description URL to poll attachment processing status (attachment variant). */ + status_url?: string; + /** @description Caption text, if any (attachment variant). */ + caption?: string; + /** @description Storage key of the underlying blob (attachment variant). */ + key?: string; + /** @description Whether the blob can be previewed (attachment variant). */ + previewable?: boolean; + /** @description Full-size preview URL (attachment variant). */ + preview_url?: string; + /** @description Thumbnail preview URL (attachment variant). */ + thumbnail_url?: string; + }; TimelineEvent: { /** Format: int64 */ id?: number; created_at?: string; + /** + * @description What kind of activity the event records. Open, non-exhaustive vocabulary — + * BC3 documents "common values include" and adds new kinds over time, so + * treat unrecognized values as valid. Common values include message_created, + * comment_created, todo_created, todo_completed, upload_created, + * document_created, google_document_created, schedule_entry_created, + * schedule_entry_rescheduled, question_created, question_answer_created, + * chat_transcript_rollup, kanban_card_created, kanban_card_completed, + * inbox_forward_created, client_correspondence_created, dock_created, and + * project_access_changed. + */ kind?: string; /** Format: int64 */ parent_recording_id?: number; @@ -4279,7 +4360,34 @@ export interface components { target?: string; title?: string; summary_excerpt?: string; + /** + * @description Avatar URLs of participants — populated for chat_transcript_rollup events + * (the people summarized in the rollup); an empty array otherwise. + */ + avatars_sample?: string[]; bucket?: components["schemas"]["TodoBucket"]; + data?: components["schemas"]["TimelineEventData"]; + /** + * @description Files attached to the event's recording, when it has any. Heterogeneous: + * an upload-kind recording contributes its full Upload shape, while other + * recordings contribute rich-text attachment/blob partials. Modeled as an + * optional-field superset so a single element type decodes either variant; + * consumers should treat the per-variant fields as present-or-absent. + */ + attachments?: components["schemas"]["TimelineAttachment"][]; + }; + /** + * @description Schedule-entry timing carried on schedule_entry_* timeline events. starts_at + * and ends_at are date-or-timestamp: a full ISO 8601 timestamp for timed + * entries, or a bare date (YYYY-MM-DD) when all_day is true. Modeled as + * ISO8601Timestamp (mirroring ScheduleEntry), with the Go enhancement pass + * mapping them to types.FlexibleTime so date-only values decode; the other + * SDKs type them as plain strings. + */ + TimelineEventData: { + all_day?: boolean; + starts_at?: string; + ends_at?: string; }; TimesheetEntry: { /** Format: int64 */ diff --git a/typescript/tests/services/timeline.test.ts b/typescript/tests/services/timeline.test.ts index 6a1f4a679..c66f509ed 100644 --- a/typescript/tests/services/timeline.test.ts +++ b/typescript/tests/services/timeline.test.ts @@ -54,5 +54,116 @@ describe("TimelineService", () => { const entries = await client.timeline.projectTimeline(100); expect(entries).toHaveLength(0); }); + + it("should decode additive activity-timeline fields: avatars_sample, data, and heterogeneous attachments", async () => { + const projectId = 100; + + const fixture = [ + { + id: 1, + created_at: "2024-03-15T10:30:00Z", + kind: "chat_transcript_rollup", + avatars_sample: [ + "https://3.basecampapi.com/1/people/aaa/avatar", + "https://3.basecampapi.com/1/people/bbb/avatar", + ], + }, + { + id: 2, + created_at: "2024-03-15T10:31:00Z", + kind: "schedule_entry_created", + avatars_sample: [], + data: { + all_day: true, + starts_at: "2025-10-30", + ends_at: "2025-10-30", + }, + }, + { + id: 3, + created_at: "2024-03-15T10:32:00Z", + kind: "upload_created", + avatars_sample: [], + attachments: [ + { + id: 900, + type: "Upload", + status: "active", + visible_to_clients: false, + title: "Diagram", + filename: "diagram.png", + content_type: "image/png", + byte_size: 20480, + width: 1024.0, + height: 768.0, + url: "https://3.basecampapi.com/1/buckets/2/uploads/900.json", + app_url: "https://3.basecamp.com/1/buckets/2/uploads/900", + download_url: "https://3.basecampapi.com/1/buckets/2/uploads/900/download/diagram.png", + app_download_url: "https://3.basecamp.com/1/buckets/2/uploads/900/download", + }, + ], + }, + { + id: 4, + created_at: "2024-03-15T10:33:00Z", + kind: "comment_created", + avatars_sample: [], + attachments: [ + { + id: 500, + attachable_sgid: "sgid-attachable-500", + sgid: "sgid-500", + status_url: "https://3.basecampapi.com/1/attachments/sgid-500/status.json", + caption: "See attached", + filename: "notes.pdf", + content_type: "application/pdf", + byte_size: 4096, + key: "blobkey500", + width: null, + height: null, + previewable: true, + download_url: "https://3.basecampapi.com/1/blobs/blobkey500/download/notes.pdf", + preview_url: "https://3.basecampapi.com/1/blobs/blobkey500/previews/full", + thumbnail_url: "https://3.basecampapi.com/1/blobs/blobkey500/previews/card", + }, + ], + }, + ]; + + server.use( + http.get(`${BASE_URL}/projects/${projectId}/timeline.json`, () => { + return HttpResponse.json(fixture); + }) + ); + + const events = await client.timeline.projectTimeline(projectId); + expect(events).toHaveLength(4); + + // avatars_sample is a non-empty array of avatar URLs + const avatars = (events[0] as any).avatars_sample; + expect(avatars).toHaveLength(2); + expect(avatars[0]).toBe("https://3.basecampapi.com/1/people/aaa/avatar"); + + // data payload: all-day schedule entry with date-only start/end + const data = (events[1] as any).data; + expect(data.all_day).toBe(true); + expect(data.starts_at).toBe("2025-10-30"); + expect(data.ends_at).toBe("2025-10-30"); + + // attachments variant 1: full Upload recording + const upload = (events[2] as any).attachments[0]; + expect(upload.type).toBe("Upload"); + expect(upload.filename).toBe("diagram.png"); + expect(upload.app_download_url).toBe("https://3.basecamp.com/1/buckets/2/uploads/900/download"); + expect(upload.width).toBe(1024); + + // attachments variant 2: rich-text attachment/blob partial + const blob = (events[3] as any).attachments[0]; + expect(blob.attachable_sgid).toBe("sgid-attachable-500"); + expect(blob.caption).toBe("See attached"); + expect(blob.key).toBe("blobkey500"); + expect(blob.previewable).toBe(true); + expect(blob.width).toBeNull(); + }); }); });