diff --git a/handlers/activity_handler.go b/handlers/activity_handler.go index 41618eca5..9b8cba930 100644 --- a/handlers/activity_handler.go +++ b/handlers/activity_handler.go @@ -42,6 +42,25 @@ type ActivityResponse struct { Error string `json:"error,omitempty"` } +type WebhookActivityRequest struct { + ContentType string `json:"content_type"` + Content string `json:"content"` + Workspace string `json:"workspace"` + ThreadID string `json:"thread_id,omitempty"` + FeatureUUID string `json:"feature_uuid,omitempty"` + PhaseUUID string `json:"phase_uuid,omitempty"` + Actions []string `json:"actions,omitempty"` + Questions []string `json:"questions,omitempty"` + Author db.AuthorType `json:"author"` + AuthorRef string `json:"author_ref"` +} + +type WebhookResponse struct { + Success bool `json:"success"` + ActivityID string `json:"activity_id,omitempty"` + Error string `json:"error,omitempty"` +} + func (ah *activityHandler) GetActivity(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" { @@ -517,4 +536,94 @@ func (ah *activityHandler) RemoveActivityQuestion(w http.ResponseWriter, r *http Success: true, Data: updatedActivity, }) +} + +func (ah *activityHandler) ReceiveActivity(w http.ResponseWriter, r *http.Request) { + var req WebhookActivityRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(WebhookResponse{ + Success: false, + Error: "Invalid request payload", + }) + return + } + + if req.Author == db.HumansAuthor { + if len(req.AuthorRef) < 32 { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(WebhookResponse{ + Success: false, + Error: "invalid public key format for human author", + }) + return + } + } else if req.Author == db.HiveAuthor { + if _, err := uuid.Parse(req.AuthorRef); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(WebhookResponse{ + Success: false, + Error: "invalid UUID format for hive author", + }) + return + } + } + + if req.ThreadID != "" { + if _, err := uuid.Parse(req.ThreadID); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(WebhookResponse{ + Success: false, + Error: "invalid source ID format", + }) + return + } + } + + activity := &db.Activity{ + ID: uuid.New(), + ContentType: db.ContentType(req.ContentType), + Content: req.Content, + Workspace: req.Workspace, + FeatureUUID: req.FeatureUUID, + PhaseUUID: req.PhaseUUID, + Actions: req.Actions, + Questions: req.Questions, + Author: req.Author, + AuthorRef: req.AuthorRef, + TimeCreated: time.Now(), + TimeUpdated: time.Now(), + Status: "active", + } + + var createdActivity *db.Activity + var err error + + if req.ThreadID != "" { + createdActivity, err = ah.db.CreateActivityThread(req.ThreadID, activity) + } else { + createdActivity, err = ah.db.CreateActivity(activity) + } + + if err != nil { + status := http.StatusInternalServerError + if err == db.ErrInvalidContent || err == db.ErrInvalidAuthorRef || + err == db.ErrInvalidContentType || err == db.ErrInvalidAuthorType || + err == db.ErrInvalidWorkspace { + status = http.StatusBadRequest + } + + w.WriteHeader(status) + json.NewEncoder(w).Encode(WebhookResponse{ + Success: false, + Error: err.Error(), + }) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(WebhookResponse{ + Success: true, + ActivityID: createdActivity.ID.String(), + }) } \ No newline at end of file diff --git a/handlers/activity_test.go b/handlers/activity_test.go new file mode 100644 index 000000000..109ddd499 --- /dev/null +++ b/handlers/activity_test.go @@ -0,0 +1,191 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stakwork/sphinx-tribes/db" + "github.com/stretchr/testify/assert" +) + +const validHumanAuthorRef = "02abc123456789abcdef0123456789abcdef0123" + +func TestReceiveActivity(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + ah := NewActivityHandler(&http.Client{}, db.TestDB) + + tests := []struct { + name string + payload WebhookActivityRequest + expectedStatus int + expectedError string + validateFunc func(t *testing.T, resp WebhookResponse) + }{ + { + name: "successful new activity creation", + payload: WebhookActivityRequest{ + ContentType: "general_update", + Content: "Test content", + Workspace: "test-workspace", + Author: db.HumansAuthor, + AuthorRef: validHumanAuthorRef, + }, + expectedStatus: http.StatusCreated, + validateFunc: func(t *testing.T, resp WebhookResponse) { + assert.True(t, resp.Success) + assert.NotEmpty(t, resp.ActivityID) + + activity, err := db.TestDB.GetActivity(resp.ActivityID) + assert.NoError(t, err) + assert.Equal(t, "Test content", activity.Content) + assert.Equal(t, "test-workspace", activity.Workspace) + }, + }, + { + name: "successful thread activity creation", + payload: WebhookActivityRequest{ + ContentType: "general_update", + Content: "Thread reply", + Workspace: "test-workspace", + ThreadID: uuid.New().String(), + Author: db.HumansAuthor, + AuthorRef: validHumanAuthorRef, + }, + expectedStatus: http.StatusCreated, + validateFunc: func(t *testing.T, resp WebhookResponse) { + assert.True(t, resp.Success) + assert.NotEmpty(t, resp.ActivityID) + + activity, err := db.TestDB.GetActivity(resp.ActivityID) + assert.NoError(t, err) + assert.Equal(t, "Thread reply", activity.Content) + assert.Equal(t, "test-workspace", activity.Workspace) + }, + }, + { + name: "invalid content - empty", + payload: WebhookActivityRequest{ + ContentType: "general_update", + Content: "", + Workspace: "test-workspace", + Author: db.HumansAuthor, + AuthorRef: validHumanAuthorRef, + }, + expectedStatus: http.StatusBadRequest, + expectedError: "content must not be empty and must be less than 10000 characters", + }, + { + name: "invalid author type", + payload: WebhookActivityRequest{ + ContentType: "general_update", + Content: "Test content", + Workspace: "test-workspace", + Author: "invalid", + AuthorRef: validHumanAuthorRef, + }, + expectedStatus: http.StatusBadRequest, + expectedError: "invalid author type", + }, + { + name: "invalid human author ref", + payload: WebhookActivityRequest{ + ContentType: "general_update", + Content: "Test content", + Workspace: "test-workspace", + Author: db.HumansAuthor, + AuthorRef: "short", + }, + expectedStatus: http.StatusBadRequest, + expectedError: "invalid public key format for human author", + }, + { + name: "invalid hive author ref", + payload: WebhookActivityRequest{ + ContentType: "general_update", + Content: "Test content", + Workspace: "test-workspace", + Author: db.HiveAuthor, + AuthorRef: "not-a-uuid", + }, + expectedStatus: http.StatusBadRequest, + expectedError: "invalid UUID format for hive author", + }, + { + name: "invalid thread ID format", + payload: WebhookActivityRequest{ + ContentType: "general_update", + Content: "Test content", + Workspace: "test-workspace", + ThreadID: "not-a-uuid", + Author: db.HumansAuthor, + AuthorRef: validHumanAuthorRef, + }, + expectedStatus: http.StatusBadRequest, + expectedError: "invalid source ID format", + }, + { + name: "missing workspace", + payload: WebhookActivityRequest{ + ContentType: "general_update", + Content: "Test content", + Author: db.HumansAuthor, + AuthorRef: validHumanAuthorRef, + }, + expectedStatus: http.StatusBadRequest, + expectedError: "workspace is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payloadBytes, err := json.Marshal(tt.payload) + assert.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/activities/receive", bytes.NewReader(payloadBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + ah.ReceiveActivity(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + + var response WebhookResponse + err = json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + + if tt.expectedError != "" { + assert.False(t, response.Success) + assert.Equal(t, tt.expectedError, response.Error) + } else if tt.validateFunc != nil { + tt.validateFunc(t, response) + } + }) + } +} + +func TestReceiveActivity_InvalidJSON(t *testing.T) { + teardownSuite := SetupSuite(t) + defer teardownSuite(t) + + ah := NewActivityHandler(&http.Client{}, db.TestDB) + + req := httptest.NewRequest(http.MethodPost, "/activities/receive", bytes.NewReader([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + ah.ReceiveActivity(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var response WebhookResponse + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + assert.False(t, response.Success) + assert.Equal(t, "Invalid request payload", response.Error) +} diff --git a/routes/activity_routes.go b/routes/activity_routes.go index 8ec70ffb7..be9dd6d40 100644 --- a/routes/activity_routes.go +++ b/routes/activity_routes.go @@ -17,6 +17,7 @@ func ActivityRoutes() chi.Router { r.Get("/{id}", activityHandler.GetActivity) r.Get("/thread/{thread_id}", activityHandler.GetActivitiesByThread) r.Get("/thread/{thread_id}/latest", activityHandler.GetLatestActivityByThread) + r.Post("/receive", activityHandler.ReceiveActivity) }) r.Group(func(r chi.Router) {