From 40afecbcddb1e78a98be5f3ea2a58a9d0365b46b Mon Sep 17 00:00:00 2001 From: Christopher Wisse Date: Wed, 8 Oct 2025 10:44:50 +0200 Subject: [PATCH] feat(slack/onboard-llmo): add option to remove site enrollment --- package-lock.json | 59 ----- src/controllers/llmo/llmo-onboarding.js | 33 ++- src/controllers/slack.js | 1 + src/support/slack/actions/index.js | 4 + .../slack/actions/onboard-llmo-modal.js | 206 ++++++++++++++++++ src/support/slack/commands/llmo-onboard.js | 94 +++++--- test/controllers/llmo-onboarding.test.js | 174 +++++++++++++++ .../slack/commands/llmo-onboard.test.js | 92 +++++++- 8 files changed, 557 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7828ffd88..a4b2ad49c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -645,7 +645,6 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.2.3.tgz", "integrity": "sha512-13lpLUAyPvitjNh6eVZKjh8+kE+0LGHElyIaV0X7wOqUn371+BR9LrfS5seOpcIDAFNpEbL4T68rgFa1/aQrbA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@adobe/fetch": "4.2.3", "aws4": "1.13.2" @@ -4304,7 +4303,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -5850,7 +5848,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -7126,7 +7123,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -8420,7 +8416,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -9200,7 +9195,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -9346,7 +9340,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -12867,7 +12860,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -13952,7 +13944,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -14360,7 +14351,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.721.0.tgz", "integrity": "sha512-jwsgdUEbNJqs1O0AQtf9M6SI7hFIjxH+IKeKCMca0xVt+Tr1UqLr/qMK/6W8LoMtRFnE0lpBSHW6hvmLp2OCoQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -14414,7 +14404,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.721.0.tgz", "integrity": "sha512-1Pv8F02hQFmPZs7WtGfQNlnInbG1lLzyngJc/MlZ3Ld2fIoWjaWp7bJWgYAjnzHNEuDtCabWJvIfePdRqsbYoA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -15798,7 +15787,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -16604,7 +16592,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -17558,7 +17545,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -18961,7 +18947,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -20213,7 +20198,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -20585,7 +20569,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -21170,7 +21153,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -22963,7 +22945,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -24509,7 +24490,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -25785,7 +25765,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -26727,7 +26706,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -29452,7 +29430,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -30037,7 +30014,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -30372,7 +30348,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -30957,7 +30932,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -32588,7 +32562,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.859.0.tgz", "integrity": "sha512-Bt840uICsGcn7IFewif8ARCF0CxtdTx9DX/LfUGRI+SVZcqyeEccmH2JJRRzThtEzKTXr+rCN6yaNB3c4RQY2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -33169,7 +33142,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -34492,7 +34464,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.2.tgz", "integrity": "sha512-hAFEB+Stqm4FoQmIuyw5AzGVJh3BSfvLjK7IK4YYRXXLt1Oq9KS6pv2samYgRTTTXsxhmVpDjiYF3Xo/gfXIXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -35407,7 +35378,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.893.0.tgz", "integrity": "sha512-rojJDqmrpzwVfcE+EZ1Q1dnSWhHZQYwk9GinsvvnQNDCrARMyH+Q24jSLXk9rKSiY9UUj0P5o3kH2RdCOS3rGw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -37667,7 +37637,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.726.0.tgz", "integrity": "sha512-5JzTX9jwev7+y2Jkzjz0pd1wobB5JQfPOQF3N2DrJ5Pao0/k6uRYwE4NqB0p0HlGrMTDm7xNq7OSPPIPG575Jw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -39298,7 +39267,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -39321,7 +39289,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -40475,7 +40442,6 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.77.tgz", "integrity": "sha512-aqXHea9xfpVn6VoCq9pjujwFqrh3vw3Fgm9KFUZJ1cF7Bx5HI62DvQPw8LlRB3NB4dhwBBA1ldAVkkkd1du8nA==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -40689,7 +40655,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz", "integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -41005,7 +40970,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -41169,7 +41133,6 @@ "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -43114,7 +43077,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -43604,7 +43566,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -43651,7 +43612,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -44128,7 +44088,6 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.10.3.tgz", "integrity": "sha512-bltsLAr4juMJJ2tT5/L/CtwUGIvHihtPe6SO/z3jjOD73PHhOYxcuwCMFFyTbTy5S4WThJO32oZk7r+pg3ZoCQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -44453,7 +44412,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -44702,7 +44660,6 @@ "integrity": "sha512-/JOoU2//6p5vCXh00FpNgtlw0LjvhGttaWc+y7wpW9yjBm3ys0dI8tSKZxIOgNruz5J0RleccatSIC3uxEZP0g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -46819,7 +46776,6 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -49973,7 +49929,6 @@ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.16.0" } @@ -50931,7 +50886,6 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -51974,7 +51928,6 @@ "integrity": "sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -54997,7 +54950,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -55550,7 +55502,6 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", - "peer": true, "bin": { "openai": "bin/cli" }, @@ -56628,7 +56579,6 @@ "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -56639,7 +56589,6 @@ "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -57278,7 +57227,6 @@ "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -58045,7 +57993,6 @@ "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^13.0.5", @@ -58631,7 +58578,6 @@ "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", @@ -59308,7 +59254,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -59731,7 +59676,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -60632,7 +60576,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -60930,7 +60873,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -60940,7 +60882,6 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.24.1" } diff --git a/src/controllers/llmo/llmo-onboarding.js b/src/controllers/llmo/llmo-onboarding.js index 9246ec406..f92aa5a16 100644 --- a/src/controllers/llmo/llmo-onboarding.js +++ b/src/controllers/llmo/llmo-onboarding.js @@ -170,7 +170,7 @@ async function publishToAdminHlx(filename, outputLocation, log) { * @param {Function} say - Optional function to send messages (e.g., Slack say function) * @returns {Promise} */ -export async function copyFilesToSharepoint(dataFolder, context, say = () => {}) { +export async function copyFilesToSharepoint(dataFolder, context, say = () => { }) { const { log, env } = context; const sharepointClient = await createSharePointClient(env); @@ -209,7 +209,7 @@ export async function copyFilesToSharepoint(dataFolder, context, say = () => {}) * @param {Function} say - Optional function to send messages (e.g., Slack say function) * @returns {Promise} */ -export async function updateIndexConfig(dataFolder, context, say = () => {}) { +export async function updateIndexConfig(dataFolder, context, say = () => { }) { const { log, env } = context; log.debug('Starting Git modification of helix query config'); @@ -260,7 +260,7 @@ export async function updateIndexConfig(dataFolder, context, say = () => {}) { * @param {object} slackContext - Slack context (optional, for Slack operations) * @returns {Promise} The organization object */ -export async function createOrFindOrganization(imsOrgId, context, say = () => {}) { +export async function createOrFindOrganization(imsOrgId, context, say = () => { }) { const { dataAccess, log } = context; const { Organization } = dataAccess; @@ -320,7 +320,7 @@ export async function createOrFindSite(baseURL, organizationId, context) { * @param {Function} say - Optional function to send messages (e.g., Slack say function) * @returns {Promise} The entitlement and enrollment objects */ -export async function createEntitlementAndEnrollment(site, context, say = () => {}) { +export async function createEntitlementAndEnrollment(site, context, say = () => { }) { const { log } = context; try { @@ -339,6 +339,31 @@ export async function createEntitlementAndEnrollment(site, context, say = () => } } +export async function hasActiveLlmoEnrollment(site, context) { + try { + const tierClient = await TierClient.createForSite(context, site, LLMO_PRODUCT_CODE); + const { siteEnrollment } = await tierClient.checkValidEntitlement(); + return !!siteEnrollment; + } catch (error) { + return false; + } +} + +export async function removeEnrollment(site, context, say = () => { }) { + const { log } = context; + + try { + const tierClient = await TierClient.createForSite(context, site, LLMO_PRODUCT_CODE); + await tierClient.revokeSiteEnrollment(); + log.info(`Successfully revoked LLMO enrollment for site ${site.getId()}`); + await say(`✅ Successfully revoked LLMO enrollment for site ${site.getId()}`); + } catch (error) { + log.error(`Removing LLMO enrollment failed: ${error.message}`); + await say('❌ Removing LLMO enrollment failed'); + throw error; + } +} + export async function enableAudits(site, context, audits = []) { const { dataAccess } = context; const { Configuration } = dataAccess; diff --git a/src/controllers/slack.js b/src/controllers/slack.js index bb6637952..c570bec3a 100644 --- a/src/controllers/slack.js +++ b/src/controllers/slack.js @@ -81,6 +81,7 @@ export function initSlackBot(lambdaContext, App) { app.view('preflight_config_modal', actions.preflight_config_modal(lambdaContext)); app.view('onboard_llmo_modal', actions.onboardLLMOModal(lambdaContext)); app.view('update_ims_org_modal', actions.updateIMSOrgModal(lambdaContext)); + app.view('confirm_remove_llmo_enrollment', actions.confirmRemoveLlmoEnrollment(lambdaContext)); return app; } diff --git a/src/support/slack/actions/index.js b/src/support/slack/actions/index.js index 4a168c88b..a7be5d66e 100644 --- a/src/support/slack/actions/index.js +++ b/src/support/slack/actions/index.js @@ -21,6 +21,8 @@ import { addEntitlementsAction, updateOrgAction, updateIMSOrgModal, + removeLlmoEnrollment, + confirmRemoveLlmoEnrollment, } from './onboard-llmo-modal.js'; import { onboardSiteModal, startOnboarding } from './onboard-modal.js'; import { preflightConfigModal } from './preflight-config-modal.js'; @@ -35,12 +37,14 @@ const actions = { onboardSiteModal, onboardLLMOModal, updateIMSOrgModal, + confirmRemoveLlmoEnrollment, start_onboarding: startOnboarding, start_llmo_onboarding: startLLMOOnboarding, preflight_config_modal: preflightConfigModal, open_preflight_config: openPreflightConfig, add_entitlements_action: addEntitlementsAction, update_org_action: updateOrgAction, + remove_llmo_enrollment: removeLlmoEnrollment, }; export default actions; diff --git a/src/support/slack/actions/onboard-llmo-modal.js b/src/support/slack/actions/onboard-llmo-modal.js index 79bbb9d55..4a132bb55 100644 --- a/src/support/slack/actions/onboard-llmo-modal.js +++ b/src/support/slack/actions/onboard-llmo-modal.js @@ -19,6 +19,7 @@ import { copyFilesToSharepoint, updateIndexConfig, enableAudits, + removeEnrollment, } from '../../../controllers/llmo/llmo-onboarding.js'; const REFERRAL_TRAFFIC_AUDIT = 'llmo-referral-traffic'; @@ -791,3 +792,208 @@ export function updateIMSOrgModal(lambdaContext) { } }; } + +export function removeLlmoEnrollment(lambdaContext) { + const { log } = lambdaContext; + + return async ({ ack, body, client }) => { + try { + await ack(); + + const metadata = JSON.parse(body.actions[0].value); + const { + brandURL, + siteId, + existingBrand, + originalThreadTs, + } = metadata; + + const originalChannel = body.channel?.id; + const { user } = body; + + log.info(`User ${user.id} initiated LLMO enrollment removal for site ${siteId} (${brandURL})`); + + // Update the original message to show user's action + await client.chat.update({ + channel: originalChannel, + ts: body.message.ts, + text: `:warning: ${user.name} is removing LLMO enrollment for ${brandURL}...`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:warning: ${user.name} is removing LLMO enrollment for ${brandURL}...`, + }, + }, + ], + }); + + // Show confirmation modal + await client.views.open({ + trigger_id: body.trigger_id, + view: { + type: 'modal', + callback_id: 'confirm_remove_llmo_enrollment', + private_metadata: JSON.stringify({ + brandURL, + siteId, + existingBrand, + originalChannel, + originalThreadTs, + originalMessageTs: body.message.ts, + }), + title: { + type: 'plain_text', + text: 'Confirm Removal', + }, + submit: { + type: 'plain_text', + text: 'Remove Enrollment', + }, + close: { + type: 'plain_text', + text: 'Cancel', + }, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:warning: *Are you sure you want to remove LLMO enrollment?*\n\n*Site:* ${brandURL}\n*Brand:* ${existingBrand}\n\nThis action will:\n• Revoke the site's LLMO enrollment\n• Remove access to LLMO features for this site\n\n*This action cannot be undone.*`, + }, + }, + ], + }, + }); + } catch (error) { + log.error('Error handling remove LLMO enrollment action:', error); + const metadata = JSON.parse(body.actions[0].value); + await client.chat.postMessage({ + channel: body.channel?.id, + text: `:x: Failed to initiate enrollment removal: ${error.message}`, + thread_ts: metadata.originalThreadTs, + }); + } + }; +} + +export function confirmRemoveLlmoEnrollment(lambdaContext) { + const { log, dataAccess } = lambdaContext; + + return async ({ ack, body, client }) => { + try { + log.debug('Processing LLMO enrollment removal confirmation...'); + + const { view, user } = body; + const metadata = JSON.parse(view.private_metadata); + const { + brandURL, + siteId, + existingBrand, + originalChannel, + originalThreadTs, + originalMessageTs, + } = metadata; + + // Acknowledge the modal submission + await ack(); + + // Post initial message to the thread + const responseChannel = originalChannel || body.user.id; + const responseThreadTs = originalChannel ? originalThreadTs : undefined; + + await client.chat.postMessage({ + channel: responseChannel, + text: `:gear: Removing LLMO enrollment for ${brandURL}...`, + thread_ts: responseThreadTs, + }); + + try { + // Find the site + const { Site } = dataAccess; + const site = await Site.findById(siteId); + + if (!site) { + throw new Error(`Site not found: ${siteId}`); + } + + // Use the reusable removeEnrollment function from the LLMO controller + await removeEnrollment(site, lambdaContext); + + log.info(`Successfully revoked LLMO enrollment for site ${siteId} (${brandURL})`); + + // Update the original message to show completion + if (originalMessageTs) { + await client.chat.update({ + channel: responseChannel, + ts: originalMessageTs, + text: `:white_check_mark: LLMO enrollment removed for ${brandURL}`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:white_check_mark: *LLMO Enrollment Removed*\n\nThe LLMO enrollment for *${brandURL}* (brand: *${existingBrand}*) has been successfully removed by ${user.name}.`, + }, + }, + ], + }); + } + + // Post success message to the thread + const successMessage = `:white_check_mark: *LLMO enrollment removed successfully!* + +:link: *Site:* ${brandURL} +:identification_card: *Site ID:* ${siteId} +:label: *Brand:* ${existingBrand} +:bust_in_silhouette: *Removed by:* ${user.name} + +The site enrollment has been revoked. The site can be re-onboarded at any time using the \`onboard-llmo\` command.`; + + await client.chat.postMessage({ + channel: responseChannel, + text: successMessage, + thread_ts: responseThreadTs, + }); + } catch (error) { + log.error(`Error removing LLMO enrollment for site ${siteId}:`, error); + + // Update the original message to show error + if (originalMessageTs) { + await client.chat.update({ + channel: responseChannel, + ts: originalMessageTs, + text: `:x: Failed to remove LLMO enrollment for ${brandURL}`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:x: *Failed to remove LLMO enrollment*\n\nThere was an error removing the enrollment for *${brandURL}*.`, + }, + }, + ], + }); + } + + // Post error message to the thread + await client.chat.postMessage({ + channel: responseChannel, + text: `:x: Failed to remove LLMO enrollment: ${error.message}`, + thread_ts: responseThreadTs, + }); + } + + log.debug(`LLMO enrollment removal processed for user ${user.id}, site ${brandURL}`); + } catch (error) { + log.error('Error handling confirm remove LLMO enrollment modal:', error); + await ack({ + response_action: 'errors', + errors: { + general: 'There was an error processing the removal request.', + }, + }); + } + }; +} diff --git a/src/support/slack/commands/llmo-onboard.js b/src/support/slack/commands/llmo-onboard.js index f5619fdd2..87e09b62b 100644 --- a/src/support/slack/commands/llmo-onboard.js +++ b/src/support/slack/commands/llmo-onboard.js @@ -14,6 +14,7 @@ import { extractURLFromSlackInput, postErrorMessage, } from '../../../utils/slack/base.js'; +import { hasActiveLlmoEnrollment } from '../../../controllers/llmo/llmo-onboarding.js'; import BaseCommand from './base.js'; @@ -30,7 +31,7 @@ function LlmoOnboardCommand(context) { const baseCommand = BaseCommand({ id: 'onboard-llmo', name: 'Onboard LLMO', - description: 'Onboards a site for LLMO (Large Language Model Optimizer) through a modal interface.', + description: 'Onboards a site for LLMO (Large Language Model Optimizer) through a modal interface. For already-onboarded sites, provides options to add entitlements, update IMS org, or remove enrollment.', phrases: PHRASES, usageText: `${PHRASES[0]} `, }); @@ -68,6 +69,62 @@ function LlmoOnboardCommand(context) { let message; if (brand) { + const hasEnrollment = await hasActiveLlmoEnrollment(existingSite, context); + + const actionButtons = [ + { + type: 'button', + text: { + type: 'plain_text', + text: 'Add Entitlements', + }, + value: JSON.stringify({ + brandURL: normalizedSite, + siteId: existingSite.getId(), + existingBrand: brand, + originalChannel: 'current', + originalThreadTs: threadTs, + }), + action_id: 'add_entitlements_action', + style: 'primary', + }, + { + type: 'button', + text: { + type: 'plain_text', + text: 'Update IMS Org', + }, + value: JSON.stringify({ + brandURL: normalizedSite, + siteId: existingSite.getId(), + existingBrand: brand, + currentOrgId: existingSite.getOrganizationId(), + originalChannel: 'current', + originalThreadTs: threadTs, + }), + action_id: 'update_org_action', + }, + ]; + + if (hasEnrollment) { + actionButtons.push({ + type: 'button', + text: { + type: 'plain_text', + text: 'Remove Enrollment', + }, + value: JSON.stringify({ + brandURL: normalizedSite, + siteId: existingSite.getId(), + existingBrand: brand, + originalChannel: 'current', + originalThreadTs: threadTs, + }), + action_id: 'remove_llmo_enrollment', + style: 'danger', + }); + } + message = { blocks: [ { @@ -79,40 +136,7 @@ function LlmoOnboardCommand(context) { }, { type: 'actions', - elements: [ - { - type: 'button', - text: { - type: 'plain_text', - text: 'Add Entitlements', - }, - value: JSON.stringify({ - brandURL: normalizedSite, - siteId: existingSite.getId(), - existingBrand: brand, - originalChannel: 'current', - originalThreadTs: threadTs, - }), - action_id: 'add_entitlements_action', - style: 'primary', - }, - { - type: 'button', - text: { - type: 'plain_text', - text: 'Update IMS Org', - }, - value: JSON.stringify({ - brandURL: normalizedSite, - siteId: existingSite.getId(), - existingBrand: brand, - currentOrgId: existingSite.getOrganizationId(), - originalChannel: 'current', - originalThreadTs: threadTs, - }), - action_id: 'update_org_action', - }, - ], + elements: actionButtons, }, ], thread_ts: threadTs, diff --git a/test/controllers/llmo-onboarding.test.js b/test/controllers/llmo-onboarding.test.js index 7009607b4..641348aea 100644 --- a/test/controllers/llmo-onboarding.test.js +++ b/test/controllers/llmo-onboarding.test.js @@ -660,4 +660,178 @@ describe('LLMO Onboarding Functions', () => { expect(mockLog.info).to.have.been.calledWith('Created site site123 for https://example.com'); }); }); + + describe('hasActiveLlmoEnrollment', () => { + it('should return true when site has an active LLMO enrollment', async () => { + const mockTierClient = { + checkValidEntitlement: sinon.stub().resolves({ + entitlement: { getId: () => 'ent-123' }, + siteEnrollment: { getId: () => 'enrollment-123' }, + }), + }; + + const { hasActiveLlmoEnrollment } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-tier-client': { + default: { + createForSite: sinon.stub().resolves(mockTierClient), + }, + }, + }); + + const mockSite = { + getId: sinon.stub().returns('site-123'), + }; + + const context = { + log: mockLog, + }; + + const result = await hasActiveLlmoEnrollment(mockSite, context); + + expect(result).to.be.true; + }); + + it('should return false when site has no enrollment', async () => { + const mockTierClient = { + checkValidEntitlement: sinon.stub().resolves({ + entitlement: { getId: () => 'ent-123' }, + siteEnrollment: null, + }), + }; + + const { hasActiveLlmoEnrollment } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-tier-client': { + default: { + createForSite: sinon.stub().resolves(mockTierClient), + }, + }, + }); + + const mockSite = { + getId: sinon.stub().returns('site-123'), + }; + + const context = { + log: mockLog, + }; + + const result = await hasActiveLlmoEnrollment(mockSite, context); + + expect(result).to.be.false; + }); + + it('should return false when TierClient throws an error', async () => { + const { hasActiveLlmoEnrollment } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-tier-client': { + default: { + createForSite: sinon.stub().rejects(new Error('TierClient error')), + }, + }, + }); + + const mockSite = { + getId: sinon.stub().returns('site-123'), + }; + + const context = { + log: mockLog, + }; + + const result = await hasActiveLlmoEnrollment(mockSite, context); + + expect(result).to.be.false; + }); + + it('should return false when checkValidEntitlement throws an error', async () => { + const mockTierClient = { + checkValidEntitlement: sinon.stub().rejects(new Error('Check failed')), + }; + + const { hasActiveLlmoEnrollment } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-tier-client': { + default: { + createForSite: sinon.stub().resolves(mockTierClient), + }, + }, + }); + + const mockSite = { + getId: sinon.stub().returns('site-123'), + }; + + const context = { + log: mockLog, + }; + + const result = await hasActiveLlmoEnrollment(mockSite, context); + + expect(result).to.be.false; + }); + }); + + describe('removeEnrollment', () => { + it('should successfully revoke LLMO enrollment', async () => { + const mockTierClient = { + revokeSiteEnrollment: sinon.stub().resolves(), + }; + + const { removeEnrollment } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-tier-client': { + default: { + createForSite: sinon.stub().resolves(mockTierClient), + }, + }, + }); + + const mockSite = { + getId: sinon.stub().returns('site-123'), + }; + + const context = { + log: mockLog, + }; + + const say = sinon.stub().resolves(); + + await removeEnrollment(mockSite, context, say); + + expect(mockTierClient.revokeSiteEnrollment).to.have.been.calledOnce; + expect(mockLog.info).to.have.been.calledWith('Successfully revoked LLMO enrollment for site site-123'); + expect(say).to.have.been.calledWith('✅ Successfully revoked LLMO enrollment for site site-123'); + }); + + it('should handle errors when revoking LLMO enrollment', async () => { + const mockError = new Error('Revoke failed'); + const mockTierClient = { + revokeSiteEnrollment: sinon.stub().rejects(mockError), + }; + + const { removeEnrollment } = await esmock('../../src/controllers/llmo/llmo-onboarding.js', { + '@adobe/spacecat-shared-tier-client': { + default: { + createForSite: sinon.stub().resolves(mockTierClient), + }, + }, + }); + + const mockSite = { + getId: sinon.stub().returns('site-123'), + }; + + const context = { + log: mockLog, + }; + + const say = sinon.stub().resolves(); + + try { + await removeEnrollment(mockSite, context, say); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.equal(mockError); + expect(mockLog.error).to.have.been.calledWith('Removing LLMO enrollment failed: Revoke failed'); + expect(say).to.have.been.calledWith('❌ Removing LLMO enrollment failed'); + } + }); + }); }); diff --git a/test/support/slack/commands/llmo-onboard.test.js b/test/support/slack/commands/llmo-onboard.test.js index 9b4e80c2f..d61be5235 100644 --- a/test/support/slack/commands/llmo-onboard.test.js +++ b/test/support/slack/commands/llmo-onboard.test.js @@ -15,8 +15,7 @@ import { expect, use } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; - -import LlmoOnboardCommand from '../../../../src/support/slack/commands/llmo-onboard.js'; +import esmock from 'esmock'; use(sinonChai); @@ -26,8 +25,9 @@ describe('LlmoOnboardCommand', () => { let mockLog; let mockDataAccess; let slackContext; + let LlmoOnboardCommand; - beforeEach(() => { + beforeEach(async () => { // Create mock log mockLog = { info: sinon.stub(), @@ -39,6 +39,15 @@ describe('LlmoOnboardCommand', () => { Site: { findByBaseURL: sinon.stub(), }, + SiteEnrollment: { + allBySiteId: sinon.stub(), + }, + Entitlement: { + findById: sinon.stub(), + PRODUCT_CODES: { + LLMO: 'LLMO', + }, + }, }; // Create mock context @@ -47,6 +56,10 @@ describe('LlmoOnboardCommand', () => { dataAccess: mockDataAccess, }; + // Import the command + const module = await import('../../../../src/support/slack/commands/llmo-onboard.js'); + LlmoOnboardCommand = module.default; + // Create slack context slackContext = { say: sinon.stub(), @@ -121,8 +134,7 @@ describe('LlmoOnboardCommand', () => { expect(button.style).to.equal('primary'); }); - it('should show reonboarding options for existing site with LLMO brand', async () => { - // Mock existing site with LLMO brand + it('should show reonboarding options for existing site with LLMO brand and active enrollment', async () => { const mockSite = { getId: sinon.stub().returns('site123'), getOrganizationId: sinon.stub().returns('org123'), @@ -132,7 +144,14 @@ describe('LlmoOnboardCommand', () => { }; mockDataAccess.Site.findByBaseURL.resolves(mockSite); - await command.handleExecution(['https://example.com'], slackContext); + const LlmoOnboardCommandWithMock = await esmock('../../../../src/support/slack/commands/llmo-onboard.js', { + '../../../../src/controllers/llmo/llmo-onboarding.js': { + hasActiveLlmoEnrollment: sinon.stub().resolves(true), + }, + }); + + const commandWithMock = LlmoOnboardCommandWithMock(mockContext); + await commandWithMock.handleExecution(['https://example.com'], slackContext); expect(slackContext.say).to.have.been.calledOnce; const message = slackContext.say.getCall(0).args[0]; @@ -146,10 +165,10 @@ describe('LlmoOnboardCommand', () => { expect(sectionBlock.text.text).to.include('Site Already Onboarded'); expect(sectionBlock.text.text).to.include('Test Brand'); - // Check for the actions block with two buttons + // Check for the actions block with three buttons const actionsBlock = message.blocks.find((block) => block.type === 'actions'); expect(actionsBlock).to.exist; - expect(actionsBlock.elements).to.have.length(2); + expect(actionsBlock.elements).to.have.length(3); const addEntitlementsButton = actionsBlock.elements[0]; expect(addEntitlementsButton.type).to.equal('button'); @@ -162,6 +181,12 @@ describe('LlmoOnboardCommand', () => { expect(updateOrgButton.text.text).to.equal('Update IMS Org'); expect(updateOrgButton.action_id).to.equal('update_org_action'); + const removeEnrollmentButton = actionsBlock.elements[2]; + expect(removeEnrollmentButton.type).to.equal('button'); + expect(removeEnrollmentButton.text.text).to.equal('Remove Enrollment'); + expect(removeEnrollmentButton.action_id).to.equal('remove_llmo_enrollment'); + expect(removeEnrollmentButton.style).to.equal('danger'); + // Check that button values contain the necessary metadata const addEntitlementsValue = JSON.parse(addEntitlementsButton.value); expect(addEntitlementsValue.brandURL).to.equal('https://example.com'); @@ -172,6 +197,57 @@ describe('LlmoOnboardCommand', () => { expect(updateOrgValue.brandURL).to.equal('https://example.com'); expect(updateOrgValue.siteId).to.equal('site123'); expect(updateOrgValue.currentOrgId).to.equal('org123'); + + const removeEnrollmentValue = JSON.parse(removeEnrollmentButton.value); + expect(removeEnrollmentValue.brandURL).to.equal('https://example.com'); + expect(removeEnrollmentValue.siteId).to.equal('site123'); + expect(removeEnrollmentValue.existingBrand).to.equal('Test Brand'); + }); + + it('should hide remove enrollment button when site has no active enrollment', async () => { + const mockSite = { + getId: sinon.stub().returns('site123'), + getOrganizationId: sinon.stub().returns('org123'), + getConfig: sinon.stub().returns({ + getLlmoBrand: sinon.stub().returns('Test Brand'), + }), + }; + mockDataAccess.Site.findByBaseURL.resolves(mockSite); + + const LlmoOnboardCommandWithMock = await esmock('../../../../src/support/slack/commands/llmo-onboard.js', { + '../../../../src/controllers/llmo/llmo-onboarding.js': { + hasActiveLlmoEnrollment: sinon.stub().resolves(false), + }, + }); + + const commandWithMock = LlmoOnboardCommandWithMock(mockContext); + await commandWithMock.handleExecution(['https://example.com'], slackContext); + + expect(slackContext.say).to.have.been.calledOnce; + const message = slackContext.say.getCall(0).args[0]; + + expect(message).to.have.property('blocks'); + expect(message).to.have.property('thread_ts', '1234567890.123456'); + + const sectionBlock = message.blocks.find((block) => block.type === 'section'); + expect(sectionBlock).to.exist; + expect(sectionBlock.text.text).to.include('Site Already Onboarded'); + expect(sectionBlock.text.text).to.include('Test Brand'); + + const actionsBlock = message.blocks.find((block) => block.type === 'actions'); + expect(actionsBlock).to.exist; + expect(actionsBlock.elements).to.have.length(2); + + const addEntitlementsButton = actionsBlock.elements[0]; + expect(addEntitlementsButton.text.text).to.equal('Add Entitlements'); + + const updateOrgButton = actionsBlock.elements[1]; + expect(updateOrgButton.text.text).to.equal('Update IMS Org'); + + const removeEnrollmentButton = actionsBlock.elements.find( + (el) => el.action_id === 'remove_llmo_enrollment', + ); + expect(removeEnrollmentButton).to.be.undefined; }); it('should show onboarding button for existing site without LLMO brand', async () => {