diff --git a/db/bounty_ownership.go b/db/bounty_ownership.go new file mode 100644 index 000000000..3dbbd99ed --- /dev/null +++ b/db/bounty_ownership.go @@ -0,0 +1,34 @@ +package db + +import ( + "errors" + "time" +) + +func (db database) TransferWorkspaceBountyOwnership(workspaceUUID string, fromOwnerID string, toOwnerID string) (int64, error) { + if workspaceUUID == "" { + return 0, errors.New("workspace_uuid is required") + } + if fromOwnerID == "" { + return 0, errors.New("from_owner_id is required") + } + if toOwnerID == "" { + return 0, errors.New("to_owner_id is required") + } + if fromOwnerID == toOwnerID { + return 0, errors.New("from_owner_id and to_owner_id must be different") + } + + now := time.Now() + result := db.db.Model(&NewBounty{}). + Where("workspace_uuid = ? AND owner_id = ? AND paid = ?", workspaceUUID, fromOwnerID, false). + Updates(map[string]interface{}{ + "owner_id": toOwnerID, + "updated": &now, + }) + if result.Error != nil { + return 0, result.Error + } + + return result.RowsAffected, nil +} diff --git a/db/interface.go b/db/interface.go index e59a0ab22..7014f77b7 100644 --- a/db/interface.go +++ b/db/interface.go @@ -56,6 +56,7 @@ type Database interface { UpdateBounty(b NewBounty) (NewBounty, error) UpdateBountyPaymentStatuses(bounty NewBounty) (NewBounty, error) UpdateBountyPayment(b NewBounty) (NewBounty, error) + TransferWorkspaceBountyOwnership(workspaceUUID string, fromOwnerID string, toOwnerID string) (int64, error) GetListedOffers(r *http.Request) ([]PeopleExtra, error) UpdateBot(uuid string, u map[string]interface{}) bool GetAllTribes() []Tribe diff --git a/handlers/bounty.go b/handlers/bounty.go index aeba337e0..5c38cf282 100644 --- a/handlers/bounty.go +++ b/handlers/bounty.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "strconv" + "strings" "sync" "time" @@ -37,6 +38,19 @@ type BountyTimingResponse struct { AccumulatedPauseSeconds int `json:"accumulated_pause_seconds"` } +type TransferBountyOwnershipRequest struct { + WorkspaceUuid string `json:"workspace_uuid"` + FromOwnerID string `json:"from_owner_id"` + ToOwnerID string `json:"to_owner_id"` +} + +type TransferBountyOwnershipResponse struct { + WorkspaceUuid string `json:"workspace_uuid"` + FromOwnerID string `json:"from_owner_id"` + ToOwnerID string `json:"to_owner_id"` + UpdatedCount int64 `json:"updated_count"` +} + type bountyHandler struct { httpClient HttpClient db db.Database @@ -76,6 +90,110 @@ func handleTimingError(w http.ResponseWriter, operation string, err error) { }) } +// TransferBountyOwnership godoc +// +// @Summary Transfer workspace bounty ownership +// @Description Transfer unpaid bounty ownership from one account to another within a workspace. +// @Tags Bounties +// @Accept json +// @Produce json +// @Security PubKeyContextAuth +// @Param request body TransferBountyOwnershipRequest true "Transfer request" +// @Success 200 {object} TransferBountyOwnershipResponse +// @Router /gobounties/owner/transfer [patch] +func (h *bountyHandler) TransferBountyOwnership(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + if pubKeyFromAuth == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + user := h.db.GetPersonByPubkey(pubKeyFromAuth) + if user.OwnerPubKey == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + var req TransferBountyOwnershipRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode("Invalid request body") + return + } + + req.WorkspaceUuid = strings.TrimSpace(req.WorkspaceUuid) + req.FromOwnerID = strings.TrimSpace(req.FromOwnerID) + req.ToOwnerID = strings.TrimSpace(req.ToOwnerID) + if req.ToOwnerID == "" { + req.ToOwnerID = pubKeyFromAuth + } + + if req.WorkspaceUuid == "" || req.FromOwnerID == "" || req.ToOwnerID == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode("workspace_uuid, from_owner_id, and to_owner_id are required") + return + } + if req.FromOwnerID == req.ToOwnerID { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode("from_owner_id and to_owner_id must be different") + return + } + + workspace := h.db.GetWorkspaceByUuid(req.WorkspaceUuid) + if workspace.Uuid != req.WorkspaceUuid { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode("Workspace not found") + return + } + + sourceOwner := h.db.GetPersonByPubkey(req.FromOwnerID) + if sourceOwner.OwnerPubKey == "" { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode("Source owner not found") + return + } + + targetOwner := h.db.GetPersonByPubkey(req.ToOwnerID) + if targetOwner.OwnerPubKey == "" { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode("Target owner not found") + return + } + + isWorkspaceOwner := pubKeyFromAuth == workspace.OwnerPubKey + isSourceOwner := pubKeyFromAuth == req.FromOwnerID + hasManageBountyRole := h.userHasManageBountyRoles(pubKeyFromAuth, req.WorkspaceUuid) + if !isWorkspaceOwner && !isSourceOwner && !hasManageBountyRole { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode("You don't have permission to transfer bounty ownership") + return + } + + targetWorkspaceUser := h.db.GetWorkspaceUser(req.ToOwnerID, req.WorkspaceUuid) + if req.ToOwnerID != workspace.OwnerPubKey && targetWorkspaceUser.OwnerPubKey != req.ToOwnerID { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode("Target owner must belong to the workspace") + return + } + + updatedCount, err := h.db.TransferWorkspaceBountyOwnership(req.WorkspaceUuid, req.FromOwnerID, req.ToOwnerID) + if err != nil { + logger.Log.Error("[bounty] Transfer owner error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode("Could not transfer bounty ownership") + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(TransferBountyOwnershipResponse{ + WorkspaceUuid: req.WorkspaceUuid, + FromOwnerID: req.FromOwnerID, + ToOwnerID: req.ToOwnerID, + UpdatedCount: updatedCount, + }) +} + // GetAllBounties godoc // // @Summary Get all bounties @@ -2803,14 +2921,14 @@ func (h *bountyHandler) GetBountiesByWorkspaceTime(w http.ResponseWriter, r *htt func (h *bountyHandler) CreateBountyStake(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - + if pubKeyFromAuth == "" { logger.Log.Error("[bounty_stake] no pubkey from auth") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) return } - + var stake db.BountyStake if err := json.NewDecoder(r.Body).Decode(&stake); err != nil { logger.Log.Error("[bounty_stake] invalid request body: %v", err) @@ -2818,9 +2936,9 @@ func (h *bountyHandler) CreateBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) return } - + stake.HunterPubKey = pubKeyFromAuth - + createdStake, err := h.db.CreateBountyStake(stake) if err != nil { logger.Log.Error("[bounty_stake] failed to create stake: %v", err) @@ -2828,7 +2946,7 @@ func (h *bountyHandler) CreateBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } - + w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createdStake) } @@ -2850,7 +2968,7 @@ func (h *bountyHandler) GetAllBountyStakes(w http.ResponseWriter, r *http.Reques json.NewEncoder(w).Encode(map[string]string{"error": "Failed to retrieve stakes"}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(stakes) } @@ -2875,7 +2993,7 @@ func (h *bountyHandler) GetBountyStakesByBountyID(w http.ResponseWriter, r *http json.NewEncoder(w).Encode(map[string]string{"error": "Invalid bounty ID"}) return } - + stakes, err := h.db.GetBountyStakesByBountyID(bountyID) if err != nil { logger.Log.Error("[bounty_stake] failed to get stakes by bounty ID: %v", err) @@ -2883,7 +3001,7 @@ func (h *bountyHandler) GetBountyStakesByBountyID(w http.ResponseWriter, r *http json.NewEncoder(w).Encode(map[string]string{"error": "Failed to retrieve stakes"}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(stakes) } @@ -2909,7 +3027,7 @@ func (h *bountyHandler) GetBountyStakeByID(w http.ResponseWriter, r *http.Reques json.NewEncoder(w).Encode(map[string]string{"error": "Invalid stake ID"}) return } - + stake, err := h.db.GetBountyStakeByID(id) if err != nil { logger.Log.Error("[bounty_stake] failed to get stake by ID: %v", err) @@ -2917,7 +3035,7 @@ func (h *bountyHandler) GetBountyStakeByID(w http.ResponseWriter, r *http.Reques json.NewEncoder(w).Encode(map[string]string{"error": "Stake not found"}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(stake) } @@ -2934,7 +3052,7 @@ func (h *bountyHandler) GetBountyStakeByID(w http.ResponseWriter, r *http.Reques // @Router /gobounties/stake/hunter/{hunterPubKey} [get] func (h *bountyHandler) GetBountyStakesByHunterPubKey(w http.ResponseWriter, r *http.Request) { hunterPubKey := chi.URLParam(r, "hunterPubKey") - + stakes, err := h.db.GetBountyStakesByHunterPubKey(hunterPubKey) if err != nil { logger.Log.Error("[bounty_stake] failed to get stakes by hunter pubkey: %v", err) @@ -2942,7 +3060,7 @@ func (h *bountyHandler) GetBountyStakesByHunterPubKey(w http.ResponseWriter, r * json.NewEncoder(w).Encode(map[string]string{"error": "Failed to retrieve stakes"}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(stakes) } @@ -2966,14 +3084,14 @@ func (h *bountyHandler) GetBountyStakesByHunterPubKey(w http.ResponseWriter, r * func (h *bountyHandler) UpdateBountyStake(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - + if pubKeyFromAuth == "" { logger.Log.Error("[bounty_stake] no pubkey from auth") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) return } - + idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { @@ -2982,7 +3100,7 @@ func (h *bountyHandler) UpdateBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": "Invalid stake ID"}) return } - + existingStake, err := h.db.GetBountyStakeByID(id) if err != nil { logger.Log.Error("[bounty_stake] stake not found: %v", err) @@ -2990,7 +3108,7 @@ func (h *bountyHandler) UpdateBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": "Stake not found"}) return } - + bounty := h.db.GetBounty(existingStake.BountyID) if existingStake.HunterPubKey != pubKeyFromAuth && bounty.OwnerID != pubKeyFromAuth { logger.Log.Error("[bounty_stake] unauthorized update attempt") @@ -2998,7 +3116,7 @@ func (h *bountyHandler) UpdateBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": "You are not authorized to update this stake"}) return } - + var updates map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { logger.Log.Error("[bounty_stake] invalid request body: %v", err) @@ -3006,7 +3124,7 @@ func (h *bountyHandler) UpdateBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) return } - + updatedStake, err := h.db.UpdateBountyStake(id, updates) if err != nil { logger.Log.Error("[bounty_stake] failed to update stake: %v", err) @@ -3014,7 +3132,7 @@ func (h *bountyHandler) UpdateBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(updatedStake) } @@ -3036,14 +3154,14 @@ func (h *bountyHandler) UpdateBountyStake(w http.ResponseWriter, r *http.Request func (h *bountyHandler) DeleteBountyStake(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - + if pubKeyFromAuth == "" { logger.Log.Error("[bounty_stake] no pubkey from auth") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) return } - + idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { @@ -3052,7 +3170,7 @@ func (h *bountyHandler) DeleteBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": "Invalid stake ID"}) return } - + existingStake, err := h.db.GetBountyStakeByID(id) if err != nil { logger.Log.Error("[bounty_stake] stake not found: %v", err) @@ -3060,7 +3178,7 @@ func (h *bountyHandler) DeleteBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": "Stake not found"}) return } - + bounty := h.db.GetBounty(existingStake.BountyID) if existingStake.HunterPubKey != pubKeyFromAuth && bounty.OwnerID != pubKeyFromAuth { logger.Log.Error("[bounty_stake] unauthorized delete attempt") @@ -3068,7 +3186,7 @@ func (h *bountyHandler) DeleteBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": "You are not authorized to delete this stake"}) return } - + err = h.db.DeleteBountyStake(id) if err != nil { logger.Log.Error("[bounty_stake] failed to delete stake: %v", err) @@ -3076,7 +3194,7 @@ func (h *bountyHandler) DeleteBountyStake(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"message": "Stake deleted successfully"}) } @@ -3098,14 +3216,14 @@ func (h *bountyHandler) DeleteBountyStake(w http.ResponseWriter, r *http.Request func (h *bountyHandler) CreateBountyStakeProcess(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - + if pubKeyFromAuth == "" { logger.Log.Error("[bounty_stake_process] no pubkey from auth") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) return } - + var process db.BountyStakeProcess if err := json.NewDecoder(r.Body).Decode(&process); err != nil { logger.Log.Error("[bounty_stake_process] invalid request body: %v", err) @@ -3113,11 +3231,11 @@ func (h *bountyHandler) CreateBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) return } - + process.HunterPubKey = pubKeyFromAuth - + process.Status = db.StakeProcessStatusNew - + createdProcess, err := h.db.CreateBountyStakeProcess(&process) if err != nil { logger.Log.Error("[bounty_stake_process] failed to create stake process: %v", err) @@ -3125,7 +3243,7 @@ func (h *bountyHandler) CreateBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } - + w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createdProcess) } @@ -3144,14 +3262,14 @@ func (h *bountyHandler) CreateBountyStakeProcess(w http.ResponseWriter, r *http. func (h *bountyHandler) GetAllBountyStakeProcesses(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - + if pubKeyFromAuth == "" { logger.Log.Error("[bounty_stake_process] no pubkey from auth") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) return } - + processes, err := h.db.GetAllBountyStakeProcesses() if err != nil { logger.Log.Error("[bounty_stake_process] failed to get stake processes: %v", err) @@ -3159,7 +3277,7 @@ func (h *bountyHandler) GetAllBountyStakeProcesses(w http.ResponseWriter, r *htt json.NewEncoder(w).Encode(map[string]string{"error": "Failed to retrieve stake processes"}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(processes) } @@ -3181,14 +3299,14 @@ func (h *bountyHandler) GetAllBountyStakeProcesses(w http.ResponseWriter, r *htt func (h *bountyHandler) GetBountyStakeProcessByID(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - + if pubKeyFromAuth == "" { logger.Log.Error("[bounty_stake_process] no pubkey from auth") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) return } - + idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { @@ -3197,7 +3315,7 @@ func (h *bountyHandler) GetBountyStakeProcessByID(w http.ResponseWriter, r *http json.NewEncoder(w).Encode(map[string]string{"error": "Invalid process ID"}) return } - + process, err := h.db.GetBountyStakeProcessByID(id) if err != nil { logger.Log.Error("[bounty_stake_process] failed to get process by ID: %v", err) @@ -3205,7 +3323,7 @@ func (h *bountyHandler) GetBountyStakeProcessByID(w http.ResponseWriter, r *http json.NewEncoder(w).Encode(map[string]string{"error": "Stake process not found"}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(process) } @@ -3229,14 +3347,14 @@ func (h *bountyHandler) GetBountyStakeProcessByID(w http.ResponseWriter, r *http func (h *bountyHandler) UpdateBountyStakeProcess(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - + if pubKeyFromAuth == "" { logger.Log.Error("[bounty_stake_process] no pubkey from auth") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) return } - + idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { @@ -3245,7 +3363,7 @@ func (h *bountyHandler) UpdateBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": "Invalid process ID"}) return } - + existingProcess, err := h.db.GetBountyStakeProcessByID(id) if err != nil { logger.Log.Error("[bounty_stake_process] process not found: %v", err) @@ -3253,7 +3371,7 @@ func (h *bountyHandler) UpdateBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": "Stake process not found"}) return } - + bounty := h.db.GetBounty(existingProcess.BountyID) if existingProcess.HunterPubKey != pubKeyFromAuth && bounty.OwnerID != pubKeyFromAuth { logger.Log.Error("[bounty_stake_process] unauthorized update attempt") @@ -3261,7 +3379,7 @@ func (h *bountyHandler) UpdateBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": "You are not authorized to update this stake process"}) return } - + var updates map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { logger.Log.Error("[bounty_stake_process] invalid request body: %v", err) @@ -3269,7 +3387,7 @@ func (h *bountyHandler) UpdateBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) return } - + updatedProcess, err := h.db.UpdateBountyStakeProcess(id, updates) if err != nil { logger.Log.Error("[bounty_stake_process] failed to update process: %v", err) @@ -3277,7 +3395,7 @@ func (h *bountyHandler) UpdateBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(updatedProcess) } @@ -3299,14 +3417,14 @@ func (h *bountyHandler) UpdateBountyStakeProcess(w http.ResponseWriter, r *http. func (h *bountyHandler) DeleteBountyStakeProcess(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) - + if pubKeyFromAuth == "" { logger.Log.Error("[bounty_stake_process] no pubkey from auth") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) return } - + idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { @@ -3315,7 +3433,7 @@ func (h *bountyHandler) DeleteBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": "Invalid process ID"}) return } - + existingProcess, err := h.db.GetBountyStakeProcessByID(id) if err != nil { logger.Log.Error("[bounty_stake_process] process not found: %v", err) @@ -3323,7 +3441,7 @@ func (h *bountyHandler) DeleteBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": "Stake process not found"}) return } - + bounty := h.db.GetBounty(existingProcess.BountyID) if existingProcess.HunterPubKey != pubKeyFromAuth && bounty.OwnerID != pubKeyFromAuth { logger.Log.Error("[bounty_stake_process] unauthorized delete attempt") @@ -3331,7 +3449,7 @@ func (h *bountyHandler) DeleteBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": "You are not authorized to delete this stake process"}) return } - + err = h.db.DeleteBountyStakeProcess(id) if err != nil { logger.Log.Error("[bounty_stake_process] failed to delete process: %v", err) @@ -3339,7 +3457,7 @@ func (h *bountyHandler) DeleteBountyStakeProcess(w http.ResponseWriter, r *http. json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } - + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"message": "Stake process deleted successfully"}) } diff --git a/handlers/bounty_test.go b/handlers/bounty_test.go index c9b24b987..c2ae9c2e2 100644 --- a/handlers/bounty_test.go +++ b/handlers/bounty_test.go @@ -115,6 +115,78 @@ func SetupSuite(_ *testing.T) func(tb testing.TB) { } } +func TestTransferBountyOwnership(t *testing.T) { + const ( + workspaceUUID = "workspace-uuid" + oldOwner = "old-owner-pubkey" + newOwner = "new-owner-pubkey" + ) + + t.Run("transfers unpaid workspace bounties for a manager", func(t *testing.T) { + mockDb := dbMocks.NewDatabase(t) + mockHttpClient := mocks.NewHttpClient(t) + bHandler := NewBountyHandler(mockHttpClient, mockDb) + bHandler.userHasManageBountyRoles = func(pubKeyFromAuth string, uuid string) bool { + return pubKeyFromAuth == newOwner && uuid == workspaceUUID + } + + mockDb.EXPECT().GetPersonByPubkey(newOwner).Return(db.Person{OwnerPubKey: newOwner}).Once() + mockDb.EXPECT().GetWorkspaceByUuid(workspaceUUID).Return(db.Workspace{ + Uuid: workspaceUUID, + OwnerPubKey: oldOwner, + }).Once() + mockDb.EXPECT().GetPersonByPubkey(oldOwner).Return(db.Person{OwnerPubKey: oldOwner}).Once() + mockDb.EXPECT().GetPersonByPubkey(newOwner).Return(db.Person{OwnerPubKey: newOwner}).Once() + mockDb.EXPECT().GetWorkspaceUser(newOwner, workspaceUUID).Return(db.WorkspaceUsers{ + OwnerPubKey: newOwner, + WorkspaceUuid: workspaceUUID, + }).Once() + mockDb.EXPECT().TransferWorkspaceBountyOwnership(workspaceUUID, oldOwner, newOwner).Return(int64(6), nil).Once() + + body := fmt.Sprintf(`{"workspace_uuid":%q,"from_owner_id":%q,"to_owner_id":%q}`, workspaceUUID, oldOwner, newOwner) + req := httptest.NewRequest(http.MethodPatch, "/gobounties/owner/transfer", strings.NewReader(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.ContextKey, newOwner)) + rr := httptest.NewRecorder() + + http.HandlerFunc(bHandler.TransferBountyOwnership).ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var response TransferBountyOwnershipResponse + assert.NoError(t, json.NewDecoder(rr.Body).Decode(&response)) + assert.Equal(t, int64(6), response.UpdatedCount) + assert.Equal(t, oldOwner, response.FromOwnerID) + assert.Equal(t, newOwner, response.ToOwnerID) + }) + + t.Run("rejects requester without source ownership or workspace bounty role", func(t *testing.T) { + const requester = "other-pubkey" + + mockDb := dbMocks.NewDatabase(t) + mockHttpClient := mocks.NewHttpClient(t) + bHandler := NewBountyHandler(mockHttpClient, mockDb) + bHandler.userHasManageBountyRoles = func(pubKeyFromAuth string, uuid string) bool { + return false + } + + mockDb.EXPECT().GetPersonByPubkey(requester).Return(db.Person{OwnerPubKey: requester}).Once() + mockDb.EXPECT().GetWorkspaceByUuid(workspaceUUID).Return(db.Workspace{ + Uuid: workspaceUUID, + OwnerPubKey: oldOwner, + }).Once() + mockDb.EXPECT().GetPersonByPubkey(oldOwner).Return(db.Person{OwnerPubKey: oldOwner}).Once() + mockDb.EXPECT().GetPersonByPubkey(newOwner).Return(db.Person{OwnerPubKey: newOwner}).Once() + + body := fmt.Sprintf(`{"workspace_uuid":%q,"from_owner_id":%q,"to_owner_id":%q}`, workspaceUUID, oldOwner, newOwner) + req := httptest.NewRequest(http.MethodPatch, "/gobounties/owner/transfer", strings.NewReader(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.ContextKey, requester)) + rr := httptest.NewRecorder() + + http.HandlerFunc(bHandler.TransferBountyOwnership).ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + }) +} + func AddExisitingDB(existingBounty db.NewBounty) { bounty := db.TestDB.GetBounty(1) if bounty.ID == 0 { @@ -1165,7 +1237,7 @@ func TestGetBountyIndexById(t *testing.T) { OwnerID: bountyOwner.OwnerPubKey, Show: true, Created: now, - MaxStakers: 1, + MaxStakers: 1, } db.TestDB.CreateOrEditBounty(bounty) diff --git a/mocks/Database.go b/mocks/Database.go index 5c98b7f66..47736ee30 100644 --- a/mocks/Database.go +++ b/mocks/Database.go @@ -17486,7 +17486,6 @@ func (_c *Database_GetAllBountyStakes_Call) RunAndReturn(run func() ([]db.Bounty return _c } - func (_m *Database) GetBountyStakesByBountyID(bountyID uint) ([]db.BountyStake, error) { ret := _m.Called(bountyID) @@ -17703,7 +17702,6 @@ func (_c *Database_UpdateBountyStake_Call) RunAndReturn(run func(uuid.UUID, map[ return _c } - func (_m *Database) DeleteBountyStake(stakeID uuid.UUID) error { ret := _m.Called(stakeID) @@ -17746,7 +17744,6 @@ func (_c *Database_DeleteBountyStake_Call) RunAndReturn(run func(uuid.UUID) erro return _c } - func (_m *Database) AddChatStatus(status *db.ChatWorkflowStatus) (db.ChatWorkflowStatus, error) { ret := _m.Called(status) @@ -17778,7 +17775,6 @@ type Database_AddChatStatus_Call struct { *mock.Call } - func (_e *Database_Expecter) AddChatStatus(status interface{}) *Database_AddChatStatus_Call { return &Database_AddChatStatus_Call{Call: _e.mock.On("AddChatStatus", status)} } @@ -17800,7 +17796,6 @@ func (_c *Database_AddChatStatus_Call) RunAndReturn(run func(*db.ChatWorkflowSta return _c } - func (_m *Database) UpdateChatStatus(status *db.ChatWorkflowStatus) (db.ChatWorkflowStatus, error) { ret := _m.Called(status) @@ -17832,7 +17827,6 @@ type Database_UpdateChatStatus_Call struct { *mock.Call } - func (_e *Database_Expecter) UpdateChatStatus(status interface{}) *Database_UpdateChatStatus_Call { return &Database_UpdateChatStatus_Call{Call: _e.mock.On("UpdateChatStatus", status)} } @@ -17908,7 +17902,6 @@ func (_c *Database_GetChatStatusByChatID_Call) RunAndReturn(run func(string) ([] return _c } - func (_m *Database) GetLatestChatStatusByChatID(chatID string) (db.ChatWorkflowStatus, error) { ret := _m.Called(chatID) @@ -17982,7 +17975,6 @@ type Database_DeleteChatStatus_Call struct { *mock.Call } - func (_e *Database_Expecter) DeleteChatStatus(_a0 interface{}) *Database_DeleteChatStatus_Call { return &Database_DeleteChatStatus_Call{Call: _e.mock.On("DeleteChatStatus", _a0)} } @@ -18110,7 +18102,6 @@ func (_c *Database_CreateBountyStakeProcess_Call) RunAndReturn(run func(*db.Boun return _c } - func (_m *Database) GetBountyStakeProcessByID(id uuid.UUID) (*db.BountyStakeProcess, error) { ret := _m.Called(id) @@ -18219,7 +18210,6 @@ func (_c *Database_GetBountyStakeProcessesByBountyID_Call) RunAndReturn(run func return _c } - func (_m *Database) GetBountyStakeProcessesByHunterPubKey(hunterPubKey string) ([]db.BountyStakeProcess, error) { ret := _m.Called(hunterPubKey) @@ -18328,7 +18318,6 @@ func (_c *Database_GetAllBountyStakeProcesses_Call) RunAndReturn(run func() ([]d return _c } - func (_m *Database) UpdateBountyStakeProcess(id uuid.UUID, updates map[string]interface{}) (*db.BountyStakeProcess, error) { ret := _m.Called(id, updates) @@ -18423,4 +18412,112 @@ func (_c *Database_DeleteBountyStakeProcess_Call) Return(_a0 error) *Database_De func (_c *Database_DeleteBountyStakeProcess_Call) RunAndReturn(run func(uuid.UUID) error) *Database_DeleteBountyStakeProcess_Call { _c.Call.Return(run) return _c -} \ No newline at end of file +} + +// GetBountyByUnlockCode provides a mock function with given fields: code +func (_m *Database) GetBountyByUnlockCode(code string) (db.NewBounty, error) { + ret := _m.Called(code) + + if len(ret) == 0 { + panic("no return value specified for GetBountyByUnlockCode") + } + + var r0 db.NewBounty + if rf, ok := ret.Get(0).(func(string) db.NewBounty); ok { + r0 = rf(code) + } else { + r0 = ret.Get(0).(db.NewBounty) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(code) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Database_GetBountyByUnlockCode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBountyByUnlockCode' +type Database_GetBountyByUnlockCode_Call struct { + *mock.Call +} + +// GetBountyByUnlockCode is a helper method to define mock.On call +// - code string +func (_e *Database_Expecter) GetBountyByUnlockCode(code interface{}) *Database_GetBountyByUnlockCode_Call { + return &Database_GetBountyByUnlockCode_Call{Call: _e.mock.On("GetBountyByUnlockCode", code)} +} + +func (_c *Database_GetBountyByUnlockCode_Call) Run(run func(code string)) *Database_GetBountyByUnlockCode_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetBountyByUnlockCode_Call) Return(_a0 db.NewBounty, _a1 error) *Database_GetBountyByUnlockCode_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_GetBountyByUnlockCode_Call) RunAndReturn(run func(string) (db.NewBounty, error)) *Database_GetBountyByUnlockCode_Call { + _c.Call.Return(run) + return _c +} + +// TransferWorkspaceBountyOwnership provides a mock function with given fields: workspaceUUID, fromOwnerID, toOwnerID +func (_m *Database) TransferWorkspaceBountyOwnership(workspaceUUID string, fromOwnerID string, toOwnerID string) (int64, error) { + ret := _m.Called(workspaceUUID, fromOwnerID, toOwnerID) + + if len(ret) == 0 { + panic("no return value specified for TransferWorkspaceBountyOwnership") + } + + var r0 int64 + if rf, ok := ret.Get(0).(func(string, string, string) int64); ok { + r0 = rf(workspaceUUID, fromOwnerID, toOwnerID) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = rf(workspaceUUID, fromOwnerID, toOwnerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Database_TransferWorkspaceBountyOwnership_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TransferWorkspaceBountyOwnership' +type Database_TransferWorkspaceBountyOwnership_Call struct { + *mock.Call +} + +// TransferWorkspaceBountyOwnership is a helper method to define mock.On call +// - workspaceUUID string +// - fromOwnerID string +// - toOwnerID string +func (_e *Database_Expecter) TransferWorkspaceBountyOwnership(workspaceUUID interface{}, fromOwnerID interface{}, toOwnerID interface{}) *Database_TransferWorkspaceBountyOwnership_Call { + return &Database_TransferWorkspaceBountyOwnership_Call{Call: _e.mock.On("TransferWorkspaceBountyOwnership", workspaceUUID, fromOwnerID, toOwnerID)} +} + +func (_c *Database_TransferWorkspaceBountyOwnership_Call) Run(run func(workspaceUUID string, fromOwnerID string, toOwnerID string)) *Database_TransferWorkspaceBountyOwnership_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *Database_TransferWorkspaceBountyOwnership_Call) Return(_a0 int64, _a1 error) *Database_TransferWorkspaceBountyOwnership_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_TransferWorkspaceBountyOwnership_Call) RunAndReturn(run func(string, string, string) (int64, error)) *Database_TransferWorkspaceBountyOwnership_Call { + _c.Call.Return(run) + return _c +} diff --git a/routes/bounty.go b/routes/bounty.go index c3157283b..99653094b 100644 --- a/routes/bounty.go +++ b/routes/bounty.go @@ -60,6 +60,7 @@ func BountyRoutes() chi.Router { r.Patch("/{id}/proofs/{proofId}/status", bountyHandler.UpdateProofStatus) r.Post("/", bountyHandler.CreateOrEditBounty) + r.Patch("/owner/transfer", bountyHandler.TransferBountyOwnership) r.Delete("/assignee", bountyHandler.DeleteBountyAssignee) r.Delete("/{pubkey}/{created}", bountyHandler.DeleteBounty) r.Post("/paymentstatus/{created}", handlers.UpdatePaymentStatus)