|
|
@@ -64,6 +64,14 @@ void WorkflowGroupController::registerRoutes(httplib::Server& server) {
|
|
|
moveGroup(req, res, ctx);
|
|
|
});
|
|
|
});
|
|
|
+
|
|
|
+ // Transfer folder ownership, optionally taking its workflows with it -
|
|
|
+ // POST /api/v1/workflow-groups/:id/transfer-owner
|
|
|
+ server.Post(R"(/api/v1/workflow-groups/([^/]+)/transfer-owner)", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
+ transferGroupOwner(req, res, ctx);
|
|
|
+ });
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -471,6 +479,119 @@ void WorkflowGroupController::moveGroup(const httplib::Request& req, httplib::Re
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+bool WorkflowGroupController::checkTransferTarget(httplib::Response& res, const std::string& new_owner_id,
|
|
|
+ const std::string& project_id) {
|
|
|
+ if (new_owner_id.empty()) {
|
|
|
+ sendError(res, "newOwnerId is required", 400);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ auto user = storage_.get("users", new_owner_id);
|
|
|
+ if (user.failed()) {
|
|
|
+ sendError(res, "Unknown user id", 400);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // The new owner has to be somebody who can actually reach the project
|
|
|
+ // this folder lives in. A personal project cannot have members, so this
|
|
|
+ // also means: inside a personal project, only that project's own owner
|
|
|
+ // is a valid target. Refusing here, rather than transferring anyway, is
|
|
|
+ // deliberate - a transfer that leaves a record naming somebody who
|
|
|
+ // cannot see it is worse than an error that says why.
|
|
|
+ auth::AuthContext target_ctx;
|
|
|
+ target_ctx.user_id = new_owner_id;
|
|
|
+ target_ctx.role = user.value().value("role", "user");
|
|
|
+ if (access_.roleIn(target_ctx, project_id) == auth::ProjectRole::None) {
|
|
|
+ sendError(res, "That user has no access to the project this belongs to. "
|
|
|
+ "Add them to the project before transferring ownership to them.", 400);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+void WorkflowGroupController::transferGroupOwner(const httplib::Request& req, httplib::Response& res,
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
+ try {
|
|
|
+ std::string id = req.matches[1];
|
|
|
+
|
|
|
+ // Manage, not Write: transferring somebody's work is an
|
|
|
+ // administrative act on the project, not an edit to the folder.
|
|
|
+ nlohmann::json existing;
|
|
|
+ if (!loadAllowed(res, ctx, id, auth::Action::Manage, existing)) return;
|
|
|
+
|
|
|
+ auto body = nlohmann::json::parse(req.body);
|
|
|
+
|
|
|
+ const std::string new_owner_id = body.value("newOwnerId", "");
|
|
|
+ const std::string project = auth::AccessControl::projectOf(existing);
|
|
|
+ if (!checkTransferTarget(res, new_owner_id, project)) return;
|
|
|
+
|
|
|
+ const bool include_workflows = body.value("includeWorkflows", false);
|
|
|
+ const std::string previous_owner = existing.value("ownerId", "");
|
|
|
+
|
|
|
+ auto folder_result = storage_.update("workflow_groups", id, {{"ownerId", new_owner_id}}, 0, true);
|
|
|
+ if (folder_result.failed()) {
|
|
|
+ sendError(res, folder_result.error().message(), 500);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Only what is directly in this folder - a folder's ownership does
|
|
|
+ // not reach down into its subfolders' own workflows.
|
|
|
+ int transferred = 0;
|
|
|
+ nlohmann::json skipped = nlohmann::json::array();
|
|
|
+ if (include_workflows) {
|
|
|
+ storage::QueryOptions in_folder;
|
|
|
+ in_folder.page_size = 1000;
|
|
|
+ in_folder.filters.push_back({"groupId", id});
|
|
|
+ auto contents = storage_.query("workflows", in_folder);
|
|
|
+ if (contents.ok()) {
|
|
|
+ for (const auto& wf : contents.value().documents) {
|
|
|
+ const std::string wf_id = wf.value("_id", "");
|
|
|
+ if (wf_id.empty()) continue;
|
|
|
+
|
|
|
+ const std::string wf_owner = wf.value("ownerId", "");
|
|
|
+ // A workflow already owned by someone other than the
|
|
|
+ // folder's previous owner is somebody else's work that
|
|
|
+ // happens to sit in this folder. It is skipped rather
|
|
|
+ // than swept up, and reported rather than left
|
|
|
+ // unmentioned, so nobody's work is reassigned as a side
|
|
|
+ // effect of moving the folder around it.
|
|
|
+ if (!wf_owner.empty() && wf_owner != previous_owner) {
|
|
|
+ skipped.push_back({
|
|
|
+ {"id", wf_id},
|
|
|
+ {"name", wf.value("name", "")},
|
|
|
+ {"ownerId", wf_owner},
|
|
|
+ });
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (storage_.update("workflows", wf_id, {{"ownerId", new_owner_id}}, 0, true).ok()) {
|
|
|
+ transferred++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ auto group = storage_.get("workflow_groups", id);
|
|
|
+ nlohmann::json folder_json = group.ok() ? group.value() : nlohmann::json{{"id", id}};
|
|
|
+ if (group.ok()) {
|
|
|
+ ws_server_.broadcast("workflow_groups.updated", group.value());
|
|
|
+ }
|
|
|
+
|
|
|
+ LOG_INFO("Folder {} ownership transferred from {} to {} by {} ({} workflows transferred, "
|
|
|
+ "{} skipped as foreign-owned)",
|
|
|
+ id, previous_owner, new_owner_id, ctx.user_id, transferred, skipped.size());
|
|
|
+
|
|
|
+ sendJson(res, {
|
|
|
+ {"folder", folder_json},
|
|
|
+ {"includeWorkflows", include_workflows},
|
|
|
+ {"transferredWorkflowCount", transferred},
|
|
|
+ {"skippedWorkflows", skipped},
|
|
|
+ });
|
|
|
+ } catch (const std::exception& e) {
|
|
|
+ sendError(res, "Invalid request body", 400);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
std::vector<nlohmann::json> WorkflowGroupController::buildGroupPath(const std::string& group_id) {
|
|
|
std::vector<nlohmann::json> path;
|
|
|
std::string current_id = group_id;
|