diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f88acd..5298451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - goVer: ["1.18", "1.19", "1.20", "1.21"] + goVer: ["1.22", "1.23", "1.24"] steps: - name: Set up Go ${{ matrix.goVer }} uses: actions/setup-go@v3 diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml index 27bf8ba..6bbf739 100644 --- a/.github/workflows/reviewdog.yml +++ b/.github/workflows/reviewdog.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v3 with: - go-version: "1.21" + go-version: "1.22" - name: Install golangci-lint run: diff --git a/audit_logs.go b/audit_logs.go new file mode 100644 index 0000000..428e938 --- /dev/null +++ b/audit_logs.go @@ -0,0 +1,70 @@ +package stream + +import ( + "context" + "encoding/json" + "time" +) + +type AuditLogsClient struct { + client *Client +} + +type AuditLog struct { + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + Action string `json:"action"` + UserID string `json:"user_id"` + Custom map[string]any `json:"custom"` + CreatedAt time.Time `json:"created_at"` +} + +type QueryAuditLogsResponse struct { + AuditLogs []AuditLog `json:"audit_logs"` + Next string `json:"next"` + Prev string `json:"prev"` + response +} + +type QueryAuditLogsFilters struct { + EntityType string + EntityID string + UserID string +} + +type QueryAuditLogsPager struct { + Next string + Prev string + Limit int +} + +func (c *AuditLogsClient) QueryAuditLogs(ctx context.Context, filters QueryAuditLogsFilters, pager QueryAuditLogsPager) (*QueryAuditLogsResponse, error) { + endpoint := c.client.makeEndpoint("audit_logs/") + if filters.EntityType != "" && filters.EntityID != "" { + endpoint.addQueryParam(makeRequestOption("entity_type", filters.EntityType)) + endpoint.addQueryParam(makeRequestOption("entity_id", filters.EntityID)) + } + if filters.UserID != "" { + endpoint.addQueryParam(makeRequestOption("user_id", filters.UserID)) + } + if pager.Next != "" { + endpoint.addQueryParam(makeRequestOption("next", pager.Next)) + } + if pager.Prev != "" { + endpoint.addQueryParam(makeRequestOption("prev", pager.Prev)) + } + if pager.Limit > 0 { + endpoint.addQueryParam(makeRequestOption("limit", pager.Limit)) + } + body, err := c.client.get(ctx, endpoint, nil, c.client.authenticator.auditLogsAuth) + if err != nil { + return nil, err + } + + var resp QueryAuditLogsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + + return &resp, nil +} diff --git a/audit_logs_test.go b/audit_logs_test.go new file mode 100644 index 0000000..6ae99e5 --- /dev/null +++ b/audit_logs_test.go @@ -0,0 +1,120 @@ +package stream_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + stream "github.com/GetStream/stream-go2/v8" +) + +func TestQueryAuditLogs(t *testing.T) { + ctx := context.Background() + client, requester := newClient(t) + + // Test with all filters and pager options + filters := stream.QueryAuditLogsFilters{ + EntityType: "feed", + EntityID: "123", + UserID: "user-42", + } + pager := stream.QueryAuditLogsPager{ + Next: "next-token", + Prev: "prev-token", + Limit: 25, + } + + // Set mock response + now := time.Now() + mockResp := struct { + AuditLogs []stream.AuditLog `json:"audit_logs"` + Next string `json:"next"` + Prev string `json:"prev"` + }{ + AuditLogs: []stream.AuditLog{ + { + EntityType: "feed", + EntityID: "123", + Action: "create", + UserID: "user-42", + Custom: map[string]any{"key": "value"}, + CreatedAt: now, + }, + }, + Next: "next-page-token", + Prev: "prev-page-token", + } + respBytes, err := json.Marshal(mockResp) + require.NoError(t, err) + requester.resp = string(respBytes) + + // Call the function + resp, err := client.AuditLogs().QueryAuditLogs(ctx, filters, pager) + require.NoError(t, err) + + // Verify request + testRequest( + t, + requester.req, + http.MethodGet, + "https://api.stream-io-api.com/api/v1.0/audit_logs/?api_key=key&entity_id=123&entity_type=feed&limit=25&next=next-token&prev=prev-token&user_id=user-42", + "", + ) + + // Verify response + assert.Len(t, resp.AuditLogs, 1) + assert.Equal(t, "feed", resp.AuditLogs[0].EntityType) + assert.Equal(t, "123", resp.AuditLogs[0].EntityID) + assert.Equal(t, "create", resp.AuditLogs[0].Action) + assert.Equal(t, "user-42", resp.AuditLogs[0].UserID) + assert.Equal(t, "value", resp.AuditLogs[0].Custom["key"]) + assert.Equal(t, now.Truncate(time.Second).UTC(), resp.AuditLogs[0].CreatedAt.Truncate(time.Second).UTC()) + assert.Equal(t, "next-page-token", resp.Next) + assert.Equal(t, "prev-page-token", resp.Prev) +} + +func TestQueryAuditLogsWithMinimalParams(t *testing.T) { + ctx := context.Background() + client, requester := newClient(t) + + // Test with minimal filters and pager options + filters := stream.QueryAuditLogsFilters{} + pager := stream.QueryAuditLogsPager{} + + // Set mock response + mockResp := struct { + AuditLogs []stream.AuditLog `json:"audit_logs"` + Next string `json:"next"` + Prev string `json:"prev"` + }{ + AuditLogs: []stream.AuditLog{}, + Next: "", + Prev: "", + } + respBytes, err := json.Marshal(mockResp) + require.NoError(t, err) + requester.resp = string(respBytes) + + // Call the function + resp, err := client.AuditLogs().QueryAuditLogs(ctx, filters, pager) + require.NoError(t, err) + + // Verify request + testRequest( + t, + requester.req, + http.MethodGet, + "https://api.stream-io-api.com/api/v1.0/audit_logs/?api_key=key", + "", + ) + + // Verify response + assert.Empty(t, resp.AuditLogs) + assert.Empty(t, resp.Next) + assert.Empty(t, resp.Prev) +} diff --git a/authenticator.go b/authenticator.go index 4dd9ae4..25b27e4 100644 --- a/authenticator.go +++ b/authenticator.go @@ -23,6 +23,7 @@ const ( resAnalytics resource = "analytics" resAnalyticsRedirect resource = "redirect_and_track" resModeration resource = "moderation" + resAuditLogs resource = "audit_logs" ) type action string @@ -121,6 +122,15 @@ func (a authenticator) moderationAuth(req *http.Request) error { return a.jwtSignRequest(req, claims) } +func (a authenticator) auditLogsAuth(req *http.Request) error { + claims := jwt.MapClaims{ + "action": "*", + "feed_id": "*", + "resource": resAuditLogs, + } + return a.jwtSignRequest(req, claims) +} + func (a authenticator) signAnalyticsRedirectEndpoint(endpoint *endpoint) error { claims := jwt.MapClaims{ "action": "*", diff --git a/client.go b/client.go index 9e9f6ea..51f40f5 100644 --- a/client.go +++ b/client.go @@ -237,6 +237,12 @@ func (c *Client) Moderation() *ModerationClient { return &ModerationClient{client: c.cloneWithURLBuilder(b)} } +// AuditLogs returns a new AuditLogsClient. +func (c *Client) AuditLogs() *AuditLogsClient { + b := newAPIURLBuilder(c.addr, c.region, c.version) + return &AuditLogsClient{client: c.cloneWithURLBuilder(b)} +} + // Personalization returns a new PersonalizationClient. func (c *Client) Personalization() *PersonalizationClient { b := newPersonalizationURLBuilder(c.region) @@ -557,8 +563,11 @@ func (c *Client) addActivities(ctx context.Context, feed Feed, activities ...Act return &out, nil } -func (c *Client) removeActivityByID(ctx context.Context, feed Feed, activityID string) (*RemoveActivityResponse, error) { +func (c *Client) removeActivityByID(ctx context.Context, feed Feed, activityID string, opts ...RemoveActivityOption) (*RemoveActivityResponse, error) { endpoint := c.makeEndpoint("feed/%s/%s/%s/", feed.Slug(), feed.UserID(), activityID) + for _, opt := range opts { + endpoint.addQueryParam(opt) + } resp, err := c.delete(ctx, endpoint, nil, c.authenticator.feedAuth(resFeed, feed)) if err != nil { return nil, err diff --git a/feed.go b/feed.go index 7c59a50..0a41120 100644 --- a/feed.go +++ b/feed.go @@ -20,7 +20,7 @@ type Feed interface { UserID() string AddActivity(context.Context, Activity) (*AddActivityResponse, error) AddActivities(context.Context, ...Activity) (*AddActivitiesResponse, error) - RemoveActivityByID(context.Context, string) (*RemoveActivityResponse, error) + RemoveActivityByID(context.Context, string, ...RemoveActivityOption) (*RemoveActivityResponse, error) RemoveActivityByForeignID(context.Context, string) (*RemoveActivityResponse, error) Follow(context.Context, *FlatFeed, ...FollowFeedOption) (*BaseResponse, error) GetFollowing(context.Context, ...FollowingOption) (*FollowingResponse, error) @@ -79,8 +79,10 @@ func (f *feed) AddActivities(ctx context.Context, activities ...Activity) (*AddA // RemoveActivityByID removes an activity from the feed (if present), using the provided // id string argument as the ID field of the activity. -func (f *feed) RemoveActivityByID(ctx context.Context, id string) (*RemoveActivityResponse, error) { - return f.client.removeActivityByID(ctx, f, id) +// Optional RemoveActivityOption parameters can be provided, such as WithRemoveByUserID +// to specify a user ID in the query string. +func (f *feed) RemoveActivityByID(ctx context.Context, id string, opts ...RemoveActivityOption) (*RemoveActivityResponse, error) { + return f.client.removeActivityByID(ctx, f, id, opts...) } // RemoveActivityByForeignID removes an activity from the feed (if present), using the provided diff --git a/feed_test.go b/feed_test.go index 4249086..b234d3d 100644 --- a/feed_test.go +++ b/feed_test.go @@ -303,3 +303,14 @@ func TestRealtimeToken(t *testing.T) { assert.Equal(t, tc.expected, token) } } + +func TestRemoveActivityByIDWithUserID(t *testing.T) { + ctx := context.Background() + client, requester := newClient(t) + flat, _ := newFlatFeedWithUserID(client, "123") + + _, err := flat.RemoveActivityByID(ctx, "activity-id", stream.WithRemoveByUserID("user-id")) + require.NoError(t, err) + + testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/feed/flat/123/activity-id/?api_key=key&user_id=user-id", "") +} diff --git a/go.mod b/go.mod index 22b4b58..e89adb5 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,7 @@ module github.com/GetStream/stream-go2/v8 +go 1.22 + require ( github.com/fatih/structs v1.1.0 github.com/golang-jwt/jwt/v4 v4.5.0 @@ -12,5 +14,3 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.0 // indirect ) - -go 1.18 diff --git a/moderation.go b/moderation.go index c9779ed..8e840fb 100644 --- a/moderation.go +++ b/moderation.go @@ -63,15 +63,17 @@ func (c *ModerationClient) flagContent(ctx context.Context, r flagRequest) error type updateStatusRequest struct { EntityType string `json:"entity_type"` EntityID string `json:"entity_id"` + ModeratorID string `json:"moderator_id"` Status string `json:"status"` RecommendedAction string `json:"recommended_action"` LatestModeratorAction string `json:"latest_moderator_action"` } -func (c *ModerationClient) UpdateActivityModerationStatus(ctx context.Context, activityID, status, recAction, modAction string) error { +func (c *ModerationClient) UpdateActivityModerationStatus(ctx context.Context, activityID, modID, status, recAction, modAction string) error { r := updateStatusRequest{ EntityType: ModerationActivity, EntityID: activityID, + ModeratorID: modID, Status: status, RecommendedAction: recAction, LatestModeratorAction: modAction, @@ -79,10 +81,11 @@ func (c *ModerationClient) UpdateActivityModerationStatus(ctx context.Context, a return c.updateStatus(ctx, r) } -func (c *ModerationClient) UpdateReactionModerationStatus(ctx context.Context, reactionID, status, recAction, modAction string) error { +func (c *ModerationClient) UpdateReactionModerationStatus(ctx context.Context, reactionID, modID, status, recAction, modAction string) error { r := updateStatusRequest{ EntityType: ModerationReaction, EntityID: reactionID, + ModeratorID: modID, Status: status, RecommendedAction: recAction, LatestModeratorAction: modAction, diff --git a/moderation_test.go b/moderation_test.go index bd8f503..f8d5326 100644 --- a/moderation_test.go +++ b/moderation_test.go @@ -53,28 +53,28 @@ func TestFlagUser(t *testing.T) { func TestUpdateActivityModerationStatus(t *testing.T) { ctx := context.Background() client, requester := newClient(t) - err := client.Moderation().UpdateActivityModerationStatus(ctx, "foo", "complete", "watch", "mark_safe") + err := client.Moderation().UpdateActivityModerationStatus(ctx, "foo", "moderator_123", "complete", "watch", "mark_safe") require.NoError(t, err) testRequest( t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/moderation/status/?api_key=key", - `{"entity_id":"foo", "entity_type":"stream:feeds:v2:activity", "latest_moderator_action":"mark_safe", "recommended_action":"watch", "status":"complete"}`, + `{"entity_id":"foo", "entity_type":"stream:feeds:v2:activity", "moderator_id": "moderator_123", "latest_moderator_action":"mark_safe", "recommended_action":"watch", "status":"complete"}`, ) } func TestUpdateReactionModerationStatus(t *testing.T) { ctx := context.Background() client, requester := newClient(t) - err := client.Moderation().UpdateReactionModerationStatus(ctx, "foo", "complete", "watch", "mark_safe") + err := client.Moderation().UpdateReactionModerationStatus(ctx, "foo", "moderator_123", "complete", "watch", "mark_safe") require.NoError(t, err) testRequest( t, requester.req, http.MethodPost, "https://api.stream-io-api.com/api/v1.0/moderation/status/?api_key=key", - `{"entity_id":"foo", "entity_type":"stream:feeds:v2:reaction", "latest_moderator_action":"mark_safe", "recommended_action":"watch", "status":"complete"}`, + `{"entity_id":"foo", "entity_type":"stream:feeds:v2:reaction", "moderator_id": "moderator_123", "latest_moderator_action":"mark_safe", "recommended_action":"watch", "status":"complete"}`, ) } diff --git a/options.go b/options.go index 5dc4832..6cb4351 100644 --- a/options.go +++ b/options.go @@ -415,6 +415,28 @@ func (nop) valid() bool { return false } +// RemoveActivityOption is an option usable by RemoveActivityByID method. +type RemoveActivityOption struct { + requestOption +} + +// WithRemoveByUserID adds the user_id parameter to API calls, used when removing activities +// to specify which user ID should be used. +func WithRemoveByUserID(userID string) RemoveActivityOption { + return RemoveActivityOption{makeRequestOption("user_id", userID)} +} + +// ReactionOption is an option usable by Reactions methods. +type ReactionOption struct { + requestOption +} + +// WithReactionUserID adds the user_id parameter to API calls, used when performing +// reaction operations to specify which user ID should be used. +func WithReactionUserID(userID string) ReactionOption { + return ReactionOption{makeRequestOption("user_id", userID)} +} + type GetReactionsOption struct { requestOption } diff --git a/reactions.go b/reactions.go index 7e933f4..ccecea8 100644 --- a/reactions.go +++ b/reactions.go @@ -64,25 +64,43 @@ func (c *ReactionsClient) Get(ctx context.Context, id string) (*ReactionResponse // Delete deletes a reaction having the given id. // The reaction is permanently deleted and cannot be restored. // Returned reaction is empty. -func (c *ReactionsClient) Delete(ctx context.Context, id string) (*ReactionResponse, error) { +// Optional ReactionOption parameters can be provided, such as WithReactionUserID +// to specify a user ID in the query string. +func (c *ReactionsClient) Delete(ctx context.Context, id string, opts ...ReactionOption) (*ReactionResponse, error) { endpoint := c.client.makeEndpoint("reaction/%s/", id) + for _, opt := range opts { + endpoint.addQueryParam(opt) + } + return c.decode(c.client.delete(ctx, endpoint, nil, c.client.authenticator.reactionsAuth)) } // SoftDelete soft-deletes a reaction having the given id. It is possible to restore this reaction using ReactionsClient.Restore. -func (c *ReactionsClient) SoftDelete(ctx context.Context, id string) error { +// Optional ReactionOption parameters can be provided, such as WithReactionUserID +// to specify a user ID in the query string. +func (c *ReactionsClient) SoftDelete(ctx context.Context, id string, opts ...ReactionOption) error { endpoint := c.client.makeEndpoint("reaction/%s/", id) endpoint.addQueryParam(makeRequestOption("soft", true)) + for _, opt := range opts { + endpoint.addQueryParam(opt) + } + _, err := c.client.delete(ctx, endpoint, nil, c.client.authenticator.reactionsAuth) return err } // Restore restores a soft deleted reaction having the given id. -func (c *ReactionsClient) Restore(ctx context.Context, id string) error { +// Optional ReactionOption parameters can be provided, such as WithReactionUserID +// to specify a user ID in the query string. +func (c *ReactionsClient) Restore(ctx context.Context, id string, opts ...ReactionOption) error { endpoint := c.client.makeEndpoint("reaction/%s/restore/", id) + for _, opt := range opts { + endpoint.addQueryParam(opt) + } + _, err := c.client.put(ctx, endpoint, nil, c.client.authenticator.reactionsAuth) return err } diff --git a/reactions_test.go b/reactions_test.go index 03bdff2..8ebfc1f 100644 --- a/reactions_test.go +++ b/reactions_test.go @@ -263,3 +263,31 @@ func TestGetNextPageReactions(t *testing.T) { _, err = client.Reactions().GetNextPageFilteredReactions(ctx, resp) require.Error(t, err) } + +func TestDeleteReactionWithUserID(t *testing.T) { + ctx := context.Background() + client, requester := newClient(t) + _, err := client.Reactions().Delete(ctx, "id1", stream.WithReactionUserID("user1")) + require.NoError(t, err) + testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/reaction/id1/?api_key=key&user_id=user1", "") +} + +func TestSoftDeleteReactionWithUserID(t *testing.T) { + ctx := context.Background() + client, requester := newClient(t) + + err := client.Reactions().SoftDelete(ctx, "rid", stream.WithReactionUserID("user1")) + require.NoError(t, err) + + testRequest(t, requester.req, http.MethodDelete, "https://api.stream-io-api.com/api/v1.0/reaction/rid/?api_key=key&soft=true&user_id=user1", "") +} + +func TestRestoreReactionWithUserID(t *testing.T) { + ctx := context.Background() + client, requester := newClient(t) + + err := client.Reactions().Restore(ctx, "rid", stream.WithReactionUserID("user1")) + require.NoError(t, err) + + testRequest(t, requester.req, http.MethodPut, "https://api.stream-io-api.com/api/v1.0/reaction/rid/restore/?api_key=key&user_id=user1", "") +}