Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Webhook Endpoint for Activity Updates from Stakwork #2549

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions handlers/activity_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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(),
})
}
191 changes: 191 additions & 0 deletions handlers/activity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions routes/activity_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading