From b962f0c159e95647cb723ddc3f99c0d7f909b13e Mon Sep 17 00:00:00 2001 From: biplavbarua Date: Sat, 10 Jan 2026 03:45:21 +0530 Subject: [PATCH] feat: implement activity pagination --- server/router/api/v1/activity_service.go | 41 ++++++++---- .../api/v1/test/activity_service_test.go | 67 +++++++++++++++++++ store/activity.go | 4 ++ store/db/mysql/activity.go | 9 +++ store/db/postgres/activity.go | 9 +++ store/db/sqlite/activity.go | 9 +++ 6 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 server/router/api/v1/test/activity_service_test.go diff --git a/server/router/api/v1/activity_service.go b/server/router/api/v1/activity_service.go index bcd0c9716..7496e19e9 100644 --- a/server/router/api/v1/activity_service.go +++ b/server/router/api/v1/activity_service.go @@ -15,22 +15,39 @@ import ( ) func (s *APIV1Service) ListActivities(ctx context.Context, request *v1pb.ListActivitiesRequest) (*v1pb.ListActivitiesResponse, error) { - // Set default page size if not specified - pageSize := request.PageSize - if pageSize <= 0 || pageSize > 1000 { - pageSize = 100 + var limit, offset int + if request.PageToken != "" { + var pageToken v1pb.PageToken + if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err) + } + limit = int(pageToken.Limit) + offset = int(pageToken.Offset) + } else { + limit = int(request.PageSize) } - - // TODO: Implement pagination with page_token and use pageSize for limiting - // For now, we'll fetch all activities and the pageSize will be used in future pagination implementation - _ = pageSize // Acknowledge pageSize variable to avoid linter warning - - activities, err := s.Store.ListActivities(ctx, &store.FindActivity{}) + if limit <= 0 { + limit = DefaultPageSize + } + limitPlusOne := limit + 1 + activities, err := s.Store.ListActivities(ctx, &store.FindActivity{ + Limit: &limitPlusOne, + Offset: &offset, + }) if err != nil { return nil, status.Errorf(codes.Internal, "failed to list activities: %v", err) } var activityMessages []*v1pb.Activity + nextPageToken := "" + if len(activities) == limitPlusOne { + activities = activities[:limit] + nextPageToken, err = getPageToken(limit, offset+limit) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get next page token, error: %v", err) + } + } + for _, activity := range activities { activityMessage, err := s.convertActivityFromStore(ctx, activity) if err != nil { @@ -40,8 +57,8 @@ func (s *APIV1Service) ListActivities(ctx context.Context, request *v1pb.ListAct } return &v1pb.ListActivitiesResponse{ - Activities: activityMessages, - // TODO: Implement next_page_token for pagination + Activities: activityMessages, + NextPageToken: nextPageToken, }, nil } diff --git a/server/router/api/v1/test/activity_service_test.go b/server/router/api/v1/test/activity_service_test.go new file mode 100644 index 000000000..53306c4c5 --- /dev/null +++ b/server/router/api/v1/test/activity_service_test.go @@ -0,0 +1,67 @@ +package test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + apiv1 "github.com/usememos/memos/proto/gen/api/v1" +) + +func TestListActivities(t *testing.T) { + ctx := context.Background() + + ts := NewTestService(t) + defer ts.Cleanup() + + // Create userOne + userOne, err := ts.CreateRegularUser(ctx, "test-user-1") + require.NoError(t, err) + userOneCtx := ts.CreateUserContext(ctx, userOne.ID) + + // Create userTwo + userTwo, err := ts.CreateRegularUser(ctx, "test-user-2") + require.NoError(t, err) + userTwoCtx := ts.CreateUserContext(ctx, userTwo.ID) + + // UserOne creates a memo + memo, err := ts.Service.CreateMemo(userOneCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "Base memo", + Visibility: apiv1.Visibility_PUBLIC, + }, + }) + require.NoError(t, err) + + // UserTwo creates 15 comments on the memo to generate 15 activities + for i := 0; i < 15; i++ { + _, err := ts.Service.CreateMemoComment(userTwoCtx, &apiv1.CreateMemoCommentRequest{ + Name: memo.Name, + Comment: &apiv1.Memo{ + Content: fmt.Sprintf("Comment %d", i), + Visibility: apiv1.Visibility_PUBLIC, + }, + }) + require.NoError(t, err) + } + + // List activities with page size 10 (as admin or userOne) + // Activities are visible to the receiver (UserOne) + resp, err := ts.Service.ListActivities(userOneCtx, &apiv1.ListActivitiesRequest{ + PageSize: 10, + }) + require.NoError(t, err) + require.Len(t, resp.Activities, 10) + require.NotEmpty(t, resp.NextPageToken) + + // List next page + resp, err = ts.Service.ListActivities(userOneCtx, &apiv1.ListActivitiesRequest{ + PageSize: 10, + PageToken: resp.NextPageToken, + }) + require.NoError(t, err) + require.Len(t, resp.Activities, 5) + require.Empty(t, resp.NextPageToken) +} diff --git a/store/activity.go b/store/activity.go index eb5b7c930..aa4533ea4 100644 --- a/store/activity.go +++ b/store/activity.go @@ -42,6 +42,10 @@ type Activity struct { type FindActivity struct { ID *int32 Type *ActivityType + + // Pagination + Limit *int + Offset *int } func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) { diff --git a/store/db/mysql/activity.go b/store/db/mysql/activity.go index fd36be4e4..bcc9dedc7 100644 --- a/store/db/mysql/activity.go +++ b/store/db/mysql/activity.go @@ -2,8 +2,10 @@ package mysql import ( "context" + "fmt" "strings" + "github.com/pkg/errors" "google.golang.org/protobuf/encoding/protojson" @@ -56,6 +58,13 @@ func (d *DB) ListActivities(ctx context.Context, find *store.FindActivity) ([]*s } query := "SELECT `id`, `creator_id`, `type`, `level`, `payload`, UNIX_TIMESTAMP(`created_ts`) FROM `activity` WHERE " + strings.Join(where, " AND ") + " ORDER BY `created_ts` DESC" + if find.Limit != nil { + query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit) + if find.Offset != nil { + query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset) + } + } + rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { return nil, err diff --git a/store/db/postgres/activity.go b/store/db/postgres/activity.go index 84e153283..ab8a2e329 100644 --- a/store/db/postgres/activity.go +++ b/store/db/postgres/activity.go @@ -2,8 +2,10 @@ package postgres import ( "context" + "fmt" "strings" + "github.com/pkg/errors" "google.golang.org/protobuf/encoding/protojson" @@ -44,6 +46,13 @@ func (d *DB) ListActivities(ctx context.Context, find *store.FindActivity) ([]*s } query := "SELECT id, creator_id, type, level, payload, created_ts FROM activity WHERE " + strings.Join(where, " AND ") + " ORDER BY created_ts DESC" + if find.Limit != nil { + query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit) + if find.Offset != nil { + query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset) + } + } + rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { return nil, err diff --git a/store/db/sqlite/activity.go b/store/db/sqlite/activity.go index 8bbe331ce..040a816b9 100644 --- a/store/db/sqlite/activity.go +++ b/store/db/sqlite/activity.go @@ -2,8 +2,10 @@ package sqlite import ( "context" + "fmt" "strings" + "github.com/pkg/errors" "google.golang.org/protobuf/encoding/protojson" @@ -46,6 +48,13 @@ func (d *DB) ListActivities(ctx context.Context, find *store.FindActivity) ([]*s } query := "SELECT `id`, `creator_id`, `type`, `level`, `payload`, `created_ts` FROM `activity` WHERE " + strings.Join(where, " AND ") + " ORDER BY `created_ts` DESC" + if find.Limit != nil { + query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit) + if find.Offset != nil { + query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset) + } + } + rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { return nil, err