From d7969213062f16f779bb58c5270aec1fccd2bab6 Mon Sep 17 00:00:00 2001 From: Qasim Date: Sun, 14 Jun 2026 10:50:16 -0400 Subject: [PATCH] TW-5401: add missing Nylas v3 CLI commands from developer.nylas.com audit Audited the Nylas v3 API surface against the CLI and added the highest-value missing commands: - email clean: clean conversation (PUT /messages/clean) - email move: move a message to a folder, or --archive - calendar resources: list bookable room/equipment resources - calendar events import: bulk-export events (migration/backup) - notetaker leave: make an active notetaker leave its meeting - notetaker update: update a scheduled notetaker (join time, name, settings) - scheduler group-events: list/create/update/delete/import group events Also fix common.StripHTML to handle block-level tags that carry attributes (e.g.

,
), which previously joined adjacent lines. Unit + integration tests added for every command. scheduler group-events was built from the Nylas OpenAPI spec (no SDK coverage yet); notetaker history was dropped after verifying it is not a real endpoint. --- docs/COMMANDS.md | 25 ++ .../adapters/nylas/calendars_events_import.go | 50 +++ .../nylas/calendars_events_import_test.go | 72 +++ internal/adapters/nylas/demo_admin.go | 33 ++ internal/adapters/nylas/demo_calendars.go | 8 + internal/adapters/nylas/demo_events.go | 5 + internal/adapters/nylas/demo_messages.go | 15 + internal/adapters/nylas/demo_productivity.go | 22 + internal/adapters/nylas/messages_clean.go | 40 ++ .../adapters/nylas/messages_clean_test.go | 92 ++++ internal/adapters/nylas/mock_calendars.go | 7 + internal/adapters/nylas/mock_events.go | 8 + internal/adapters/nylas/mock_messages.go | 15 + internal/adapters/nylas/mock_productivity.go | 17 + internal/adapters/nylas/mock_scheduler.go | 32 ++ internal/adapters/nylas/notetakers.go | 65 +++ .../adapters/nylas/notetakers_leave_test.go | 70 +++ .../adapters/nylas/notetakers_update_test.go | 72 +++ internal/adapters/nylas/resources.go | 28 ++ internal/adapters/nylas/resources_test.go | 82 ++++ .../adapters/nylas/scheduler_group_events.go | 145 ++++++ .../nylas/scheduler_group_events_test.go | 191 ++++++++ internal/cli/calendar/calendar.go | 1 + internal/cli/calendar/events.go | 1 + internal/cli/calendar/events_import.go | 124 ++++++ internal/cli/calendar/events_import_test.go | 53 +++ internal/cli/calendar/resources.go | 74 +++ internal/cli/calendar/resources_cmd_test.go | 34 ++ internal/cli/common/html.go | 31 +- internal/cli/common/html_test.go | 66 +++ internal/cli/email/clean.go | 116 +++++ internal/cli/email/clean_test.go | 43 ++ internal/cli/email/email.go | 2 + internal/cli/email/move.go | 76 ++++ internal/cli/email/move_test.go | 45 ++ .../integration/calendar_resources_test.go | 52 +++ .../cli/integration/email_clean_move_test.go | 95 ++++ .../cli/integration/events_import_test.go | 76 ++++ internal/cli/integration/group_events_test.go | 70 +++ .../integration/new_commands_helpers_test.go | 21 + .../integration/notetaker_lifecycle_test.go | 83 ++++ internal/cli/notetaker/leave.go | 40 ++ internal/cli/notetaker/leave_test.go | 40 ++ internal/cli/notetaker/notetaker.go | 2 + internal/cli/notetaker/update.go | 108 +++++ internal/cli/notetaker/update_test.go | 36 ++ internal/cli/scheduler/group_events.go | 421 ++++++++++++++++++ internal/cli/scheduler/group_events_test.go | 105 +++++ internal/cli/scheduler/scheduler.go | 1 + internal/domain/email_clean.go | 32 ++ internal/domain/group_events.go | 72 +++ internal/domain/notetaker.go | 15 + internal/domain/room_resources.go | 17 + internal/ports/calendar.go | 8 + internal/ports/messages.go | 4 + internal/ports/notetaker.go | 7 + internal/ports/scheduler.go | 23 + 57 files changed, 3074 insertions(+), 14 deletions(-) create mode 100644 internal/adapters/nylas/calendars_events_import.go create mode 100644 internal/adapters/nylas/calendars_events_import_test.go create mode 100644 internal/adapters/nylas/messages_clean.go create mode 100644 internal/adapters/nylas/messages_clean_test.go create mode 100644 internal/adapters/nylas/notetakers_leave_test.go create mode 100644 internal/adapters/nylas/notetakers_update_test.go create mode 100644 internal/adapters/nylas/resources.go create mode 100644 internal/adapters/nylas/resources_test.go create mode 100644 internal/adapters/nylas/scheduler_group_events.go create mode 100644 internal/adapters/nylas/scheduler_group_events_test.go create mode 100644 internal/cli/calendar/events_import.go create mode 100644 internal/cli/calendar/events_import_test.go create mode 100644 internal/cli/calendar/resources.go create mode 100644 internal/cli/calendar/resources_cmd_test.go create mode 100644 internal/cli/common/html_test.go create mode 100644 internal/cli/email/clean.go create mode 100644 internal/cli/email/clean_test.go create mode 100644 internal/cli/email/move.go create mode 100644 internal/cli/email/move_test.go create mode 100644 internal/cli/integration/calendar_resources_test.go create mode 100644 internal/cli/integration/email_clean_move_test.go create mode 100644 internal/cli/integration/events_import_test.go create mode 100644 internal/cli/integration/group_events_test.go create mode 100644 internal/cli/integration/new_commands_helpers_test.go create mode 100644 internal/cli/integration/notetaker_lifecycle_test.go create mode 100644 internal/cli/notetaker/leave.go create mode 100644 internal/cli/notetaker/leave_test.go create mode 100644 internal/cli/notetaker/update.go create mode 100644 internal/cli/notetaker/update_test.go create mode 100644 internal/cli/scheduler/group_events.go create mode 100644 internal/cli/scheduler/group_events_test.go create mode 100644 internal/domain/email_clean.go create mode 100644 internal/domain/group_events.go create mode 100644 internal/domain/room_resources.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 8ff22ad..6fd668d 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -206,6 +206,10 @@ nylas email delete # Delete email nylas email mark read # Mark as read nylas email mark unread # Mark as unread nylas email mark starred # Star a message +nylas email move --folder # Move a message to a folder +nylas email move --archive # Archive a message (clear folders/labels) +nylas email clean # Strip quoted replies & signatures (clean conversation) +nylas email clean --keep-links # Clean multiple messages, keep links (--json for raw HTML) nylas email attachments list # List attachments nylas email attachments download # Download attachment nylas email metadata show # Show message metadata @@ -361,7 +365,9 @@ nylas calendar events create --title T --start TIME --end TIME # Create event nylas calendar events update --title "New Title" # Update event nylas calendar events delete # Delete event nylas calendar events rsvp --status yes # RSVP to event +nylas calendar events import --calendar primary --start 2026-01-01 --end 2026-12-31 --json # Bulk export/migrate nylas calendar availability check # Check availability +nylas calendar resources # List bookable rooms/equipment (alias: rooms) nylas calendar recurring list # List recurring events nylas calendar virtual list # List virtual meetings nylas calendar focus-time list # List focus time blocks @@ -809,11 +815,22 @@ nylas notetaker show # Get recording and transcript URLs nylas notetaker media +# Update a scheduled notetaker (before it joins) +nylas notetaker update --join-time "tomorrow 2pm" +nylas notetaker update --bot-name "Recorder" --transcription=false + +# Make an active notetaker leave the meeting (keeps the recording) +nylas notetaker leave + # Delete/cancel a notetaker nylas notetaker delete # With confirmation nylas notetaker delete --yes # Skip confirmation ``` +> **`leave` vs `delete`:** `leave` removes an *active* bot from its meeting and triggers +> recording/transcript generation, keeping the notetaker record. `delete` cancels a +> scheduled bot or removes the notetaker and any unsaved media entirely. + **Aliases:** `nylas nt`, `nylas bot` **Supported Providers:** Zoom, Google Meet, Microsoft Teams @@ -888,6 +905,14 @@ nylas scheduler configurations delete # Delete configuration # Sessions nylas scheduler sessions create # Create booking session nylas scheduler sessions show # Show session details + +# Group events (shared/group booking under a configuration; alias: ge) +nylas scheduler group-events list --calendar primary # List group events (in a time window) +nylas scheduler group-events create --calendar primary \ + --title "Workshop" --capacity 50 --start "2026-07-01 18:00" --end "2026-07-01 19:00" +nylas scheduler group-events update --capacity 80 +nylas scheduler group-events delete # Delete a group event +nylas scheduler group-events import --file events.json # Import provider events ``` **Details:** `docs/commands/scheduler.md` diff --git a/internal/adapters/nylas/calendars_events_import.go b/internal/adapters/nylas/calendars_events_import.go new file mode 100644 index 0000000..adf5e1b --- /dev/null +++ b/internal/adapters/nylas/calendars_events_import.go @@ -0,0 +1,50 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" +) + +// importEventsDefaultLimit mirrors the API default page size for the import +// endpoint (max allowed is 500). +const importEventsDefaultLimit = 50 + +// ImportEvents bulk-reads events from a calendar over a time window +// (GET /v3/grants/{id}/events/import), including expanded recurring instances. +// Unlike GetEvents this endpoint is intended for migration/export and requires +// a calendar ID. When start/end are omitted the API defaults to now .. +1 month. +func (c *HTTPClient) ImportEvents(ctx context.Context, grantID string, params *domain.EventQueryParams) ([]domain.Event, error) { + if err := validateRequired("grant ID", grantID); err != nil { + return nil, err + } + if params == nil || params.CalendarID == "" { + return nil, fmt.Errorf("%w: calendar ID is required for import", domain.ErrInvalidInput) + } + // Don't mutate the caller's params: a shared/reused EventQueryParams must + // not be silently altered by this call. + limit := params.Limit + if limit <= 0 { + limit = importEventsDefaultLimit + } + + baseURL := fmt.Sprintf("%s/v3/grants/%s/events/import", c.baseURL, url.PathEscape(grantID)) + queryURL := NewQueryBuilder(). + Add("calendar_id", params.CalendarID). + AddInt("limit", limit). + Add("page_token", params.PageToken). + AddInt64("start", params.Start). + AddInt64("end", params.End). + BuildURL(baseURL) + + var result struct { + Data []eventResponse `json:"data"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + return convertEvents(result.Data), nil +} diff --git a/internal/adapters/nylas/calendars_events_import_test.go b/internal/adapters/nylas/calendars_events_import_test.go new file mode 100644 index 0000000..14be3de --- /dev/null +++ b/internal/adapters/nylas/calendars_events_import_test.go @@ -0,0 +1,72 @@ +//go:build !integration +// +build !integration + +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClient_ImportEvents(t *testing.T) { + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Import hits the dedicated /events/import path, not /events. + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v3/grants/grant-1/events/import", r.URL.Path) + gotQuery = r.URL.RawQuery + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "evt-1", "calendar_id": "primary", "title": "Kickoff"}, + }, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + events, err := client.ImportEvents(context.Background(), "grant-1", &domain.EventQueryParams{ + CalendarID: "primary", + Start: 1735689600, + End: 1767225600, + }) + + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "evt-1", events[0].ID) + // calendar_id is required by the API and must be forwarded as a query param. + assert.Contains(t, gotQuery, "calendar_id=primary") + assert.Contains(t, gotQuery, "start=1735689600") +} + +func TestHTTPClient_ImportEvents_RequiresCalendar(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + t.Run("missing calendar id", func(t *testing.T) { + _, err := client.ImportEvents(context.Background(), "grant-1", &domain.EventQueryParams{}) + assert.Error(t, err) + }) + + t.Run("nil params", func(t *testing.T) { + _, err := client.ImportEvents(context.Background(), "grant-1", nil) + assert.Error(t, err) + }) + + t.Run("missing grant", func(t *testing.T) { + _, err := client.ImportEvents(context.Background(), "", &domain.EventQueryParams{CalendarID: "primary"}) + assert.Error(t, err) + }) +} diff --git a/internal/adapters/nylas/demo_admin.go b/internal/adapters/nylas/demo_admin.go index 58535b6..856d59b 100644 --- a/internal/adapters/nylas/demo_admin.go +++ b/internal/adapters/nylas/demo_admin.go @@ -92,6 +92,39 @@ func (d *DemoClient) CancelBooking(ctx context.Context, bookingID string, reason return nil } +// Group Event Demo Implementations + +func (d *DemoClient) ListGroupEvents(ctx context.Context, grantID, configID, calendarID string, startTime, endTime int64) ([]domain.GroupEvent, error) { + return []domain.GroupEvent{ + {ID: "ge-001", Title: "Annual Philosophy Club Meeting", CalendarID: "primary", Capacity: 50, Location: "Library, Cave Room"}, + {ID: "ge-002", Title: "Intro to Stoicism Workshop", CalendarID: "primary", Capacity: 20}, + }, nil +} + +func (d *DemoClient) CreateGroupEvent(ctx context.Context, grantID, configID string, req *domain.CreateGroupEventRequest) ([]domain.GroupEvent, error) { + return []domain.GroupEvent{ + {ID: "ge-new", Title: req.Title, CalendarID: req.CalendarID, Capacity: req.Capacity, Participants: req.Participants, When: req.When}, + }, nil +} + +func (d *DemoClient) UpdateGroupEvent(ctx context.Context, grantID, configID, eventID string, req *domain.UpdateGroupEventRequest) ([]domain.GroupEvent, error) { + return []domain.GroupEvent{ + {ID: eventID, Title: req.Title, CalendarID: req.CalendarID, Capacity: req.Capacity}, + }, nil +} + +func (d *DemoClient) DeleteGroupEvent(ctx context.Context, grantID, configID, eventID string) error { + return nil +} + +func (d *DemoClient) ImportGroupEvents(ctx context.Context, configID string, items []domain.ImportGroupEventItem) ([]domain.GroupEvent, error) { + events := make([]domain.GroupEvent, 0, len(items)) + for _, it := range items { + events = append(events, domain.GroupEvent{ID: "ge-import", CalendarID: it.CalendarID, Capacity: it.Capacity}) + } + return events, nil +} + // Admin Demo Implementations func (d *DemoClient) ListApplications(ctx context.Context) ([]domain.Application, error) { diff --git a/internal/adapters/nylas/demo_calendars.go b/internal/adapters/nylas/demo_calendars.go index 6efeace..e425c49 100644 --- a/internal/adapters/nylas/demo_calendars.go +++ b/internal/adapters/nylas/demo_calendars.go @@ -20,6 +20,14 @@ func (d *DemoClient) GetCalendar(ctx context.Context, grantID, calendarID string return &domain.Calendar{ID: calendarID, Name: "Demo Calendar", IsPrimary: true}, nil } +// ListRoomResources returns demo room resources. +func (d *DemoClient) ListRoomResources(ctx context.Context, grantID string) ([]domain.RoomResource, error) { + return []domain.RoomResource{ + {Email: "boardroom@demo.nylas.com", Name: "Boardroom", Capacity: 20, Building: "Main", FloorName: "5", FloorNumber: 5, Object: "room_resource"}, + {Email: "huddle-1@demo.nylas.com", Name: "Huddle Room 1", Capacity: 4, Building: "Main", FloorName: "2", FloorNumber: 2, Object: "room_resource"}, + }, nil +} + // CreateCalendar simulates creating a calendar. func (d *DemoClient) CreateCalendar(ctx context.Context, grantID string, req *domain.CreateCalendarRequest) (*domain.Calendar, error) { return &domain.Calendar{ diff --git a/internal/adapters/nylas/demo_events.go b/internal/adapters/nylas/demo_events.go index 0544332..cddf9eb 100644 --- a/internal/adapters/nylas/demo_events.go +++ b/internal/adapters/nylas/demo_events.go @@ -17,6 +17,11 @@ func (d *DemoClient) GetEventsWithCursor(ctx context.Context, grantID, calendarI return &domain.EventListResponse{Data: d.getDemoEvents()}, nil } +// ImportEvents returns demo events for bulk import/export. +func (d *DemoClient) ImportEvents(ctx context.Context, grantID string, params *domain.EventQueryParams) ([]domain.Event, error) { + return d.getDemoEvents(), nil +} + func (d *DemoClient) getDemoEvents() []domain.Event { now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) diff --git a/internal/adapters/nylas/demo_messages.go b/internal/adapters/nylas/demo_messages.go index 6704798..fe8e086 100644 --- a/internal/adapters/nylas/demo_messages.go +++ b/internal/adapters/nylas/demo_messages.go @@ -191,3 +191,18 @@ func (d *DemoClient) UpdateMessage(ctx context.Context, grantID, messageID strin func (d *DemoClient) DeleteMessage(ctx context.Context, grantID, messageID string) error { return nil } + +// CleanMessages simulates cleaning messages into display-ready text. +func (d *DemoClient) CleanMessages(ctx context.Context, grantID string, req *domain.CleanMessagesRequest) ([]domain.CleanedMessage, error) { + result := make([]domain.CleanedMessage, 0, len(req.MessageIDs)) + for _, id := range req.MessageIDs { + result = append(result, domain.CleanedMessage{ + ID: id, + GrantID: grantID, + Object: "message", + Subject: "Quarterly Review", + Conversation: "Thanks for the update — the numbers look great. Let's sync on Thursday.", + }) + } + return result, nil +} diff --git a/internal/adapters/nylas/demo_productivity.go b/internal/adapters/nylas/demo_productivity.go index 838f988..f558370 100644 --- a/internal/adapters/nylas/demo_productivity.go +++ b/internal/adapters/nylas/demo_productivity.go @@ -162,6 +162,28 @@ func (d *DemoClient) DeleteNotetaker(ctx context.Context, grantID, notetakerID s return nil } +// LeaveNotetaker simulates an active notetaker leaving its meeting. +func (d *DemoClient) LeaveNotetaker(ctx context.Context, grantID, notetakerID string) error { + return nil +} + +// UpdateNotetaker simulates updating a scheduled notetaker. +func (d *DemoClient) UpdateNotetaker(ctx context.Context, grantID, notetakerID string, req *domain.UpdateNotetakerRequest) (*domain.Notetaker, error) { + now := time.Now() + nt := &domain.Notetaker{ + ID: notetakerID, + State: domain.NotetakerStateScheduled, + UpdatedAt: now, + } + if req.JoinTime > 0 { + nt.JoinTime = time.Unix(req.JoinTime, 0) + } + if req.Name != "" { + nt.BotConfig = &domain.BotConfig{Name: req.Name} + } + return nt, nil +} + // GetNotetakerMedia returns demo notetaker media. func (d *DemoClient) GetNotetakerMedia(ctx context.Context, grantID, notetakerID string) (*domain.MediaData, error) { return &domain.MediaData{ diff --git a/internal/adapters/nylas/messages_clean.go b/internal/adapters/nylas/messages_clean.go new file mode 100644 index 0000000..2fff1c4 --- /dev/null +++ b/internal/adapters/nylas/messages_clean.go @@ -0,0 +1,40 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" +) + +// CleanMessages parses one or more messages into clean, display-ready text, +// stripping quoted reply/forward chains, signatures, and conclusion phrases +// (PUT /v3/grants/{id}/messages/clean). +func (c *HTTPClient) CleanMessages(ctx context.Context, grantID string, req *domain.CleanMessagesRequest) ([]domain.CleanedMessage, error) { + if err := validateRequired("grant ID", grantID); err != nil { + return nil, err + } + if req == nil || len(req.MessageIDs) == 0 { + return nil, fmt.Errorf("%w: at least one message ID is required", domain.ErrInvalidInput) + } + if len(req.MessageIDs) > domain.CleanMessagesMaxIDs { + return nil, fmt.Errorf("%w: clean accepts at most %d message IDs (got %d)", domain.ErrInvalidInput, domain.CleanMessagesMaxIDs, len(req.MessageIDs)) + } + + queryURL := fmt.Sprintf("%s/v3/grants/%s/messages/clean", c.baseURL, url.PathEscape(grantID)) + + resp, err := c.doJSONRequest(ctx, "PUT", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data []domain.CleanedMessage `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + return result.Data, nil +} diff --git a/internal/adapters/nylas/messages_clean_test.go b/internal/adapters/nylas/messages_clean_test.go new file mode 100644 index 0000000..925cec1 --- /dev/null +++ b/internal/adapters/nylas/messages_clean_test.go @@ -0,0 +1,92 @@ +//go:build !integration +// +build !integration + +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClient_CleanMessages(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/v3/grants/grant-123/messages/clean", r.URL.Path) + _ = json.NewDecoder(r.Body).Decode(&body) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + { + "id": "msg-1", + "grant_id": "grant-123", + "object": "message", + "subject": "Re: Lunch", + "conversation": "Sounds good, see you at noon.", + "body": "

Sounds good, see you at noon.
On Monday X wrote...
", + }, + }, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + keepLinks := false + req := &domain.CleanMessagesRequest{ + MessageIDs: []string{"msg-1"}, + IgnoreLinks: &keepLinks, + } + cleaned, err := client.CleanMessages(context.Background(), "grant-123", req) + + require.NoError(t, err) + require.Len(t, cleaned, 1) + // The whole point of clean is the parsed conversation text — assert it, + // not just that a request happened. + assert.Equal(t, "Sounds good, see you at noon.", cleaned[0].Conversation) + assert.Equal(t, "msg-1", cleaned[0].ID) + + // The request must carry the exact message IDs and only the options the + // caller set — a regression that sent the wrong IDs or leaked defaults must fail. + assert.Equal(t, []any{"msg-1"}, body["message_id"]) + assert.Equal(t, false, body["ignore_links"]) + assert.NotContains(t, body, "images_as_markdown", "unset options must be omitted so API defaults apply") +} + +func TestHTTPClient_CleanMessages_Validation(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + // No base URL set: validation must fail before any network call. + + t.Run("rejects empty message list", func(t *testing.T) { + _, err := client.CleanMessages(context.Background(), "grant-123", &domain.CleanMessagesRequest{}) + assert.Error(t, err) + }) + + t.Run("rejects more than the API maximum", func(t *testing.T) { + ids := make([]string, domain.CleanMessagesMaxIDs+1) + for i := range ids { + ids[i] = "m" + } + _, err := client.CleanMessages(context.Background(), "grant-123", &domain.CleanMessagesRequest{MessageIDs: ids}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "at most"), "error should explain the 20-ID limit, got: %v", err) + }) + + t.Run("requires a grant", func(t *testing.T) { + _, err := client.CleanMessages(context.Background(), "", &domain.CleanMessagesRequest{MessageIDs: []string{"m1"}}) + assert.Error(t, err) + }) +} diff --git a/internal/adapters/nylas/mock_calendars.go b/internal/adapters/nylas/mock_calendars.go index d557145..245e5a5 100644 --- a/internal/adapters/nylas/mock_calendars.go +++ b/internal/adapters/nylas/mock_calendars.go @@ -15,6 +15,13 @@ func (m *MockClient) GetCalendars(ctx context.Context, grantID string) ([]domain }, nil } +// ListRoomResources returns mock room resources. +func (m *MockClient) ListRoomResources(ctx context.Context, grantID string) ([]domain.RoomResource, error) { + return []domain.RoomResource{ + {Email: "room-a@example.com", Name: "Conference Room A", Capacity: 10, Building: "HQ", FloorName: "3", Object: "room_resource"}, + }, nil +} + // GetCalendar retrieves a single calendar. func (m *MockClient) GetCalendar(ctx context.Context, grantID, calendarID string) (*domain.Calendar, error) { return &domain.Calendar{ diff --git a/internal/adapters/nylas/mock_events.go b/internal/adapters/nylas/mock_events.go index 38ad740..4c7d3e3 100644 --- a/internal/adapters/nylas/mock_events.go +++ b/internal/adapters/nylas/mock_events.go @@ -13,6 +13,14 @@ func (m *MockClient) GetEvents(ctx context.Context, grantID, calendarID string, return []domain.Event{}, nil } +// ImportEvents bulk-reads events for a calendar. +func (m *MockClient) ImportEvents(ctx context.Context, grantID string, params *domain.EventQueryParams) ([]domain.Event, error) { + if m.GetEventsFunc != nil { + return m.GetEventsFunc(ctx, grantID, params.CalendarID, params) + } + return []domain.Event{}, nil +} + // GetEventsWithCursor retrieves events with pagination. func (m *MockClient) GetEventsWithCursor(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) (*domain.EventListResponse, error) { if m.GetEventsWithCursorFunc != nil { diff --git a/internal/adapters/nylas/mock_messages.go b/internal/adapters/nylas/mock_messages.go index 18f1306..643fd90 100644 --- a/internal/adapters/nylas/mock_messages.go +++ b/internal/adapters/nylas/mock_messages.go @@ -126,6 +126,21 @@ func (m *MockClient) UpdateMessage(ctx context.Context, grantID, messageID strin return msg, nil } +// CleanMessages returns mock cleaned messages. +func (m *MockClient) CleanMessages(ctx context.Context, grantID string, req *domain.CleanMessagesRequest) ([]domain.CleanedMessage, error) { + result := make([]domain.CleanedMessage, 0, len(req.MessageIDs)) + for _, id := range req.MessageIDs { + result = append(result, domain.CleanedMessage{ + ID: id, + GrantID: grantID, + Object: "message", + Subject: "Test Subject", + Conversation: "Cleaned conversation text", + }) + } + return result, nil +} + // DeleteMessage deletes a message. func (m *MockClient) DeleteMessage(ctx context.Context, grantID, messageID string) error { m.DeleteMessageCalled = true diff --git a/internal/adapters/nylas/mock_productivity.go b/internal/adapters/nylas/mock_productivity.go index dfbb343..a4896c4 100644 --- a/internal/adapters/nylas/mock_productivity.go +++ b/internal/adapters/nylas/mock_productivity.go @@ -45,6 +45,23 @@ func (m *MockClient) DeleteNotetaker(ctx context.Context, grantID, notetakerID s return nil } +// LeaveNotetaker simulates an active notetaker leaving its meeting. +func (m *MockClient) LeaveNotetaker(ctx context.Context, grantID, notetakerID string) error { + return nil +} + +// UpdateNotetaker simulates updating a scheduled notetaker. +func (m *MockClient) UpdateNotetaker(ctx context.Context, grantID, notetakerID string, req *domain.UpdateNotetakerRequest) (*domain.Notetaker, error) { + nt := &domain.Notetaker{ + ID: notetakerID, + State: domain.NotetakerStateScheduled, + } + if req.Name != "" { + nt.BotConfig = &domain.BotConfig{Name: req.Name} + } + return nt, nil +} + // GetNotetakerMedia retrieves notetaker media. func (m *MockClient) GetNotetakerMedia(ctx context.Context, grantID, notetakerID string) (*domain.MediaData, error) { return &domain.MediaData{ diff --git a/internal/adapters/nylas/mock_scheduler.go b/internal/adapters/nylas/mock_scheduler.go index 4c7bf6e..51b205c 100644 --- a/internal/adapters/nylas/mock_scheduler.go +++ b/internal/adapters/nylas/mock_scheduler.go @@ -90,4 +90,36 @@ func (m *MockClient) CancelBooking(ctx context.Context, bookingID string, reason return nil } +// Group Event Mock Implementations + +func (m *MockClient) ListGroupEvents(ctx context.Context, grantID, configID, calendarID string, startTime, endTime int64) ([]domain.GroupEvent, error) { + return []domain.GroupEvent{ + {ID: "ge-1", Title: "Philosophy Club", CalendarID: "primary", Capacity: 50}, + }, nil +} + +func (m *MockClient) CreateGroupEvent(ctx context.Context, grantID, configID string, req *domain.CreateGroupEventRequest) ([]domain.GroupEvent, error) { + return []domain.GroupEvent{ + {ID: "ge-new", Title: req.Title, CalendarID: req.CalendarID, Capacity: req.Capacity, Participants: req.Participants, When: req.When}, + }, nil +} + +func (m *MockClient) UpdateGroupEvent(ctx context.Context, grantID, configID, eventID string, req *domain.UpdateGroupEventRequest) ([]domain.GroupEvent, error) { + return []domain.GroupEvent{ + {ID: eventID, Title: req.Title, CalendarID: req.CalendarID, Capacity: req.Capacity}, + }, nil +} + +func (m *MockClient) DeleteGroupEvent(ctx context.Context, grantID, configID, eventID string) error { + return nil +} + +func (m *MockClient) ImportGroupEvents(ctx context.Context, configID string, items []domain.ImportGroupEventItem) ([]domain.GroupEvent, error) { + events := make([]domain.GroupEvent, 0, len(items)) + for _, it := range items { + events = append(events, domain.GroupEvent{ID: "ge-import", CalendarID: it.CalendarID, Capacity: it.Capacity}) + } + return events, nil +} + // Admin Mock Implementations diff --git a/internal/adapters/nylas/notetakers.go b/internal/adapters/nylas/notetakers.go index 0561f02..932038f 100644 --- a/internal/adapters/nylas/notetakers.go +++ b/internal/adapters/nylas/notetakers.go @@ -3,6 +3,7 @@ package nylas import ( "context" "fmt" + "net/http" "net/url" "time" @@ -131,6 +132,70 @@ func (c *HTTPClient) DeleteNotetaker(ctx context.Context, grantID, notetakerID s return c.doDelete(ctx, queryURL) } +// LeaveNotetaker instructs an active notetaker to leave the meeting it is in. +// Unlike DeleteNotetaker, the notetaker record and any generated media are kept. +func (c *HTTPClient) LeaveNotetaker(ctx context.Context, grantID, notetakerID string) error { + queryURL := fmt.Sprintf("%s/v3/grants/%s/notetakers/%s/leave", c.baseURL, url.PathEscape(grantID), url.PathEscape(notetakerID)) + resp, err := c.doJSONRequest(ctx, "POST", queryURL, map[string]any{}, http.StatusOK, http.StatusAccepted) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + return nil +} + +// UpdateNotetaker updates a scheduled notetaker (join time, name, settings) +// via PATCH. Only fields the caller set are sent. +func (c *HTTPClient) UpdateNotetaker(ctx context.Context, grantID, notetakerID string, req *domain.UpdateNotetakerRequest) (*domain.Notetaker, error) { + if req == nil { + return nil, fmt.Errorf("%w: update request cannot be nil", domain.ErrInvalidInput) + } + + queryURL := fmt.Sprintf("%s/v3/grants/%s/notetakers/%s", c.baseURL, url.PathEscape(grantID), url.PathEscape(notetakerID)) + + payload := map[string]any{} + if req.JoinTime > 0 { + payload["join_time"] = req.JoinTime + } + if req.Name != "" { + payload["name"] = req.Name + } + if req.MeetingSettings != nil { + ms := map[string]any{} + if req.MeetingSettings.VideoRecording != nil { + ms["video_recording"] = *req.MeetingSettings.VideoRecording + } + if req.MeetingSettings.AudioRecording != nil { + ms["audio_recording"] = *req.MeetingSettings.AudioRecording + } + if req.MeetingSettings.Transcription != nil { + ms["transcription"] = *req.MeetingSettings.Transcription + } + if len(ms) > 0 { + payload["meeting_settings"] = ms + } + } + + if len(payload) == 0 { + return nil, fmt.Errorf("%w: no fields to update", domain.ErrInvalidInput) + } + + resp, err := c.doJSONRequest(ctx, "PATCH", queryURL, payload) + if err != nil { + return nil, err + } + + var result struct { + Data notetakerResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + notetaker := convertNotetaker(result.Data) + return ¬etaker, nil +} + // GetNotetakerMedia retrieves the media (recording/transcript) for a notetaker. func (c *HTTPClient) GetNotetakerMedia(ctx context.Context, grantID, notetakerID string) (*domain.MediaData, error) { queryURL := fmt.Sprintf("%s/v3/grants/%s/notetakers/%s/media", c.baseURL, url.PathEscape(grantID), url.PathEscape(notetakerID)) diff --git a/internal/adapters/nylas/notetakers_leave_test.go b/internal/adapters/nylas/notetakers_leave_test.go new file mode 100644 index 0000000..c83c928 --- /dev/null +++ b/internal/adapters/nylas/notetakers_leave_test.go @@ -0,0 +1,70 @@ +//go:build !integration +// +build !integration + +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClient_LeaveNotetaker(t *testing.T) { + tests := []struct { + name string + status int + }{ + {"accepts 200 OK", http.StatusOK}, + {"accepts 202 Accepted", http.StatusAccepted}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // leave is a POST to the /leave sub-path, distinct from delete. + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v3/grants/grant-1/notetakers/nt-1/leave", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "request_id": "req-1", + "data": map[string]any{"id": "nt-1", "message": "left meeting"}, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + err := client.LeaveNotetaker(context.Background(), "grant-1", "nt-1") + require.NoError(t, err) + }) + } +} + +func TestHTTPClient_LeaveNotetaker_Error(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "request_id": "req-1", + "error": map[string]any{"type": "not_found", "message": "notetaker not found"}, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + err := client.LeaveNotetaker(context.Background(), "grant-1", "missing") + assert.Error(t, err) +} diff --git a/internal/adapters/nylas/notetakers_update_test.go b/internal/adapters/nylas/notetakers_update_test.go new file mode 100644 index 0000000..80848b2 --- /dev/null +++ b/internal/adapters/nylas/notetakers_update_test.go @@ -0,0 +1,72 @@ +//go:build !integration +// +build !integration + +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClient_UpdateNotetaker(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Notetaker update is a PATCH (not PUT/POST) on the bare notetaker path. + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/v3/grants/grant-1/notetakers/nt-1", r.URL.Path) + _ = json.NewDecoder(r.Body).Decode(&body) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"id": "nt-1", "state": "scheduled", "object": "notetaker"}, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + video := false + req := &domain.UpdateNotetakerRequest{ + JoinTime: 1893456000, + Name: "Recorder", + MeetingSettings: &domain.NotetakerMeetingSettings{ + VideoRecording: &video, + }, + } + nt, err := client.UpdateNotetaker(context.Background(), "grant-1", "nt-1", req) + + require.NoError(t, err) + assert.Equal(t, "nt-1", nt.ID) + // The set fields must reach the wire; nested settings must be a sub-object, + // and an explicit false must not be dropped. + assert.Equal(t, "Recorder", body["name"]) + assert.EqualValues(t, 1893456000, body["join_time"]) + ms, ok := body["meeting_settings"].(map[string]any) + require.True(t, ok, "meeting_settings should be a nested object") + assert.Equal(t, false, ms["video_recording"]) +} + +func TestHTTPClient_UpdateNotetaker_Validation(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + t.Run("nil request", func(t *testing.T) { + _, err := client.UpdateNotetaker(context.Background(), "grant-1", "nt-1", nil) + assert.Error(t, err) + }) + + t.Run("empty request (no fields)", func(t *testing.T) { + _, err := client.UpdateNotetaker(context.Background(), "grant-1", "nt-1", &domain.UpdateNotetakerRequest{}) + assert.Error(t, err) + }) +} diff --git a/internal/adapters/nylas/resources.go b/internal/adapters/nylas/resources.go new file mode 100644 index 0000000..b00ed88 --- /dev/null +++ b/internal/adapters/nylas/resources.go @@ -0,0 +1,28 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" +) + +// ListRoomResources retrieves the bookable room and equipment resources a grant +// has access to (GET /v3/grants/{id}/resources). +func (c *HTTPClient) ListRoomResources(ctx context.Context, grantID string) ([]domain.RoomResource, error) { + if err := validateRequired("grant ID", grantID); err != nil { + return nil, err + } + + queryURL := fmt.Sprintf("%s/v3/grants/%s/resources", c.baseURL, url.PathEscape(grantID)) + + var result struct { + Data []domain.RoomResource `json:"data"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + return result.Data, nil +} diff --git a/internal/adapters/nylas/resources_test.go b/internal/adapters/nylas/resources_test.go new file mode 100644 index 0000000..d8be012 --- /dev/null +++ b/internal/adapters/nylas/resources_test.go @@ -0,0 +1,82 @@ +//go:build !integration +// +build !integration + +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClient_ListRoomResources(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Pin the contract: room resources are a grant-scoped GET on /resources. + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v3/grants/grant-123/resources", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "request_id": "req-1", + "data": []map[string]any{ + { + "object": "room_resource", + "email": "boardroom@example.com", + "name": "Boardroom", + "capacity": 20, + "building": "HQ", + "floor_name": "5", + "floor_number": 5, + }, + }, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + resources, err := client.ListRoomResources(context.Background(), "grant-123") + + require.NoError(t, err) + require.Len(t, resources, 1) + // The email is what callers reuse as a calendar ID, so it must survive intact. + assert.Equal(t, "boardroom@example.com", resources[0].Email) + assert.Equal(t, "Boardroom", resources[0].Name) + assert.Equal(t, 20, resources[0].Capacity) + assert.Equal(t, "HQ", resources[0].Building) + assert.Equal(t, "5", resources[0].FloorName) + assert.Equal(t, 5, resources[0].FloorNumber) +} + +func TestHTTPClient_ListRoomResources_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + resources, err := client.ListRoomResources(context.Background(), "grant-123") + + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestHTTPClient_ListRoomResources_RequiresGrant(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + _, err := client.ListRoomResources(context.Background(), "") + assert.Error(t, err) +} diff --git a/internal/adapters/nylas/scheduler_group_events.go b/internal/adapters/nylas/scheduler_group_events.go new file mode 100644 index 0000000..6def29f --- /dev/null +++ b/internal/adapters/nylas/scheduler_group_events.go @@ -0,0 +1,145 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" +) + +// groupEventsBaseURL builds the grant + configuration scoped group-events URL. +func (c *HTTPClient) groupEventsBaseURL(grantID, configID string) string { + return fmt.Sprintf("%s/v3/grants/%s/scheduling/configurations/%s/group-events", + c.baseURL, url.PathEscape(grantID), url.PathEscape(configID)) +} + +// ListGroupEvents retrieves the group events for a configuration within a time +// window. The API requires calendar_id, start_time, and end_time query params. +func (c *HTTPClient) ListGroupEvents(ctx context.Context, grantID, configID, calendarID string, startTime, endTime int64) ([]domain.GroupEvent, error) { + if err := validateRequired("grant ID", grantID); err != nil { + return nil, err + } + if err := validateRequired("configuration ID", configID); err != nil { + return nil, err + } + if err := validateRequired("calendar ID", calendarID); err != nil { + return nil, err + } + if startTime <= 0 || endTime <= 0 { + return nil, fmt.Errorf("%w: start and end time are required to list group events", domain.ErrInvalidInput) + } + + queryURL := NewQueryBuilder(). + Add("calendar_id", calendarID). + AddInt64("start_time", startTime). + AddInt64("end_time", endTime). + BuildURL(c.groupEventsBaseURL(grantID, configID)) + + var result struct { + Data []domain.GroupEvent `json:"data"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + return result.Data, nil +} + +// CreateGroupEvent creates a group event under a configuration. +func (c *HTTPClient) CreateGroupEvent(ctx context.Context, grantID, configID string, req *domain.CreateGroupEventRequest) ([]domain.GroupEvent, error) { + if err := validateRequired("grant ID", grantID); err != nil { + return nil, err + } + if err := validateRequired("configuration ID", configID); err != nil { + return nil, err + } + if req == nil { + return nil, fmt.Errorf("%w: group event request cannot be nil", domain.ErrInvalidInput) + } + + resp, err := c.doJSONRequest(ctx, "POST", c.groupEventsBaseURL(grantID, configID), req) + if err != nil { + return nil, err + } + + var result struct { + Data []domain.GroupEvent `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + return result.Data, nil +} + +// UpdateGroupEvent updates a group event. +func (c *HTTPClient) UpdateGroupEvent(ctx context.Context, grantID, configID, eventID string, req *domain.UpdateGroupEventRequest) ([]domain.GroupEvent, error) { + if err := validateRequired("grant ID", grantID); err != nil { + return nil, err + } + if err := validateRequired("configuration ID", configID); err != nil { + return nil, err + } + if err := validateRequired("event ID", eventID); err != nil { + return nil, err + } + if req == nil { + return nil, fmt.Errorf("%w: group event request cannot be nil", domain.ErrInvalidInput) + } + + queryURL := fmt.Sprintf("%s/%s", c.groupEventsBaseURL(grantID, configID), url.PathEscape(eventID)) + resp, err := c.doJSONRequest(ctx, "PUT", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data []domain.GroupEvent `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + return result.Data, nil +} + +// DeleteGroupEvent deletes a group event. +func (c *HTTPClient) DeleteGroupEvent(ctx context.Context, grantID, configID, eventID string) error { + if err := validateRequired("grant ID", grantID); err != nil { + return err + } + if err := validateRequired("configuration ID", configID); err != nil { + return err + } + if err := validateRequired("event ID", eventID); err != nil { + return err + } + + queryURL := fmt.Sprintf("%s/%s", c.groupEventsBaseURL(grantID, configID), url.PathEscape(eventID)) + return c.doDelete(ctx, queryURL) +} + +// ImportGroupEvents imports existing provider events as group events. This +// endpoint is configuration-scoped (not grant-scoped). +func (c *HTTPClient) ImportGroupEvents(ctx context.Context, configID string, items []domain.ImportGroupEventItem) ([]domain.GroupEvent, error) { + if err := validateRequired("configuration ID", configID); err != nil { + return nil, err + } + if len(items) == 0 { + return nil, fmt.Errorf("%w: at least one event to import is required", domain.ErrInvalidInput) + } + + queryURL := fmt.Sprintf("%s/v3/scheduling/configurations/%s/import-group-events", c.baseURL, url.PathEscape(configID)) + // Per the OpenAPI spec (requests/scheduling/import_group_event.yaml), the + // request body is a bare JSON array of import items, not a wrapped object. + resp, err := c.doJSONRequest(ctx, "POST", queryURL, items) + if err != nil { + return nil, err + } + + var result struct { + Data []domain.GroupEvent `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + return result.Data, nil +} diff --git a/internal/adapters/nylas/scheduler_group_events_test.go b/internal/adapters/nylas/scheduler_group_events_test.go new file mode 100644 index 0000000..565ffa0 --- /dev/null +++ b/internal/adapters/nylas/scheduler_group_events_test.go @@ -0,0 +1,191 @@ +//go:build !integration +// +build !integration + +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newGroupEventTestClient(t *testing.T, handler http.HandlerFunc) (*nylas.HTTPClient, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + return client, server.Close +} + +func TestHTTPClient_ListGroupEvents(t *testing.T) { + var gotQuery string + client, closeFn := newGroupEventTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + // Group events are nested under grant + configuration. + assert.Equal(t, "/v3/grants/grant-1/scheduling/configurations/cfg-1/group-events", r.URL.Path) + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{{"id": "ge-1", "title": "Workshop", "capacity": 50}}, + }) + }) + defer closeFn() + + events, err := client.ListGroupEvents(context.Background(), "grant-1", "cfg-1", "primary", 1735689600, 1738368000) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "ge-1", events[0].ID) + assert.Equal(t, "Workshop", events[0].Title) + // The list endpoint requires calendar_id + start_time + end_time; all must + // be forwarded as query params (the API 400s without them). + assert.Contains(t, gotQuery, "calendar_id=primary") + assert.Contains(t, gotQuery, "start_time=1735689600") + assert.Contains(t, gotQuery, "end_time=1738368000") +} + +func TestHTTPClient_CreateGroupEvent(t *testing.T) { + var body map[string]any + client, closeFn := newGroupEventTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v3/grants/grant-1/scheduling/configurations/cfg-1/group-events", r.URL.Path) + _ = json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{{"id": "ge-new", "title": "Workshop"}}, + }) + }) + defer closeFn() + + req := &domain.CreateGroupEventRequest{ + CalendarID: "primary", + Title: "Workshop", + Capacity: 50, + Participants: []domain.GroupEventParticipant{{Email: "nyla@example.com", IsOrganizer: true}}, + When: &domain.GroupEventWhen{StartTime: 1744286400, EndTime: 1744290000, StartTimezone: "America/New_York"}, + } + events, err := client.CreateGroupEvent(context.Background(), "grant-1", "cfg-1", req) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "ge-new", events[0].ID) + + // Required fields and the nested when object must reach the wire. + assert.Equal(t, "primary", body["calendar_id"]) + assert.EqualValues(t, 50, body["capacity"]) + when, ok := body["when"].(map[string]any) + require.True(t, ok, "when must be a nested object") + assert.EqualValues(t, 1744286400, when["start_time"]) + + // Participants must serialize as an array of objects carrying email and the + // organizer flag — not be flattened or dropped. + parts, ok := body["participants"].([]any) + require.True(t, ok, "participants must be a JSON array") + require.Len(t, parts, 1) + p0, ok := parts[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "nyla@example.com", p0["email"]) + assert.Equal(t, true, p0["is_organizer"]) +} + +func TestHTTPClient_CreateGroupEvent_OmitsNilParticipants(t *testing.T) { + var body map[string]any + client, closeFn := newGroupEventTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": "ge"}}}) + }) + defer closeFn() + + // No participants supplied: the field must be OMITTED (so the API falls back + // to the organizer), never sent as participants:null. + _, err := client.CreateGroupEvent(context.Background(), "grant-1", "cfg-1", &domain.CreateGroupEventRequest{ + CalendarID: "primary", Title: "Solo", Capacity: 10, + When: &domain.GroupEventWhen{StartTime: 1, EndTime: 2}, + }) + require.NoError(t, err) + assert.NotContains(t, body, "participants", "nil participants must be omitted, not sent as null") +} + +func TestHTTPClient_UpdateGroupEvent(t *testing.T) { + var body map[string]any + client, closeFn := newGroupEventTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/v3/grants/grant-1/scheduling/configurations/cfg-1/group-events/ge-1", r.URL.Path) + _ = json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": "ge-1", "title": "New Title"}}}) + }) + defer closeFn() + + events, err := client.UpdateGroupEvent(context.Background(), "grant-1", "cfg-1", "ge-1", &domain.UpdateGroupEventRequest{ + Title: "New Title", + Capacity: 80, + }) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "New Title", events[0].Title) + // The changed fields must actually be in the PUT body. + assert.Equal(t, "New Title", body["title"]) + assert.EqualValues(t, 80, body["capacity"]) +} + +func TestHTTPClient_DeleteGroupEvent(t *testing.T) { + client, closeFn := newGroupEventTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/v3/grants/grant-1/scheduling/configurations/cfg-1/group-events/ge-1", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"request_id":"r"}`)) + }) + defer closeFn() + + err := client.DeleteGroupEvent(context.Background(), "grant-1", "cfg-1", "ge-1") + assert.NoError(t, err) +} + +func TestHTTPClient_ImportGroupEvents(t *testing.T) { + var body []map[string]any + client, closeFn := newGroupEventTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + // Import is configuration-scoped, NOT grant-scoped. + assert.Equal(t, "/v3/scheduling/configurations/cfg-1/import-group-events", r.URL.Path) + _ = json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": "ge-imp"}}}) + }) + defer closeFn() + + events, err := client.ImportGroupEvents(context.Background(), "cfg-1", []domain.ImportGroupEventItem{ + {CalendarID: "primary", EventID: "evt-9", Capacity: 30}, + }) + require.NoError(t, err) + require.Len(t, events, 1) + // Body must be a JSON array carrying the import item's required fields. + require.Len(t, body, 1) + assert.Equal(t, "primary", body[0]["calendar_id"]) + assert.Equal(t, "evt-9", body[0]["event_id"]) +} + +func TestHTTPClient_GroupEvents_Validation(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + _, err := client.ListGroupEvents(context.Background(), "", "cfg-1", "primary", 1, 2) + assert.Error(t, err, "missing grant") + _, err = client.ListGroupEvents(context.Background(), "grant-1", "", "primary", 1, 2) + assert.Error(t, err, "missing config") + _, err = client.ListGroupEvents(context.Background(), "grant-1", "cfg-1", "", 1, 2) + assert.Error(t, err, "missing calendar") + _, err = client.ListGroupEvents(context.Background(), "grant-1", "cfg-1", "primary", 0, 0) + assert.Error(t, err, "missing time window") + err = client.DeleteGroupEvent(context.Background(), "grant-1", "cfg-1", "") + assert.Error(t, err, "missing event id") + _, err = client.ImportGroupEvents(context.Background(), "cfg-1", nil) + assert.Error(t, err, "no items") +} diff --git a/internal/cli/calendar/calendar.go b/internal/cli/calendar/calendar.go index 3be5f85..ea663ac 100644 --- a/internal/cli/calendar/calendar.go +++ b/internal/cli/calendar/calendar.go @@ -32,6 +32,7 @@ API reference: https://developer.nylas.com/docs/v3/calendar/`, cmd.AddCommand(newDeleteCmd()) cmd.AddCommand(newEventsCmd()) cmd.AddCommand(newAvailabilityCmd()) + cmd.AddCommand(newResourcesCmd()) cmd.AddCommand(newVirtualCmd()) cmd.AddCommand(newRecurringCmd()) cmd.AddCommand(newFindTimeCmd()) diff --git a/internal/cli/calendar/events.go b/internal/cli/calendar/events.go index b2facf8..3dfe8ec 100644 --- a/internal/cli/calendar/events.go +++ b/internal/cli/calendar/events.go @@ -20,6 +20,7 @@ API reference: https://developer.nylas.com/docs/reference/api/events/`, cmd.AddCommand(newEventsUpdateCmd()) cmd.AddCommand(newEventsDeleteCmd()) cmd.AddCommand(newEventsRSVPCmd()) + cmd.AddCommand(newEventsImportCmd()) return cmd } diff --git a/internal/cli/calendar/events_import.go b/internal/cli/calendar/events_import.go new file mode 100644 index 0000000..7d65921 --- /dev/null +++ b/internal/cli/calendar/events_import.go @@ -0,0 +1,124 @@ +package calendar + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newEventsImportCmd() *cobra.Command { + var ( + calendarID string + startStr string + endStr string + limit int + ) + + cmd := &cobra.Command{ + Use: "import [grant-id]", + Short: "Bulk-export events from a calendar (for migration/backup)", + Long: `Bulk-read events from a calendar over a time window. + +Unlike "events list", import is built for migration and backup: it reads events +directly from the provider (including expanded recurring instances) for the +given window. A calendar is required. When --start/--end are omitted the API +defaults to now through one month ahead. + +Use --json to capture the full event data for export or syncing. A single call +returns up to --limit events (max 500); raise --limit to export more. + +API reference: https://developer.nylas.com/docs/v3/calendar/`, + Example: ` # Export a year of events from the primary calendar as JSON + nylas calendar events import --calendar primary \ + --start 2026-01-01 --end 2026-12-31 --json + + # Export from a specific calendar + nylas calendar events import --calendar --limit 200`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + start, err := parseImportTime("start", startStr) + if err != nil { + return err + } + end, err := parseImportTime("end", endStr) + if err != nil { + return err + } + + _, err = common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + calID, err := GetDefaultCalendarID(ctx, client, grantID, calendarID, false) + if err != nil { + return struct{}{}, err + } + + params := &domain.EventQueryParams{ + CalendarID: calID, + Limit: limit, + Start: start, + End: end, + } + + events, err := client.ImportEvents(ctx, grantID, params) + if err != nil { + return struct{}{}, common.WrapListError("imported events", err) + } + + if common.IsStructuredOutput(cmd) { + out := common.GetOutputWriter(cmd) + return struct{}{}, out.Write(events) + } + + if len(events) == 0 { + common.PrintEmptyState("events") + return struct{}{}, nil + } + + fmt.Printf("Imported %d event(s) from %s:\n\n", len(events), calID) + for _, event := range events { + title := event.Title + if title == "" { + title = "(no title)" + } + fmt.Printf("%s\n", common.Cyan.Sprint(title)) + fmt.Printf(" %s %s\n", common.Dim.Sprint("When:"), formatEventTime(event.When)) + fmt.Printf(" %s %s\n\n", common.Dim.Sprint("ID:"), common.Dim.Sprint(event.ID)) + } + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVarP(&calendarID, "calendar", "c", "", "Calendar ID to import from (defaults to primary)") + cmd.Flags().StringVar(&startStr, "start", "", "Start of the window (YYYY-MM-DD, 'YYYY-MM-DD HH:MM', or Unix). Defaults to now.") + cmd.Flags().StringVar(&endStr, "end", "", "End of the window (YYYY-MM-DD, 'YYYY-MM-DD HH:MM', or Unix). Defaults to +1 month.") + cmd.Flags().IntVarP(&limit, "limit", "n", 50, "Maximum number of events to import (max 500)") + + return cmd +} + +// parseImportTime parses an import window bound into a Unix timestamp. An empty +// value returns 0 so the API default applies. +func parseImportTime(name, value string) (int64, error) { + if value == "" { + return 0, nil + } + if ts, err := strconv.ParseInt(value, 10, 64); err == nil && ts > 1000000000 { + return ts, nil + } + for _, layout := range []string{"2006-01-02 15:04", "2006-01-02", time.RFC3339} { + if t, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return t.Unix(), nil + } + } + return 0, common.NewUserError( + fmt.Sprintf("could not parse --%s value %q", name, value), + "Use YYYY-MM-DD, 'YYYY-MM-DD HH:MM', or a Unix timestamp.", + ) +} diff --git a/internal/cli/calendar/events_import_test.go b/internal/cli/calendar/events_import_test.go new file mode 100644 index 0000000..8862c91 --- /dev/null +++ b/internal/cli/calendar/events_import_test.go @@ -0,0 +1,53 @@ +package calendar + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventsImportCommand_Structure(t *testing.T) { + cmd := newEventsImportCmd() + + assert.Equal(t, "import [grant-id]", cmd.Use) + for _, flag := range []string{"calendar", "start", "end", "limit"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing --%s flag", flag) + } +} + +func TestEventsCmd_RegistersImport(t *testing.T) { + cmd := newEventsCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["import"], "events command must register the import subcommand") +} + +func TestParseImportTime(t *testing.T) { + tests := []struct { + name string + input string + want int64 + wantErr bool + }{ + {name: "empty defers to API default", input: "", want: 0}, + {name: "unix timestamp passes through", input: "1735689600", want: 1735689600}, + {name: "date only", input: "2025-01-01", want: time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local).Unix()}, + {name: "invalid", input: "not-a-date", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseImportTime("start", tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/cli/calendar/resources.go b/internal/cli/calendar/resources.go new file mode 100644 index 0000000..96f3df3 --- /dev/null +++ b/internal/cli/calendar/resources.go @@ -0,0 +1,74 @@ +package calendar + +import ( + "context" + "fmt" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newResourcesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "resources [grant-id]", + Aliases: []string{"rooms"}, + Short: "List bookable room and equipment resources", + Long: `List the room and equipment resources you can book for meetings. + +Each resource's email address doubles as a calendar ID, so you can add it as a +participant when creating events or pass it to availability/free-busy checks. + +API reference: https://developer.nylas.com/docs/v3/calendar/`, + Example: ` # List bookable rooms + nylas calendar resources + + # JSON output + nylas calendar resources --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resources, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) ([]domain.RoomResource, error) { + return client.ListRoomResources(ctx, grantID) + }) + if err != nil { + return common.WrapListError("room resources", err) + } + + if len(resources) == 0 { + if !common.IsStructuredOutput(cmd) { + common.PrintEmptyState("room resources") + } + return nil + } + + if common.IsStructuredOutput(cmd) { + out := common.GetOutputWriter(cmd) + return out.Write(resources) + } + + if !common.IsQuiet() { + fmt.Printf("Found %d room resource(s):\n\n", len(resources)) + } + + normalCols := []ports.Column{ + {Header: "Name", Field: "Name", Width: 24}, + {Header: "Email", Field: "Email", Width: 36}, + {Header: "Capacity", Field: "Capacity"}, + {Header: "Building", Field: "Building"}, + {Header: "Floor", Field: "FloorName"}, + } + wideCols := []ports.Column{ + {Header: "Name", Field: "Name"}, + {Header: "Email", Field: "Email", Width: -1}, + {Header: "Capacity", Field: "Capacity"}, + {Header: "Building", Field: "Building"}, + {Header: "Floor", Field: "FloorName"}, + } + + return common.WriteListWithWideColumns(cmd, resources, normalCols, wideCols) + }, + } + + return cmd +} diff --git a/internal/cli/calendar/resources_cmd_test.go b/internal/cli/calendar/resources_cmd_test.go new file mode 100644 index 0000000..9d0ec4c --- /dev/null +++ b/internal/cli/calendar/resources_cmd_test.go @@ -0,0 +1,34 @@ +package calendar + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResourcesCommand(t *testing.T) { + cmd := newResourcesCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "resources [grant-id]", cmd.Use) + }) + + t.Run("has_rooms_alias", func(t *testing.T) { + assert.Contains(t, cmd.Aliases, "rooms") + }) + + t.Run("explains_email_as_calendar_id", func(t *testing.T) { + // Callers reuse a resource's email as a calendar ID; the help must + // surface that so the command is actually useful. + assert.Contains(t, cmd.Long, "calendar ID") + }) +} + +func TestCalendarCmd_RegistersResources(t *testing.T) { + cmd := NewCalendarCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["resources"], "calendar command must register the resources subcommand") +} diff --git a/internal/cli/common/html.go b/internal/cli/common/html.go index f3bf627..59ef07e 100644 --- a/internal/cli/common/html.go +++ b/internal/cli/common/html.go @@ -2,9 +2,23 @@ package common import ( "html" + "regexp" "strings" ) +// blockTagRe matches opening, closing, and self-closing block-level tags — +// with or without attributes — so they can be turned into newlines before the +// generic tag stripper runs. The optional `(?:\s[^>]*)?` consumes any +// attributes, which is what bare-string matching missed: a tag like +//
or

would otherwise be stripped with no +// separator, silently joining adjacent lines. +// +// Table cell elements (table, td, th, tbody, thead, tfoot) are intentionally +// excluded because they're typically layout; tr is included to separate rows. +// The `\s`/`/`/`>` boundary after the tag name prevents over-matching names +// that merely share a prefix (e.g.

 must not match 

). +var blockTagRe = regexp.MustCompile(`(?i)]*)?/?>`) + // StripHTML removes HTML tags from a string and decodes HTML entities. func StripHTML(s string) string { // Remove style and script tags and their contents @@ -12,20 +26,9 @@ func StripHTML(s string) string { s = RemoveTagWithContent(s, "script") s = RemoveTagWithContent(s, "head") - // Replace block-level elements with newlines before stripping tags - // Note: table cell elements (table, td, th, tbody, thead, tfoot) are NOT included - // because they're typically used for layout; tr is included to separate rows - blockTags := []string{"br", "p", "div", "tr", "li", "h1", "h2", "h3", "h4", "h5", "h6"} - for _, tag := range blockTags { - // Handle
,
,
- s = strings.ReplaceAll(s, "<"+tag+">", "\n") - s = strings.ReplaceAll(s, "<"+tag+"/>", "\n") - s = strings.ReplaceAll(s, "<"+tag+" />", "\n") - s = strings.ReplaceAll(s, "", "\n") - // Case insensitive - s = strings.ReplaceAll(s, "<"+strings.ToUpper(tag)+">", "\n") - s = strings.ReplaceAll(s, "", "\n") - } + // Replace block-level elements (including attributed/self-closing forms) + // with newlines before stripping the remaining tags. + s = blockTagRe.ReplaceAllString(s, "\n") // Strip remaining HTML tags var result strings.Builder diff --git a/internal/cli/common/html_test.go b/internal/cli/common/html_test.go new file mode 100644 index 0000000..0b507c9 --- /dev/null +++ b/internal/cli/common/html_test.go @@ -0,0 +1,66 @@ +package common + +import "testing" + +// TestStripHTML_BlockTagsWithAttributes pins the fix for block/void tags that +// carry attributes. The original implementation only converted *bare* block +// tags (

,
) to newlines; a tag with attributes (
, +//

) fell through to the generic tag stripper and was removed +// with no separator, silently joining adjacent lines (e.g. "Line1Line2"). +// +// Real-world HTML email (Gmail, Outlook) almost always emits attributed tags, +// so this is the common case, not an edge case. +func TestStripHTML_BlockTagsWithAttributes(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "self-closing br with attributes still breaks the line", + input: `Line 1
Line 2`, + expected: "Line 1\nLine 2", + }, + { + name: "void br with attributes and no slash", + input: `Line 1
Line 2`, + expected: "Line 1\nLine 2", + }, + { + name: "paragraphs with attributes separate like bare paragraphs", + input: `

Para 1

Para 2

`, + expected: "Para 1\n\nPara 2", + }, + { + name: "div with style attribute separates blocks", + input: `
Block 1
Block 2
`, + expected: "Block 1\n\nBlock 2", + }, + { + name: "uppercase tag with attributes", + input: `Line 1
Line 2`, + expected: "Line 1\nLine 2", + }, + { + name: "list items with attributes", + input: `
  • Item 1
  • Item 2
`, + expected: "Item 1\n\nItem 2", + }, + { + // Guard against over-matching: a non-block tag whose name merely + // starts with a block tag's letter (pre vs p) must NOT be treated + // as a block separator. + name: "non-block tag sharing a prefix is not a block separator", + input: `
code stays inline
`, + expected: "code stays inline", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StripHTML(tt.input); got != tt.expected { + t.Errorf("StripHTML(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/internal/cli/email/clean.go b/internal/cli/email/clean.go new file mode 100644 index 0000000..68c99ce --- /dev/null +++ b/internal/cli/email/clean.go @@ -0,0 +1,116 @@ +package email + +import ( + "context" + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newCleanCmd() *cobra.Command { + var ( + grantID string + keepLinks bool + keepImages bool + keepTables bool + imagesAsMarkdown bool + keepSignatures bool + ) + + cmd := &cobra.Command{ + Use: "clean [message-id...]", + Short: "Strip quoted replies and signatures from messages", + Long: `Parse one or more messages into clean, display-ready text. + +The clean endpoint removes quoted reply/forward chains, signatures, and +conclusion phrases ("Best", "Regards"), returning just the meaningful message +text. Handy for piping a clean message body into AI tools or scripts. + +By default links, images, tables, and signature phrases are stripped. Pass the +--keep-* flags to retain them. Up to 20 message IDs may be cleaned in one call. + +Plain-text output has HTML tags stripped for readability; use --json to get the +raw cleaned HTML body in the "conversation" field. + +API reference: https://developer.nylas.com/docs/v3/email/clean-conversation/`, + Example: ` # Clean a single message + nylas email clean + + # Clean several messages, keep links + nylas email clean --keep-links + + # JSON output for scripting (raw cleaned HTML) + nylas email clean --json`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > domain.CleanMessagesMaxIDs { + return fmt.Errorf("clean accepts at most %d message IDs (got %d)", domain.CleanMessagesMaxIDs, len(args)) + } + + req := &domain.CleanMessagesRequest{MessageIDs: args} + // Only send options that deviate from the API defaults (which strip + // links/images/tables/signatures); leave the rest unset. + no, yes := false, true + if keepLinks { + req.IgnoreLinks = &no + } + if keepImages { + req.IgnoreImages = &no + } + if keepTables { + req.IgnoreTables = &no + } + if imagesAsMarkdown { + req.ImagesAsMarkdown = &yes + } + if keepSignatures { + req.RemoveConclusionPhrases = &no + } + + // Message IDs are variadic positionals, so the grant can't ride the + // usual trailing [grant-id] arg — it comes from --grant (or the + // active grant when empty). + cleaned, err := common.WithClient([]string{grantID}, func(ctx context.Context, client ports.NylasClient, gid string) ([]domain.CleanedMessage, error) { + return client.CleanMessages(ctx, gid, req) + }) + if err != nil { + return common.WrapListError("cleaned messages", err) + } + + if common.IsStructuredOutput(cmd) { + out := common.GetOutputWriter(cmd) + return out.Write(cleaned) + } + + if len(cleaned) == 0 { + common.PrintEmptyState("cleaned messages") + return nil + } + + out := cmd.OutOrStdout() + for i, msg := range cleaned { + if i > 0 { + _, _ = fmt.Fprintln(out) + } + if len(cleaned) > 1 { + _, _ = fmt.Fprintf(out, "── %s ──\n", msg.ID) + } + _, _ = fmt.Fprintln(out, strings.TrimSpace(common.StripHTML(msg.Conversation))) + } + return nil + }, + } + + cmd.Flags().StringVarP(&grantID, "grant", "g", "", "Grant ID or email (defaults to the active grant)") + cmd.Flags().BoolVar(&keepLinks, "keep-links", false, "Keep links instead of stripping them") + cmd.Flags().BoolVar(&keepImages, "keep-images", false, "Keep images instead of stripping them") + cmd.Flags().BoolVar(&keepTables, "keep-tables", false, "Keep tables instead of stripping them") + cmd.Flags().BoolVar(&imagesAsMarkdown, "images-as-markdown", false, "Return images as Markdown links") + cmd.Flags().BoolVar(&keepSignatures, "keep-signatures", false, `Keep conclusion phrases like "Best" and "Regards"`) + + return cmd +} diff --git a/internal/cli/email/clean_test.go b/internal/cli/email/clean_test.go new file mode 100644 index 0000000..f8ad0a4 --- /dev/null +++ b/internal/cli/email/clean_test.go @@ -0,0 +1,43 @@ +package email + +import ( + "fmt" + "testing" + + "github.com/nylas/cli/internal/cli/testutil" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" +) + +func TestCleanCommand_Structure(t *testing.T) { + cmd := newCleanCmd() + + assert.Equal(t, "clean [message-id...]", cmd.Use) + for _, flag := range []string{"grant", "keep-links", "keep-images", "keep-tables", "images-as-markdown", "keep-signatures"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing --%s flag", flag) + } +} + +// The CLI must reject more than the API maximum before making a request, so the +// user gets a clear local error instead of an opaque 4xx. +func TestCleanCommand_RejectsTooManyIDs(t *testing.T) { + cmd := newCleanCmd() + + args := make([]string, domain.CleanMessagesMaxIDs+1) + for i := range args { + args[i] = fmt.Sprintf("msg-%d", i) + } + + _, _, err := testutil.ExecuteCommand(cmd, args...) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at most") +} + +func TestEmailCmd_RegistersClean(t *testing.T) { + cmd := NewEmailCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["clean"], "email command must register the clean subcommand") +} diff --git a/internal/cli/email/email.go b/internal/cli/email/email.go index 339774c..0afdd79 100644 --- a/internal/cli/email/email.go +++ b/internal/cli/email/email.go @@ -21,6 +21,8 @@ API reference: https://developer.nylas.com/docs/v3/email/`, cmd.AddCommand(newReplyCmd()) cmd.AddCommand(newSearchCmd()) cmd.AddCommand(newMarkCmd()) + cmd.AddCommand(newMoveCmd()) + cmd.AddCommand(newCleanCmd()) cmd.AddCommand(newDeleteCmd()) cmd.AddCommand(newFoldersCmd()) cmd.AddCommand(newThreadsCmd()) diff --git a/internal/cli/email/move.go b/internal/cli/email/move.go new file mode 100644 index 0000000..d7ef0aa --- /dev/null +++ b/internal/cli/email/move.go @@ -0,0 +1,76 @@ +package email + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newMoveCmd() *cobra.Command { + var ( + folder string + archive bool + ) + + cmd := &cobra.Command{ + Use: "move [grant-id]", + Short: "Move a message to a folder (or archive it)", + Long: `Move a message to a different folder, or archive it. + +Pass --folder with a folder ID to move the message into that folder. Use the +"nylas email folders list" command to find folder IDs. + +Pass --archive to archive the message instead. On Gmail/label-based accounts +this removes all labels (including INBOX); on folder-based (IMAP/Microsoft) +accounts the provider moves the message to its Archive folder. + +API reference: https://developer.nylas.com/docs/v3/email/`, + Example: ` # Move a message to a folder + nylas email move --folder + + # Archive a message + nylas email move --archive`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + if folder != "" && archive { + return common.NewMutuallyExclusiveError("folder", "archive") + } + if folder == "" && !archive { + return common.NewUserError( + "no destination specified", + "Pass --folder to move the message, or --archive to archive it.", + ) + } + + messageID := args[0] + // nil leaves folders untouched; a non-nil slice (even empty) sets + // them. --archive sends an empty slice to clear all folders/labels. + folders := []string{} + if folder != "" { + folders = []string{folder} + } + + _, err := common.WithClient(args[1:], func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + req := &domain.UpdateMessageRequest{Folders: folders} + if _, err := client.UpdateMessage(ctx, grantID, messageID, req); err != nil { + return struct{}{}, common.WrapUpdateError("message", err) + } + if archive { + common.PrintSuccess("Message archived") + } else { + common.PrintSuccess("Message moved") + } + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVar(&folder, "folder", "", "Destination folder ID") + cmd.Flags().BoolVar(&archive, "archive", false, "Archive the message (clear all folders/labels)") + + return cmd +} diff --git a/internal/cli/email/move_test.go b/internal/cli/email/move_test.go new file mode 100644 index 0000000..804d1cc --- /dev/null +++ b/internal/cli/email/move_test.go @@ -0,0 +1,45 @@ +package email + +import ( + "testing" + + "github.com/nylas/cli/internal/cli/testutil" + "github.com/stretchr/testify/assert" +) + +func TestMoveCommand_Structure(t *testing.T) { + cmd := newMoveCmd() + + assert.Equal(t, "move [grant-id]", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("folder")) + assert.NotNil(t, cmd.Flags().Lookup("archive")) +} + +// The guard checks run before any client call, so these exercise real intent +// without network access: --folder and --archive are mutually exclusive, and +// at least one destination is required. +func TestMoveCommand_RejectsFolderAndArchiveTogether(t *testing.T) { + cmd := newMoveCmd() + _, _, err := testutil.ExecuteCommand(cmd, "msg-1", "--folder", "F1", "--archive") + + require := assert.New(t) + require.Error(err) + require.Contains(err.Error(), "both") +} + +func TestMoveCommand_RequiresDestination(t *testing.T) { + cmd := newMoveCmd() + _, _, err := testutil.ExecuteCommand(cmd, "msg-1") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "destination") +} + +func TestEmailCmd_RegistersMove(t *testing.T) { + cmd := NewEmailCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["move"], "email command must register the move subcommand") +} diff --git a/internal/cli/integration/calendar_resources_test.go b/internal/cli/integration/calendar_resources_test.go new file mode 100644 index 0000000..a8956da --- /dev/null +++ b/internal/cli/integration/calendar_resources_test.go @@ -0,0 +1,52 @@ +//go:build integration + +package integration + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestCLI_CalendarResourcesHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("calendar", "resources", "--help") + if err != nil { + t.Fatalf("calendar resources --help failed: %v\nstderr: %s", err, stderr) + } + // The command's value proposition (email doubles as calendar ID) must surface. + if !strings.Contains(stdout, "calendar ID") { + t.Errorf("expected resources help to mention 'calendar ID', got: %s", stdout) + } +} + +// TestCalendarResources_Integration exercises the read-only room-resources +// endpoint against the live API. +func TestCalendarResources_Integration(t *testing.T) { + skipIfMissingCreds(t) + client := getTestClient() + acquireRateLimit(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resources, err := client.ListRoomResources(ctx, testGrantID) + if err != nil { + // Not every account/provider exposes room resources. + if isUnavailableErr(err) { + t.Skipf("room resources not available for this account: %v", err) + } + t.Fatalf("ListRoomResources() error = %v", err) + } + // A returned resource must carry the email that doubles as its calendar ID. + for _, r := range resources { + if r.Email == "" { + t.Errorf("room resource missing email (its calendar ID): %+v", r) + } + } + t.Logf("ListRoomResources returned %d resource(s)", len(resources)) +} diff --git a/internal/cli/integration/email_clean_move_test.go b/internal/cli/integration/email_clean_move_test.go new file mode 100644 index 0000000..2be0742 --- /dev/null +++ b/internal/cli/integration/email_clean_move_test.go @@ -0,0 +1,95 @@ +//go:build integration + +package integration + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestCLI_EmailCleanHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + stdout, stderr, err := runCLI("email", "clean", "--help") + if err != nil { + t.Fatalf("email clean --help failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "conversation") { + t.Errorf("expected email clean help to mention 'conversation', got: %s", stdout) + } +} + +func TestCLI_EmailMoveHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + stdout, stderr, err := runCLI("email", "move", "--help") + if err != nil { + t.Fatalf("email move --help failed: %v\nstderr: %s", err, stderr) + } + for _, want := range []string{"--folder", "--archive"} { + if !strings.Contains(stdout, want) { + t.Errorf("expected email move help to contain %q, got: %s", want, stdout) + } + } +} + +// TestCLI_EmailMoveGuards exercises the mutually-exclusive / required-destination +// guards through the real binary (these run before any API call). +func TestCLI_EmailMoveGuards(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + _, _, err := runCLI("email", "move", "msg-1", "--folder", "F1", "--archive") + if err == nil { + t.Error("expected error when both --folder and --archive are given") + } + + _, _, err = runCLI("email", "move", "msg-1") + if err == nil { + t.Error("expected error when neither --folder nor --archive is given") + } +} + +// TestEmailClean_Integration cleans a real message (read-only — clean returns +// parsed text without modifying the message). +func TestEmailClean_Integration(t *testing.T) { + skipIfMissingCreds(t) + client := getTestClient() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + acquireRateLimit(t) + messages, err := client.GetMessages(ctx, testGrantID, 1) + if err != nil { + t.Fatalf("GetMessages() error = %v", err) + } + if len(messages) == 0 { + t.Skip("no messages available to clean") + } + + acquireRateLimit(t) + cleaned, err := client.CleanMessages(ctx, testGrantID, &domain.CleanMessagesRequest{ + MessageIDs: []string{messages[0].ID}, + }) + if err != nil { + if isUnavailableErr(err) { + t.Skipf("clean messages not available for this account: %v", err) + } + t.Fatalf("CleanMessages() error = %v", err) + } + if len(cleaned) != 1 { + t.Fatalf("expected 1 cleaned message, got %d", len(cleaned)) + } + // The cleaned message must correspond to the one we asked for. + if cleaned[0].ID != messages[0].ID { + t.Errorf("cleaned message ID = %q, want %q", cleaned[0].ID, messages[0].ID) + } +} diff --git a/internal/cli/integration/events_import_test.go b/internal/cli/integration/events_import_test.go new file mode 100644 index 0000000..1e4c383 --- /dev/null +++ b/internal/cli/integration/events_import_test.go @@ -0,0 +1,76 @@ +//go:build integration + +package integration + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestCLI_CalendarEventsImportHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("calendar", "events", "import", "--help") + if err != nil { + t.Fatalf("calendar events import --help failed: %v\nstderr: %s", err, stderr) + } + for _, want := range []string{"--calendar", "--start", "--end"} { + if !strings.Contains(stdout, want) { + t.Errorf("expected events import help to contain %q, got: %s", want, stdout) + } + } +} + +// TestEventsImport_Integration exercises the read-only events/import endpoint +// against the live API for the primary calendar. +func TestEventsImport_Integration(t *testing.T) { + skipIfMissingCreds(t) + client := getTestClient() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Resolve the primary calendar. + acquireRateLimit(t) + calendars, err := client.GetCalendars(ctx, testGrantID) + if err != nil { + t.Fatalf("GetCalendars() error = %v", err) + } + calID := "primary" + for _, c := range calendars { + if c.IsPrimary { + calID = c.ID + break + } + } + + now := time.Now() + params := &domain.EventQueryParams{ + CalendarID: calID, + Start: now.AddDate(0, -1, 0).Unix(), + End: now.AddDate(0, 1, 0).Unix(), + Limit: 5, + } + + acquireRateLimit(t) + events, err := client.ImportEvents(ctx, testGrantID, params) + if err != nil { + if isUnavailableErr(err) { + t.Skipf("events import not available for this account: %v", err) + } + t.Fatalf("ImportEvents() error = %v", err) + } + // Imported events must belong to the calendar we asked for. + for _, e := range events { + if e.ID == "" { + t.Errorf("imported event has empty ID: %+v", e) + } + } + t.Logf("ImportEvents returned %d event(s) from %s", len(events), calID) +} diff --git a/internal/cli/integration/group_events_test.go b/internal/cli/integration/group_events_test.go new file mode 100644 index 0000000..b55fa5b --- /dev/null +++ b/internal/cli/integration/group_events_test.go @@ -0,0 +1,70 @@ +//go:build integration + +package integration + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestCLI_SchedulerGroupEventsHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + stdout, stderr, err := runCLI("scheduler", "group-events", "--help") + if err != nil { + t.Fatalf("scheduler group-events --help failed: %v\nstderr: %s", err, stderr) + } + for _, want := range []string{"list", "create", "update", "delete", "import"} { + if !strings.Contains(stdout, want) { + t.Errorf("expected group-events help to list subcommand %q, got: %s", want, stdout) + } + } +} + +// TestCLI_GroupEventsListGuard verifies the list command requires --calendar +// (the API needs calendar_id/start_time/end_time) before any API call. +func TestCLI_GroupEventsListGuard(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + _, _, err := runCLI("scheduler", "group-events", "list", "cfg-1") + if err == nil { + t.Error("expected error when --calendar is omitted from group-events list") + } +} + +// TestGroupEvents_Integration lists group events for an existing Scheduler +// configuration. Skips when no configuration is set up for the account. +func TestGroupEvents_Integration(t *testing.T) { + skipIfMissingCreds(t) + client := getTestClient() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + acquireRateLimit(t) + configs, err := client.ListSchedulerConfigurations(ctx) + if err != nil { + if isUnavailableErr(err) { + t.Skipf("scheduler not available for this account: %v", err) + } + t.Fatalf("ListSchedulerConfigurations() error = %v", err) + } + if len(configs) == 0 { + t.Skip("no scheduler configurations available to list group events") + } + + now := time.Now() + acquireRateLimit(t) + events, err := client.ListGroupEvents(ctx, testGrantID, configs[0].ID, "primary", now.Unix(), now.AddDate(0, 1, 0).Unix()) + if err != nil { + if isUnavailableErr(err) { + t.Skipf("group events not available for this configuration: %v", err) + } + t.Fatalf("ListGroupEvents() error = %v", err) + } + t.Logf("ListGroupEvents returned %d event(s) for config %s", len(events), configs[0].ID) +} diff --git a/internal/cli/integration/new_commands_helpers_test.go b/internal/cli/integration/new_commands_helpers_test.go new file mode 100644 index 0000000..2e419c0 --- /dev/null +++ b/internal/cli/integration/new_commands_helpers_test.go @@ -0,0 +1,21 @@ +//go:build integration + +package integration + +import "strings" + +// isUnavailableErr reports whether an API error means the feature/endpoint is +// simply not enabled for the test account (so the test should skip rather than +// fail). Mirrors the inline checks used by the existing notetaker tests. +func isUnavailableErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + for _, s := range []string{"not found", "forbidden", "not available", "not supported", "no access", "invalid_request"} { + if strings.Contains(msg, s) { + return true + } + } + return false +} diff --git a/internal/cli/integration/notetaker_lifecycle_test.go b/internal/cli/integration/notetaker_lifecycle_test.go new file mode 100644 index 0000000..d15585e --- /dev/null +++ b/internal/cli/integration/notetaker_lifecycle_test.go @@ -0,0 +1,83 @@ +//go:build integration + +package integration + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestCLI_NotetakerUpdateHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + stdout, stderr, err := runCLI("notetaker", "update", "--help") + if err != nil { + t.Fatalf("notetaker update --help failed: %v\nstderr: %s", err, stderr) + } + for _, want := range []string{"--join-time", "--bot-name", "--transcription"} { + if !strings.Contains(stdout, want) { + t.Errorf("expected notetaker update help to contain %q, got: %s", want, stdout) + } + } +} + +func TestCLI_NotetakerLeaveHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + stdout, stderr, err := runCLI("notetaker", "leave", "--help") + if err != nil { + t.Fatalf("notetaker leave --help failed: %v\nstderr: %s", err, stderr) + } + // Help must explain the leave-vs-delete distinction. + if !strings.Contains(stdout, "delete") { + t.Errorf("expected notetaker leave help to mention 'delete', got: %s", stdout) + } +} + +// TestNotetakerUpdate_Integration creates a scheduled notetaker, updates it, +// then deletes it. Skips gracefully when notetaker is not enabled. +func TestNotetakerUpdate_Integration(t *testing.T) { + skipIfMissingCreds(t) + client := getTestClient() + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + acquireRateLimit(t) + created, err := client.CreateNotetaker(ctx, testGrantID, &domain.CreateNotetakerRequest{ + MeetingLink: "https://zoom.us/j/123456789", + JoinTime: time.Now().Add(2 * time.Hour).Unix(), + }) + if err != nil { + if isUnavailableErr(err) { + t.Skipf("notetaker not available for this account: %v", err) + } + t.Fatalf("CreateNotetaker() error = %v", err) + } + // Always clean up the bot we created. + defer func() { + acquireRateLimit(t) + _ = client.DeleteNotetaker(context.Background(), testGrantID, created.ID) + }() + + newName := "Integration Recorder" + acquireRateLimit(t) + updated, err := client.UpdateNotetaker(ctx, testGrantID, created.ID, &domain.UpdateNotetakerRequest{ + Name: newName, + }) + if err != nil { + if isUnavailableErr(err) { + t.Skipf("notetaker update not available for this account: %v", err) + } + t.Fatalf("UpdateNotetaker() error = %v", err) + } + if updated.ID != created.ID { + t.Errorf("UpdateNotetaker returned ID %q, want %q", updated.ID, created.ID) + } +} diff --git a/internal/cli/notetaker/leave.go b/internal/cli/notetaker/leave.go new file mode 100644 index 0000000..5305fec --- /dev/null +++ b/internal/cli/notetaker/leave.go @@ -0,0 +1,40 @@ +package notetaker + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newLeaveCmd() *cobra.Command { + return &cobra.Command{ + Use: "leave [grant-id]", + Short: "Make an active notetaker leave its meeting", + Long: `Instruct an active notetaker bot to leave the meeting it is currently in. + +This stops the recording and triggers media (recording/transcript) generation, +while keeping the notetaker record and its media. Use this to cleanly end a live +recording. + +To cancel a scheduled bot before it joins, or to remove a notetaker and its +media entirely, use "nylas notetaker delete" instead. + +API reference: https://developer.nylas.com/docs/v3/notetaker/`, + Example: ` # Tell a notetaker to leave the meeting now + nylas notetaker leave `, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + notetakerID := args[0] + _, err := common.WithClient(args[1:], func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + if err := client.LeaveNotetaker(ctx, grantID, notetakerID); err != nil { + return struct{}{}, err + } + common.PrintSuccess("Notetaker instructed to leave the meeting") + return struct{}{}, nil + }) + return err + }, + } +} diff --git a/internal/cli/notetaker/leave_test.go b/internal/cli/notetaker/leave_test.go new file mode 100644 index 0000000..3ca5f46 --- /dev/null +++ b/internal/cli/notetaker/leave_test.go @@ -0,0 +1,40 @@ +package notetaker + +import ( + "testing" + + "github.com/nylas/cli/internal/cli/testutil" + "github.com/stretchr/testify/assert" +) + +func TestLeaveCommand(t *testing.T) { + cmd := newLeaveCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "leave [grant-id]", cmd.Use) + }) + + t.Run("describes the leave-vs-delete distinction", func(t *testing.T) { + // The whole reason this command exists is that "leave" keeps the + // recording while "delete" discards it — the help must say so. + assert.Contains(t, cmd.Long, "delete") + assert.Contains(t, cmd.Short, "leave") + }) +} + +func TestNotetakerCmd_RegistersLeave(t *testing.T) { + cmd := NewNotetakerCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["leave"], "notetaker command must register the leave subcommand") +} + +func TestNotetakerLeaveHelp(t *testing.T) { + root := testutil.NewTestRoot(NewNotetakerCmd()) + stdout, _, err := testutil.ExecuteCommand(root, "notetaker", "leave", "--help") + + assert.NoError(t, err) + assert.Contains(t, stdout, "leave") +} diff --git a/internal/cli/notetaker/notetaker.go b/internal/cli/notetaker/notetaker.go index 62af9fd..16b6015 100644 --- a/internal/cli/notetaker/notetaker.go +++ b/internal/cli/notetaker/notetaker.go @@ -40,6 +40,8 @@ API reference: https://developer.nylas.com/docs/v3/notetaker/`, cmd.AddCommand(newShowCmd()) cmd.AddCommand(newCreateCmd()) cmd.AddCommand(newDeleteCmd()) + cmd.AddCommand(newLeaveCmd()) + cmd.AddCommand(newUpdateCmd()) cmd.AddCommand(newMediaCmd()) return cmd diff --git a/internal/cli/notetaker/update.go b/internal/cli/notetaker/update.go new file mode 100644 index 0000000..9d787a0 --- /dev/null +++ b/internal/cli/notetaker/update.go @@ -0,0 +1,108 @@ +package notetaker + +import ( + "context" + "fmt" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newUpdateCmd() *cobra.Command { + var ( + joinTime string + botName string + videoRecording bool + audioRecording bool + transcription bool + ) + + cmd := &cobra.Command{ + Use: "update [grant-id]", + Short: "Update a scheduled notetaker", + Long: `Update a scheduled notetaker before it joins its meeting. + +You can change when the bot joins, its display name, and what it records +(video, audio, transcription). Only the options you pass are changed. + +This applies to notetakers that haven't joined yet (scheduled state). + +API reference: https://developer.nylas.com/docs/v3/notetaker/`, + Example: ` # Reschedule when the bot joins + nylas notetaker update --join-time "tomorrow 2pm" + + # Rename the bot and turn off video recording + nylas notetaker update --bot-name "Recorder" --video-recording=false`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + notetakerID := args[0] + + req := &domain.UpdateNotetakerRequest{} + if joinTime != "" { + parsedTime, err := parseJoinTime(joinTime) + if err != nil { + return common.WrapDateParseError("join time", err) + } + req.JoinTime = parsedTime.Unix() + } + if botName != "" { + req.Name = botName + } + + // Only include recording toggles the user explicitly set. + ms := &domain.NotetakerMeetingSettings{} + settingsChanged := false + if cmd.Flags().Changed("video-recording") { + ms.VideoRecording = &videoRecording + settingsChanged = true + } + if cmd.Flags().Changed("audio-recording") { + ms.AudioRecording = &audioRecording + settingsChanged = true + } + if cmd.Flags().Changed("transcription") { + ms.Transcription = &transcription + settingsChanged = true + } + if settingsChanged { + req.MeetingSettings = ms + } + + if req.JoinTime == 0 && req.Name == "" && !settingsChanged { + return common.NewUserError( + "nothing to update", + "Pass at least one of --join-time, --bot-name, --video-recording, --audio-recording, or --transcription.", + ) + } + + _, err := common.WithClient(args[1:], func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + notetaker, err := client.UpdateNotetaker(ctx, grantID, notetakerID, req) + if err != nil { + return struct{}{}, common.WrapUpdateError("notetaker", err) + } + + if common.IsJSON(cmd) { + return struct{}{}, common.PrintJSON(notetaker) + } + + common.PrintSuccess("Notetaker updated") + fmt.Printf("State: %s\n", formatState(notetaker.State)) + if !notetaker.JoinTime.IsZero() { + fmt.Printf("Join: %s\n", notetaker.JoinTime.Local().Format(common.DisplayWeekdayFullWithTZ)) + } + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVarP(&joinTime, "join-time", "j", "", "When to join (e.g., '2024-01-15 14:00', 'tomorrow 9am', '30m')") + cmd.Flags().StringVar(&botName, "bot-name", "", "New display name for the notetaker bot") + cmd.Flags().BoolVar(&videoRecording, "video-recording", false, "Record the meeting's video") + cmd.Flags().BoolVar(&audioRecording, "audio-recording", false, "Record the meeting's audio") + cmd.Flags().BoolVar(&transcription, "transcription", false, "Transcribe the meeting's audio") + + return cmd +} diff --git a/internal/cli/notetaker/update_test.go b/internal/cli/notetaker/update_test.go new file mode 100644 index 0000000..8462678 --- /dev/null +++ b/internal/cli/notetaker/update_test.go @@ -0,0 +1,36 @@ +package notetaker + +import ( + "testing" + + "github.com/nylas/cli/internal/cli/testutil" + "github.com/stretchr/testify/assert" +) + +func TestUpdateCommand_Structure(t *testing.T) { + cmd := newUpdateCmd() + + assert.Equal(t, "update [grant-id]", cmd.Use) + for _, flag := range []string{"join-time", "bot-name", "video-recording", "audio-recording", "transcription"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing --%s flag", flag) + } +} + +// With no flags set there is nothing to PATCH; the command must fail locally +// with a helpful message rather than send an empty update. +func TestUpdateCommand_RequiresAtLeastOneField(t *testing.T) { + cmd := newUpdateCmd() + _, _, err := testutil.ExecuteCommand(cmd, "nt-1") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "nothing to update") +} + +func TestNotetakerCmd_RegistersUpdate(t *testing.T) { + cmd := NewNotetakerCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["update"], "notetaker command must register the update subcommand") +} diff --git a/internal/cli/scheduler/group_events.go b/internal/cli/scheduler/group_events.go new file mode 100644 index 0000000..138b51d --- /dev/null +++ b/internal/cli/scheduler/group_events.go @@ -0,0 +1,421 @@ +package scheduler + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newGroupEventsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "group-events", + Aliases: []string{"group-event", "ge"}, + Short: "Manage Scheduler group events", + Long: `Manage Scheduler group events under a Configuration. + +Group events let multiple participants book a single shared time slot (for +example, a workshop or webinar). They live under a Scheduler Configuration, so +every command takes a configuration ID. + +API reference: https://developer.nylas.com/docs/v3/scheduler/`, + } + + cmd.AddCommand(newGroupEventsListCmd()) + cmd.AddCommand(newGroupEventCreateCmd()) + cmd.AddCommand(newGroupEventUpdateCmd()) + cmd.AddCommand(newGroupEventDeleteCmd()) + cmd.AddCommand(newGroupEventsImportCmd()) + + return cmd +} + +func newGroupEventsListCmd() *cobra.Command { + var ( + calendarID string + startStr string + endStr string + ) + + cmd := &cobra.Command{ + Use: "list [grant-id]", + Aliases: []string{"ls"}, + Short: "List group events for a configuration", + Long: `List group events under a Scheduler Configuration within a time window. + +A calendar and time window are required by the API; --start/--end default to +now through 30 days ahead when omitted.`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + configID := args[0] + if calendarID == "" { + return common.NewUserError("a calendar is required", "Pass --calendar (e.g. primary).") + } + start, err := parseGroupEventTime("start", startStr) + if err != nil { + return err + } + end, err := parseGroupEventTime("end", endStr) + if err != nil { + return err + } + // The list endpoint requires a window; default to now .. +30d. + if start == 0 { + start = time.Now().Unix() + } + if end == 0 { + end = time.Now().AddDate(0, 0, 30).Unix() + } + + events, err := common.WithClient(args[1:], func(ctx context.Context, client ports.NylasClient, grantID string) ([]domain.GroupEvent, error) { + return client.ListGroupEvents(ctx, grantID, configID, calendarID, start, end) + }) + if err != nil { + return common.WrapListError("group events", err) + } + + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(events) + } + if len(events) == 0 { + common.PrintEmptyState("group events") + return nil + } + fmt.Printf("Found %d group event(s):\n\n", len(events)) + for _, e := range events { + printGroupEvent(e) + } + return nil + }, + } + + cmd.Flags().StringVarP(&calendarID, "calendar", "c", "", "Calendar ID to list events from (required, e.g. primary)") + cmd.Flags().StringVar(&startStr, "start", "", "Window start (YYYY-MM-DD HH:MM, RFC3339, or Unix). Defaults to now.") + cmd.Flags().StringVar(&endStr, "end", "", "Window end (YYYY-MM-DD HH:MM, RFC3339, or Unix). Defaults to +30 days.") + + return cmd +} + +func newGroupEventCreateCmd() *cobra.Command { + var ( + calendarID string + title string + capacity int + description string + location string + startStr string + endStr string + timezone string + participants []string + organizer string + ) + + cmd := &cobra.Command{ + Use: "create [grant-id]", + Short: "Create a group event", + Long: `Create a group event under a Scheduler Configuration. + +A calendar, title, capacity, time window, and at least one participant are +required. If no participants are given, the API uses the event organizer.`, + Example: ` nylas scheduler group-events create \ + --calendar primary --title "Philosophy Workshop" --capacity 50 \ + --start "2026-07-01 18:00" --end "2026-07-01 19:00" --timezone America/New_York \ + --organizer "Nyla:nyla@example.com"`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + configID := args[0] + + start, err := parseGroupEventTime("start", startStr) + if err != nil { + return err + } + end, err := parseGroupEventTime("end", endStr) + if err != nil { + return err + } + if calendarID == "" || title == "" || start == 0 || end == 0 { + return common.NewUserError( + "missing required fields", + "Provide --calendar, --title, --start, and --end (and usually --capacity).", + ) + } + + parts := buildGroupParticipants(participants, organizer) + req := &domain.CreateGroupEventRequest{ + CalendarID: calendarID, + Title: title, + Capacity: capacity, + Description: description, + Location: location, + Participants: parts, + When: &domain.GroupEventWhen{ + StartTime: start, + EndTime: end, + StartTimezone: timezone, + EndTimezone: timezone, + }, + } + + events, err := common.WithClient(args[1:], func(ctx context.Context, client ports.NylasClient, grantID string) ([]domain.GroupEvent, error) { + return client.CreateGroupEvent(ctx, grantID, configID, req) + }) + if err != nil { + return common.WrapCreateError("group event", err) + } + return reportGroupEvents(cmd, events, "Group event created") + }, + } + + cmd.Flags().StringVarP(&calendarID, "calendar", "c", "", "Calendar ID (required)") + cmd.Flags().StringVarP(&title, "title", "t", "", "Event title (required)") + cmd.Flags().IntVar(&capacity, "capacity", 10, "Maximum number of attendees (1-500)") + cmd.Flags().StringVar(&description, "description", "", "Event description") + cmd.Flags().StringVar(&location, "location", "", "Event location") + cmd.Flags().StringVar(&startStr, "start", "", "Start time (YYYY-MM-DD HH:MM, RFC3339, or Unix) (required)") + cmd.Flags().StringVar(&endStr, "end", "", "End time (YYYY-MM-DD HH:MM, RFC3339, or Unix) (required)") + cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone for start/end (e.g. America/New_York)") + cmd.Flags().StringArrayVarP(&participants, "participant", "p", nil, "Participant as '[name:]email' (repeatable)") + cmd.Flags().StringVar(&organizer, "organizer", "", "Organizer participant as '[name:]email'") + + return cmd +} + +func newGroupEventUpdateCmd() *cobra.Command { + var ( + title string + capacity int + description string + location string + ) + + cmd := &cobra.Command{ + Use: "update [grant-id]", + Short: "Update a group event", + Args: cobra.RangeArgs(2, 3), + RunE: func(cmd *cobra.Command, args []string) error { + configID, eventID := args[0], args[1] + + req := &domain.UpdateGroupEventRequest{ + Title: title, + Capacity: capacity, + Description: description, + Location: location, + } + if title == "" && capacity == 0 && description == "" && location == "" { + return common.NewUserError( + "nothing to update", + "Pass at least one of --title, --capacity, --description, or --location.", + ) + } + + events, err := common.WithClient(args[2:], func(ctx context.Context, client ports.NylasClient, grantID string) ([]domain.GroupEvent, error) { + return client.UpdateGroupEvent(ctx, grantID, configID, eventID, req) + }) + if err != nil { + return common.WrapUpdateError("group event", err) + } + return reportGroupEvents(cmd, events, "Group event updated") + }, + } + + cmd.Flags().StringVarP(&title, "title", "t", "", "New event title") + cmd.Flags().IntVar(&capacity, "capacity", 0, "New maximum number of attendees (1-500)") + cmd.Flags().StringVar(&description, "description", "", "New event description") + cmd.Flags().StringVar(&location, "location", "", "New event location") + + return cmd +} + +func newGroupEventDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [grant-id]", + Aliases: []string{"rm"}, + Short: "Delete a group event", + Args: cobra.RangeArgs(2, 3), + RunE: func(cmd *cobra.Command, args []string) error { + configID, eventID := args[0], args[1] + _, err := common.WithClient(args[2:], func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + if err := client.DeleteGroupEvent(ctx, grantID, configID, eventID); err != nil { + return struct{}{}, common.WrapDeleteError("group event", err) + } + common.PrintSuccess("Group event deleted") + return struct{}{}, nil + }) + return err + }, + } + return cmd +} + +func newGroupEventsImportCmd() *cobra.Command { + var ( + file string + calendarID string + eventID string + capacity int + ) + + cmd := &cobra.Command{ + Use: "import ", + Short: "Import existing provider events as group events", + Long: `Import one or more existing calendar events into a Configuration as +group events. + +Provide a JSON array of import items with --file, or import a single event +inline with --calendar and --event. The JSON file format is an array of: + [{"calendar_id": "...", "event_id": "...", "capacity": 50}] + +This endpoint is configuration-scoped and does not take a grant.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + configID := args[0] + + items, err := loadImportItems(file, calendarID, eventID, capacity) + if err != nil { + return err + } + + events, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) ([]domain.GroupEvent, error) { + return client.ImportGroupEvents(ctx, configID, items) + }) + if err != nil { + return common.WrapCreateError("group events", err) + } + return reportGroupEvents(cmd, events, fmt.Sprintf("Imported %d group event(s)", len(events))) + }, + } + + cmd.Flags().StringVarP(&file, "file", "f", "", "Path to a JSON array of import items") + cmd.Flags().StringVarP(&calendarID, "calendar", "c", "", "Calendar ID (single-event import)") + cmd.Flags().StringVarP(&eventID, "event", "e", "", "Provider event ID to import (single-event import)") + cmd.Flags().IntVar(&capacity, "capacity", 0, "Capacity for the imported event (single-event import)") + + return cmd +} + +// loadImportItems builds the import payload from a JSON file or inline flags. +func loadImportItems(file, calendarID, eventID string, capacity int) ([]domain.ImportGroupEventItem, error) { + if file != "" { + data, err := os.ReadFile(file) //nolint:gosec // user-supplied path is expected + if err != nil { + return nil, common.NewUserError(fmt.Sprintf("could not read --file %q: %v", file, err), "Provide a readable JSON file.") + } + var items []domain.ImportGroupEventItem + if err := json.Unmarshal(data, &items); err != nil { + return nil, common.NewUserError( + fmt.Sprintf("invalid JSON in --file %q: %v", file, err), + `Expected a JSON array like [{"calendar_id":"...","event_id":"..."}].`, + ) + } + if len(items) == 0 { + return nil, common.NewUserError("no events to import", "The JSON array is empty.") + } + return items, nil + } + + if calendarID == "" || eventID == "" { + return nil, common.NewUserError( + "no events to import", + "Provide --file with a JSON array, or --calendar and --event for a single import.", + ) + } + return []domain.ImportGroupEventItem{ + {CalendarID: calendarID, EventID: eventID, Capacity: capacity}, + }, nil +} + +// buildGroupParticipants converts repeatable "[name:]email" flags into +// participants, marking the organizer entry when provided. +func buildGroupParticipants(participants []string, organizer string) []domain.GroupEventParticipant { + out := make([]domain.GroupEventParticipant, 0, len(participants)+1) + for _, p := range participants { + if pp, ok := parseGroupParticipant(p, false); ok { + out = append(out, pp) + } + } + if organizer != "" { + if pp, ok := parseGroupParticipant(organizer, true); ok { + out = append(out, pp) + } + } + return out +} + +// parseGroupParticipant parses "[name:]email" into a participant. +func parseGroupParticipant(s string, isOrganizer bool) (domain.GroupEventParticipant, bool) { + s = strings.TrimSpace(s) + if s == "" { + return domain.GroupEventParticipant{}, false + } + name, email := "", s + if idx := strings.LastIndex(s, ":"); idx >= 0 { + name = strings.TrimSpace(s[:idx]) + email = strings.TrimSpace(s[idx+1:]) + } + if email == "" { + return domain.GroupEventParticipant{}, false + } + return domain.GroupEventParticipant{Name: name, Email: email, IsOrganizer: isOrganizer}, true +} + +// parseGroupEventTime parses a time bound into a Unix timestamp. Empty returns 0. +func parseGroupEventTime(name, value string) (int64, error) { + if value == "" { + return 0, nil + } + if ts, err := strconv.ParseInt(value, 10, 64); err == nil && ts > 1000000000 { + return ts, nil + } + for _, layout := range []string{"2006-01-02 15:04", "2006-01-02", time.RFC3339} { + if t, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return t.Unix(), nil + } + } + return 0, common.NewUserError( + fmt.Sprintf("could not parse --%s value %q", name, value), + "Use YYYY-MM-DD HH:MM, RFC3339, or a Unix timestamp.", + ) +} + +func reportGroupEvents(cmd *cobra.Command, events []domain.GroupEvent, successMsg string) error { + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(events) + } + common.PrintSuccess(successMsg) + for _, e := range events { + printGroupEvent(e) + } + return nil +} + +func printGroupEvent(e domain.GroupEvent) { + title := e.Title + if title == "" { + title = "(no title)" + } + fmt.Printf("%s\n", common.Cyan.Sprint(title)) + if e.ID != "" { + fmt.Printf(" %s %s\n", common.Dim.Sprint("ID:"), e.ID) + } + if e.Capacity > 0 { + fmt.Printf(" %s %d\n", common.Dim.Sprint("Capacity:"), e.Capacity) + } + if e.When != nil && e.When.StartTime > 0 { + fmt.Printf(" %s %s\n", common.Dim.Sprint("Start:"), time.Unix(e.When.StartTime, 0).Format(time.RFC1123)) + } + if e.Location != "" { + fmt.Printf(" %s %s\n", common.Dim.Sprint("Location:"), e.Location) + } + if len(e.Participants) > 0 { + fmt.Printf(" %s %d\n", common.Dim.Sprint("Participants:"), len(e.Participants)) + } + fmt.Println() +} diff --git a/internal/cli/scheduler/group_events_test.go b/internal/cli/scheduler/group_events_test.go new file mode 100644 index 0000000..2d06704 --- /dev/null +++ b/internal/cli/scheduler/group_events_test.go @@ -0,0 +1,105 @@ +package scheduler + +import ( + "testing" + + "github.com/nylas/cli/internal/cli/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGroupEventsCmd_Structure(t *testing.T) { + cmd := newGroupEventsCmd() + assert.Equal(t, "group-events", cmd.Use) + + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + for _, want := range []string{"list", "create", "update", "delete", "import"} { + assert.True(t, names[want], "missing group-events subcommand: %s", want) + } +} + +func TestSchedulerCmd_RegistersGroupEvents(t *testing.T) { + cmd := NewSchedulerCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["group-events"], "scheduler must register group-events") +} + +// The list endpoint requires calendar_id/start_time/end_time; the CLI must +// enforce --calendar locally (the API 400s without it). +func TestGroupEventList_RequiresCalendar(t *testing.T) { + cmd := newGroupEventsListCmd() + _, _, err := testutil.ExecuteCommand(cmd, "cfg-1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "calendar") +} + +// Create requires calendar/title/start/end before any API call. +func TestGroupEventCreate_RequiresFields(t *testing.T) { + cmd := newGroupEventCreateCmd() + _, _, err := testutil.ExecuteCommand(cmd, "cfg-1", "--title", "Only title") + assert.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +// Update with no fields must fail locally rather than send an empty PUT. +func TestGroupEventUpdate_RequiresAField(t *testing.T) { + cmd := newGroupEventUpdateCmd() + _, _, err := testutil.ExecuteCommand(cmd, "cfg-1", "ge-1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "nothing to update") +} + +// Import needs either a file or inline calendar+event. +func TestGroupEventImport_RequiresInput(t *testing.T) { + cmd := newGroupEventsImportCmd() + _, _, err := testutil.ExecuteCommand(cmd, "cfg-1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "import") +} + +func TestParseGroupParticipant(t *testing.T) { + tests := []struct { + name string + input string + organizer bool + wantName string + wantEmail string + wantOK bool + }{ + {name: "email only", input: "nyla@example.com", wantEmail: "nyla@example.com", wantOK: true}, + {name: "name and email", input: "Nyla:nyla@example.com", wantName: "Nyla", wantEmail: "nyla@example.com", wantOK: true}, + {name: "organizer flag carried", input: "boss@example.com", organizer: true, wantEmail: "boss@example.com", wantOK: true}, + {name: "empty is dropped", input: " ", wantOK: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, ok := parseGroupParticipant(tt.input, tt.organizer) + assert.Equal(t, tt.wantOK, ok) + if !ok { + return + } + assert.Equal(t, tt.wantName, p.Name) + assert.Equal(t, tt.wantEmail, p.Email) + assert.Equal(t, tt.organizer, p.IsOrganizer) + }) + } +} + +func TestParseGroupEventTime(t *testing.T) { + got, err := parseGroupEventTime("start", "") + require.NoError(t, err) + assert.Equal(t, int64(0), got, "empty defers to API default") + + got, err = parseGroupEventTime("start", "1744286400") + require.NoError(t, err) + assert.Equal(t, int64(1744286400), got) + + _, err = parseGroupEventTime("start", "nonsense") + assert.Error(t, err) +} diff --git a/internal/cli/scheduler/scheduler.go b/internal/cli/scheduler/scheduler.go index 7616327..be8f4e1 100644 --- a/internal/cli/scheduler/scheduler.go +++ b/internal/cli/scheduler/scheduler.go @@ -22,6 +22,7 @@ API reference: https://developer.nylas.com/docs/v3/scheduler/`, cmd.AddCommand(newConfigurationsCmd()) cmd.AddCommand(newSessionsCmd()) cmd.AddCommand(newBookingsCmd()) + cmd.AddCommand(newGroupEventsCmd()) return cmd } diff --git a/internal/domain/email_clean.go b/internal/domain/email_clean.go new file mode 100644 index 0000000..e61642a --- /dev/null +++ b/internal/domain/email_clean.go @@ -0,0 +1,32 @@ +package domain + +// CleanMessagesMaxIDs is the maximum number of message IDs the clean endpoint +// accepts in a single request. +const CleanMessagesMaxIDs = 20 + +// CleanMessagesRequest configures a clean-conversation request +// (PUT /v3/grants/{id}/messages/clean). +// +// The boolean options are pointers so that an unset option is omitted from the +// request body and the API default applies. Defaults (when omitted) are: +// IgnoreLinks=true, IgnoreImages=true, IgnoreTables=true, ImagesAsMarkdown=false, +// RemoveConclusionPhrases=true. +type CleanMessagesRequest struct { + MessageIDs []string `json:"message_id"` + IgnoreLinks *bool `json:"ignore_links,omitempty"` + IgnoreImages *bool `json:"ignore_images,omitempty"` + IgnoreTables *bool `json:"ignore_tables,omitempty"` + ImagesAsMarkdown *bool `json:"images_as_markdown,omitempty"` + RemoveConclusionPhrases *bool `json:"remove_conclusion_phrases,omitempty"` +} + +// CleanedMessage is a single message returned by the clean endpoint. The cleaned +// (HTML) message body is in Conversation; Body holds the original message body. +type CleanedMessage struct { + ID string `json:"id"` + GrantID string `json:"grant_id,omitempty"` + Object string `json:"object,omitempty"` + Subject string `json:"subject,omitempty"` + Conversation string `json:"conversation"` + Body string `json:"body,omitempty"` +} diff --git a/internal/domain/group_events.go b/internal/domain/group_events.go new file mode 100644 index 0000000..c1b7c17 --- /dev/null +++ b/internal/domain/group_events.go @@ -0,0 +1,72 @@ +package domain + +// GroupEventParticipant is a participant included in a Scheduler group event. +type GroupEventParticipant struct { + Name string `json:"name,omitempty"` + Email string `json:"email"` + IsOrganizer bool `json:"is_organizer,omitempty"` +} + +// GroupEventWhen is the time span (start/end + timezones) of a group event. +type GroupEventWhen struct { + StartTime int64 `json:"start_time,omitempty"` + EndTime int64 `json:"end_time,omitempty"` + StartTimezone string `json:"start_timezone,omitempty"` + EndTimezone string `json:"end_timezone,omitempty"` +} + +// GroupEvent is a Nylas Scheduler group event. Group events live under a +// Scheduler Configuration and let multiple participants book a shared slot. +type GroupEvent struct { + ID string `json:"id,omitempty"` + CalendarID string `json:"calendar_id,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Location string `json:"location,omitempty"` + Capacity int `json:"capacity,omitempty"` + Participants []GroupEventParticipant `json:"participants,omitempty"` + When *GroupEventWhen `json:"when,omitempty"` +} + +// CreateGroupEventRequest creates a group event. calendar_id, capacity, title, +// and when are required. Participants uses omitempty: when none are supplied the +// field is omitted (not sent as null) so the API falls back to the organizer, +// per the documented "if you don't specify a participant, Nylas uses the event +// organizer instead" behavior. +type CreateGroupEventRequest struct { + CalendarID string `json:"calendar_id"` + Title string `json:"title"` + Capacity int `json:"capacity"` + Description string `json:"description,omitempty"` + Location string `json:"location,omitempty"` + Participants []GroupEventParticipant `json:"participants,omitempty"` + When *GroupEventWhen `json:"when"` +} + +// UpdateGroupEventRequest updates a group event. All fields are optional; only +// non-zero fields are sent. +type UpdateGroupEventRequest struct { + CalendarID string `json:"calendar_id,omitempty"` + Title string `json:"title,omitempty"` + Capacity int `json:"capacity,omitempty"` + Description string `json:"description,omitempty"` + Location string `json:"location,omitempty"` + Participants []GroupEventParticipant `json:"participants,omitempty"` + When *GroupEventWhen `json:"when,omitempty"` +} + +// ImportGroupEventItem describes one provider event to import as a group event. +type ImportGroupEventItem struct { + CalendarID string `json:"calendar_id"` + EventID string `json:"event_id"` + Capacity int `json:"capacity,omitempty"` + Participants []GroupEventParticipant `json:"participants,omitempty"` + Exceptions []ImportGroupEventException `json:"exceptions,omitempty"` +} + +// ImportGroupEventException overrides capacity for a specific instance when +// importing a recurring event. +type ImportGroupEventException struct { + EventID string `json:"event_id,omitempty"` + Capacity int `json:"capacity,omitempty"` +} diff --git a/internal/domain/notetaker.go b/internal/domain/notetaker.go index 397a478..9f19170 100644 --- a/internal/domain/notetaker.go +++ b/internal/domain/notetaker.go @@ -62,6 +62,21 @@ type CreateNotetakerRequest struct { BotConfig *BotConfig `json:"bot_config,omitempty"` } +// NotetakerMeetingSettings controls what a notetaker records. Pointer fields +// distinguish "unset" (leave as-is) from an explicit true/false. +type NotetakerMeetingSettings struct { + VideoRecording *bool `json:"video_recording,omitempty"` + AudioRecording *bool `json:"audio_recording,omitempty"` + Transcription *bool `json:"transcription,omitempty"` +} + +// UpdateNotetakerRequest updates a scheduled notetaker before it joins. +type UpdateNotetakerRequest struct { + JoinTime int64 `json:"join_time,omitempty"` // Unix timestamp for when to join + Name string `json:"name,omitempty"` // Bot display name + MeetingSettings *NotetakerMeetingSettings `json:"meeting_settings,omitempty"` +} + // NotetakerListResponse represents a list of notetakers. type NotetakerListResponse struct { Data []Notetaker `json:"data"` diff --git a/internal/domain/room_resources.go b/internal/domain/room_resources.go new file mode 100644 index 0000000..44f89fc --- /dev/null +++ b/internal/domain/room_resources.go @@ -0,0 +1,17 @@ +package domain + +// RoomResource represents a bookable room or equipment resource returned by the +// Nylas room resources endpoint (GET /v3/grants/{id}/resources). +// +// A resource's Email doubles as a calendar ID, so it can be added as a +// participant when creating events or passed to availability/free-busy checks. +type RoomResource struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + Capacity int `json:"capacity,omitempty"` + Building string `json:"building,omitempty"` + FloorName string `json:"floor_name,omitempty"` + FloorSection string `json:"floor_section,omitempty"` + FloorNumber int `json:"floor_number,omitempty"` + Object string `json:"object,omitempty"` +} diff --git a/internal/ports/calendar.go b/internal/ports/calendar.go index f094b51..3221309 100644 --- a/internal/ports/calendar.go +++ b/internal/ports/calendar.go @@ -40,6 +40,11 @@ type CalendarClient interface { // GetEvent retrieves a specific event. GetEvent(ctx context.Context, grantID, calendarID, eventID string) (*domain.Event, error) + // ImportEvents bulk-reads events from a calendar over a time window + // (GET /v3/grants/{id}/events/import), including expanded recurring + // instances. Intended for migration/export. CalendarID is required. + ImportEvents(ctx context.Context, grantID string, params *domain.EventQueryParams) ([]domain.Event, error) + // CreateEvent creates a new event. CreateEvent(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error) @@ -62,6 +67,9 @@ type CalendarClient interface { // GetAvailability retrieves availability across multiple accounts. GetAvailability(ctx context.Context, req *domain.AvailabilityRequest) (*domain.AvailabilityResponse, error) + // ListRoomResources retrieves bookable room and equipment resources for a grant. + ListRoomResources(ctx context.Context, grantID string) ([]domain.RoomResource, error) + // ================================ // VIRTUAL CALENDAR OPERATIONS // ================================ diff --git a/internal/ports/messages.go b/internal/ports/messages.go index a23e1a2..a5c5b62 100644 --- a/internal/ports/messages.go +++ b/internal/ports/messages.go @@ -55,6 +55,10 @@ type MessageClient interface { // DeleteMessage deletes a message. DeleteMessage(ctx context.Context, grantID, messageID string) error + // CleanMessages parses messages into clean, display-ready text, stripping + // quoted reply chains, signatures, and conclusion phrases. + CleanMessages(ctx context.Context, grantID string, req *domain.CleanMessagesRequest) ([]domain.CleanedMessage, error) + // ================================ // SCHEDULED MESSAGE OPERATIONS // ================================ diff --git a/internal/ports/notetaker.go b/internal/ports/notetaker.go index 4d94f13..2b7c660 100644 --- a/internal/ports/notetaker.go +++ b/internal/ports/notetaker.go @@ -20,6 +20,13 @@ type NotetakerClient interface { // DeleteNotetaker deletes a notetaker. DeleteNotetaker(ctx context.Context, grantID, notetakerID string) error + // LeaveNotetaker instructs an active notetaker to leave its meeting, + // keeping the notetaker record and any generated media. + LeaveNotetaker(ctx context.Context, grantID, notetakerID string) error + + // UpdateNotetaker updates a scheduled notetaker (join time, name, settings). + UpdateNotetaker(ctx context.Context, grantID, notetakerID string, req *domain.UpdateNotetakerRequest) (*domain.Notetaker, error) + // GetNotetakerMedia retrieves media data for a notetaker. GetNotetakerMedia(ctx context.Context, grantID, notetakerID string) (*domain.MediaData, error) } diff --git a/internal/ports/scheduler.go b/internal/ports/scheduler.go index da08974..29cec6f 100644 --- a/internal/ports/scheduler.go +++ b/internal/ports/scheduler.go @@ -52,4 +52,27 @@ type SchedulerClient interface { // CancelBooking cancels a booking. CancelBooking(ctx context.Context, bookingID string, reason string) error + + // ================================ + // GROUP EVENT OPERATIONS + // ================================ + + // ListGroupEvents retrieves the group events for a configuration within a + // time window. calendarID, startTime, and endTime (Unix seconds) are all + // required by the API. + ListGroupEvents(ctx context.Context, grantID, configID, calendarID string, startTime, endTime int64) ([]domain.GroupEvent, error) + + // CreateGroupEvent creates a group event under a configuration. The API may + // return more than one event (e.g. when recurrence is set). + CreateGroupEvent(ctx context.Context, grantID, configID string, req *domain.CreateGroupEventRequest) ([]domain.GroupEvent, error) + + // UpdateGroupEvent updates a group event. + UpdateGroupEvent(ctx context.Context, grantID, configID, eventID string, req *domain.UpdateGroupEventRequest) ([]domain.GroupEvent, error) + + // DeleteGroupEvent deletes a group event. + DeleteGroupEvent(ctx context.Context, grantID, configID, eventID string) error + + // ImportGroupEvents imports existing provider events as group events under a + // configuration. This endpoint is configuration-scoped (not grant-scoped). + ImportGroupEvents(ctx context.Context, configID string, items []domain.ImportGroupEventItem) ([]domain.GroupEvent, error) }