diff --git a/local.env.dist b/local.env.dist index 6b7efc82..74cd1d31 100644 --- a/local.env.dist +++ b/local.env.dist @@ -22,6 +22,7 @@ BUILD_ENGINE_SECRETS_BUCKET=SOME_ORG_PREFIX-SOME_APP_ENV-aps-secrets # Built artifacts will be stored here BUILD_ENGINE_ARTIFACTS_BUCKET=SOME_ORG_PREFIX-SOME_APP_ENV-aps-artifacts BUILD_ENGINE_ARTIFACTS_BUCKET_REGION=us-east-1 +BUILD_ENGINE_GRADING_LAMBDA_FUNCTION_NAME=SOME_GRADING_LAMBDA_FUNCTION # Projects are moved to here by SAB BUILD_ENGINE_PROJECTS_BUCKET=SOME_ORG_PREFIX-SOME_APP_ENV-aps-projects diff --git a/package-lock.json b/package-lock.json index 6ed98fd4..b6b9ac00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-codebuild": "^3.907.0", "@aws-sdk/client-ecr": "^3.981.0", "@aws-sdk/client-iam": "^3.911.0", + "@aws-sdk/client-lambda": "^3.907.0", "@aws-sdk/client-s3": "^3.907.0", "@aws-sdk/client-sts": "^3.907.0", "@grpc/grpc-js": "^1.14.3", @@ -1497,6 +1498,67 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/@aws-sdk/client-lambda": { + "version": "3.907.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.907.0.tgz", + "integrity": "sha512-Xj5vlaWbYKzvE3r+lgBSZhV6XtGGBEMQEor4iez7FWI9jKbA/3saGXBXVj+mrpMAAVk3IPLzwdN/7drY4Ft7HA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.907.0", + "@aws-sdk/credential-provider-node": "3.907.0", + "@aws-sdk/middleware-host-header": "3.901.0", + "@aws-sdk/middleware-logger": "3.901.0", + "@aws-sdk/middleware-recursion-detection": "3.901.0", + "@aws-sdk/middleware-user-agent": "3.907.0", + "@aws-sdk/region-config-resolver": "3.901.0", + "@aws-sdk/types": "3.901.0", + "@aws-sdk/util-endpoints": "3.901.0", + "@aws-sdk/util-user-agent-browser": "3.907.0", + "@aws-sdk/util-user-agent-node": "3.907.0", + "@smithy/config-resolver": "^4.3.0", + "@smithy/core": "^3.14.0", + "@smithy/eventstream-serde-browser": "^4.2.0", + "@smithy/eventstream-serde-config-resolver": "^4.3.0", + "@smithy/eventstream-serde-node": "^4.2.0", + "@smithy/fetch-http-handler": "^5.3.0", + "@smithy/hash-node": "^4.2.0", + "@smithy/invalid-dependency": "^4.2.0", + "@smithy/middleware-content-length": "^4.2.0", + "@smithy/middleware-endpoint": "^4.3.0", + "@smithy/middleware-retry": "^4.4.0", + "@smithy/middleware-serde": "^4.2.0", + "@smithy/middleware-stack": "^4.2.0", + "@smithy/node-config-provider": "^4.3.0", + "@smithy/node-http-handler": "^4.3.0", + "@smithy/protocol-http": "^5.3.0", + "@smithy/smithy-client": "^4.7.0", + "@smithy/types": "^4.6.0", + "@smithy/url-parser": "^4.2.0", + "@smithy/util-base64": "^4.2.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.0", + "@smithy/util-defaults-mode-browser": "^4.2.0", + "@smithy/util-defaults-mode-node": "^4.2.0", + "@smithy/util-endpoints": "^3.2.0", + "@smithy/util-middleware": "^4.2.0", + "@smithy/util-retry": "^4.2.0", + "@smithy/util-stream": "^4.4.0", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-lambda/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@aws-sdk/client-s3": { "version": "3.907.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.907.0.tgz", @@ -1792,18 +1854,16 @@ "license": "0BSD" }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.3.tgz", - "integrity": "sha512-Gc3O91iVvA47kp2CLIXOwuo5ffo1cIpmmyIewcYjAcvurdFHQ8YdcBe1KHidnbbBO4/ZtywGBACsAX5vr3UdoA==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.43.tgz", + "integrity": "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/nested-clients": "3.980.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -1811,86 +1871,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/core": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.5.tgz", - "integrity": "sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA==", + "version": "3.974.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.13.tgz", + "integrity": "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/xml-builder": "^3.972.2", - "@smithy/core": "^3.22.0", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", - "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", - "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", - "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.25", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.5.tgz", - "integrity": "sha512-TVZQ6PWPwQbahUI8V+Er+gS41ctIawcI/uMNmQtQ7RMcg3JYn6gyKAFKUb3HFYx2OjYlx1u11sETSwwEUxVHTg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.980.0", - "@smithy/core": "^3.22.0", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -1898,64 +1890,36 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/nested-clients": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.980.0.tgz", - "integrity": "sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ==", + "version": "3.997.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", + "integrity": "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.5", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.5", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.980.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.3", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.22.0", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.12", - "@smithy/middleware-retry": "^4.4.29", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.28", - "@smithy/util-defaults-mode-node": "^4.2.31", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.28", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", - "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.28.tgz", + "integrity": "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/config-resolver": "^4.4.6", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -1963,78 +1927,27 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/types": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", - "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-endpoints": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", - "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", - "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.3.tgz", - "integrity": "sha512-gqG+02/lXQtO0j3US6EVnxtwwoXQC5l2qkhLCrqUrqdtcQxV7FDMbm9wLjKqoronSHyELGTjbFKK/xV5q1bZNA==", + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.5", - "@aws-sdk/types": "^3.973.1", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/xml-builder": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.3.tgz", - "integrity": "sha512-bCk63RsBNCWW4tt5atv5Sbrh+3J3e8YzgyF6aZb1JeXcdzG4k5SlPLeTMFOIXFuuFHIwgphUhn4i3uS/q49eww==", + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.25.tgz", + "integrity": "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "fast-xml-parser": "5.3.4", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, "engines": { @@ -2042,18 +1955,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws/lambda-invoke-store": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", "funding": [ { "type": "github", @@ -2062,7 +1975,10 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -3679,6 +3595,18 @@ "win32" ] }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4964,20 +4892,13 @@ "license": "0BSD" }, "node_modules/@smithy/core": { - "version": "3.22.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.22.1.tgz", - "integrity": "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==", + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", + "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.9", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.11", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -4991,15 +4912,13 @@ "license": "0BSD" }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", - "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", + "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -5113,15 +5032,13 @@ "license": "0BSD" }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", - "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", + "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -5385,15 +5302,13 @@ "license": "0BSD" }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.9.tgz", - "integrity": "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", + "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -5444,26 +5359,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", - "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-uri-escape": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/@smithy/querystring-parser": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", @@ -5515,18 +5410,13 @@ "license": "0BSD" }, "node_modules/@smithy/signature-v4": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", - "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", + "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -5564,9 +5454,9 @@ "license": "0BSD" }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5841,24 +5731,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/@smithy/util-utf8": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", @@ -8301,6 +8173,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, "node_modules/fast-xml-parser": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", @@ -10259,6 +10147,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -11375,9 +11278,9 @@ } }, "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", @@ -12238,6 +12141,21 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 48aaadae..3bd12593 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@aws-sdk/client-codebuild": "^3.907.0", "@aws-sdk/client-ecr": "^3.981.0", "@aws-sdk/client-iam": "^3.911.0", + "@aws-sdk/client-lambda": "^3.907.0", "@aws-sdk/client-s3": "^3.907.0", "@aws-sdk/client-sts": "^3.907.0", "@grpc/grpc-js": "^1.14.3", diff --git a/src/lib/prisma/migrations/03_grading_result/migration.sql b/src/lib/prisma/migrations/03_grading_result/migration.sql new file mode 100644 index 00000000..18612f32 --- /dev/null +++ b/src/lib/prisma/migrations/03_grading_result/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "public"."gradingResult" ( + "uuid" UUID NOT NULL, + "project_id" INTEGER NOT NULL, + "status" VARCHAR(255), + "result" VARCHAR(2000), + "publisher_id" VARCHAR(255) NOT NULL, + "lambda_request_id" VARCHAR(255), + "created" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + "updated" TIMESTAMP(6), + + CONSTRAINT "gradingResult_pkey" PRIMARY KEY ("uuid") +); + +-- CreateIndex +CREATE INDEX "idx_grading_result_project_id" ON "public"."gradingResult"("project_id"); + +-- AddForeignKey +ALTER TABLE "public"."gradingResult" ADD CONSTRAINT "fk_grading_result_project_id" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/src/lib/prisma/schema.prisma b/src/lib/prisma/schema.prisma index 46eb7309..a785ba4f 100644 --- a/src/lib/prisma/schema.prisma +++ b/src/lib/prisma/schema.prisma @@ -4,7 +4,7 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") + url = env("DATABASE_URL") } model build { @@ -62,21 +62,22 @@ model job { } model project { - id Int @id @default(autoincrement()) - status String? @db.VarChar(255) - result String? @db.VarChar(255) - error String? @db.VarChar(2083) - url String? @db.VarChar(1024) - user_id String? @db.VarChar(255) // ISSUE #77: remove this? - group_id String? @db.VarChar(255) // ISSUE #77: remove this? - app_id String? @db.VarChar(255) - project_name String? @db.VarChar(255) - language_code String? @db.VarChar(255) - publishing_key String? @db.VarChar(1024) // ISSUE #77: remove this? - created DateTime? @default(now()) @db.Timestamp(6) - updated DateTime? @updatedAt @db.Timestamp(6) + id Int @id @default(autoincrement()) + status String? @db.VarChar(255) + result String? @db.VarChar(255) + error String? @db.VarChar(2083) + url String? @db.VarChar(1024) + user_id String? @db.VarChar(255) // ISSUE #77: remove this? + group_id String? @db.VarChar(255) // ISSUE #77: remove this? + app_id String? @db.VarChar(255) + project_name String? @db.VarChar(255) + language_code String? @db.VarChar(255) + publishing_key String? @db.VarChar(1024) // ISSUE #77: remove this? + created DateTime? @default(now()) @db.Timestamp(6) + updated DateTime? @updatedAt @db.Timestamp(6) client_id Int? - client client? @relation(fields: [client_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_project_client_id") + gradingResult gradingResult[] + client client? @relation(fields: [client_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_project_client_id") @@index([client_id], map: "idx_project_client_id") } @@ -112,3 +113,17 @@ model appVersion { created DateTime @default(now()) @db.Timestamp(6) updated DateTime? @updatedAt @db.Timestamp(6) } + +model gradingResult { + uuid String @id @default(uuid()) @db.Uuid + project_id Int + status String? @db.VarChar(255) + result String? @db.VarChar(2000) + publisher_id String @db.VarChar(255) + lambda_request_id String? @db.VarChar(255) + created DateTime? @default(now()) @db.Timestamp(6) + updated DateTime? @updatedAt @db.Timestamp(6) + project project @relation(fields: [project_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_grading_result_project_id") + + @@index([project_id], map: "idx_grading_result_project_id") +} diff --git a/src/lib/server/aws/lambda.ts b/src/lib/server/aws/lambda.ts new file mode 100644 index 00000000..fe59eca4 --- /dev/null +++ b/src/lib/server/aws/lambda.ts @@ -0,0 +1,65 @@ +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { SpanStatusCode, trace } from '@opentelemetry/api'; +import { AWSVars } from './vars'; + +const tracer = trace.getTracer('Lambda'); + +export class Lambda { + private client; + + public constructor() { + this.client = new LambdaClient({ region: AWSVars.artifactsRegion() }); + } + + public async invokeJson>( + functionName: string, + payload: TPayload + ) { + return tracer.startActiveSpan('Lambda - Invoke', async (span) => { + span.setAttributes({ + 'lambda.function-name': functionName, + 'lambda.payload': JSON.stringify(payload) + }); + try { + const startTime = Date.now(); + const result = await this.client.send( + new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + Payload: Buffer.from(JSON.stringify(payload)) + }) + ); + span.setAttributes({ + 'lambda.status-code': result.StatusCode, + 'lambda.executed-version': result.ExecutedVersion ?? '', + 'lambda.function-error': result.FunctionError ?? '', + 'lambda.executionTimeMs': Date.now() - startTime + }); + + const body = result.Payload ? Buffer.from(result.Payload).toString('utf8') : ''; + const parsed = body ? this.parsePayload(body) : null; + return { + requestId: result.$metadata.requestId ?? null, + payload: parsed + }; + } catch (e) { + span.recordException(e as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: (e as Error).message + }); + throw e; + } finally { + span.end(); + } + }); + } + + private parsePayload(body: string) { + try { + return JSON.parse(body) as Record; + } catch { + throw new Error(`Lambda returned invalid JSON: ${body}`); + } + } +} diff --git a/src/lib/server/aws/s3.ts b/src/lib/server/aws/s3.ts index 7c7136fd..e0e23255 100644 --- a/src/lib/server/aws/s3.ts +++ b/src/lib/server/aws/s3.ts @@ -2,6 +2,7 @@ import { CopyObjectCommand, DeleteObjectsCommand, GetObjectCommand, + HeadObjectCommand, ListObjectsV2Command, NoSuchKey, PutObjectCommand, @@ -237,6 +238,25 @@ export class S3 { ); handleArtifact(artifacts_provider, fileS3Key, fileContents); } + + public async objectExists(key: string) { + const bucket = AWSVars.artifacts(); + try { + await this.s3Client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: key + }) + ); + return true; + } catch (e) { + if (e instanceof S3ServiceException && e.$metadata.httpStatusCode === 404) { + return false; + } + throw e; + } + } + public async removeCodeBuildFolder(artifacts_provider: ProviderForPrefix) { const s3Folder = getBasePrefixUrl(artifacts_provider, 'codebuild-output') + '/'; const s3Bucket = AWSVars.artifacts(); diff --git a/src/lib/server/aws/vars.ts b/src/lib/server/aws/vars.ts index 45f21835..3102229d 100644 --- a/src/lib/server/aws/vars.ts +++ b/src/lib/server/aws/vars.ts @@ -42,6 +42,10 @@ export class AWSVars { public static scriptsPath() { return `s3://${AWSVars.projects()}/default`; } + + public static gradingLambdaFunctionName() { + return env.BUILD_ENGINE_GRADING_LAMBDA_FUNCTION_NAME; + } /** * Get the project name which is the prd or stg plus build_app or publish_app * diff --git a/src/lib/server/bullmq/BullMQ.ts b/src/lib/server/bullmq/BullMQ.ts index ec8fd6c3..4b4249b3 100644 --- a/src/lib/server/bullmq/BullMQ.ts +++ b/src/lib/server/bullmq/BullMQ.ts @@ -7,6 +7,7 @@ export const allWorkers = building new Workers.Builds(), new Workers.S3(), new Workers.Releases(), + new Workers.Grading(), new Workers.Polling(), new Workers.SystemStartup(), new Workers.SystemRecurring() diff --git a/src/lib/server/bullmq/BullWorker.ts b/src/lib/server/bullmq/BullWorker.ts index 799192d8..d385ae81 100644 --- a/src/lib/server/bullmq/BullWorker.ts +++ b/src/lib/server/bullmq/BullWorker.ts @@ -150,6 +150,18 @@ export class Releases extends BullWorker { } } +export class Grading extends BullWorker { + constructor() { + super(BullMQ.QueueName.Grading); + } + async run(job: Job) { + switch (job.data.type) { + case BullMQ.JobType.Grading_Generate: + return Executor.Grading.generate(job as Job); + } + } +} + export class Polling extends BullWorker { constructor() { super(BullMQ.QueueName.Polling); diff --git a/src/lib/server/bullmq/queues.ts b/src/lib/server/bullmq/queues.ts index a1c675a0..f170faf1 100644 --- a/src/lib/server/bullmq/queues.ts +++ b/src/lib/server/bullmq/queues.ts @@ -1,7 +1,15 @@ import { Queue } from 'bullmq'; import { BullMQOtel } from 'bullmq-otel'; import { Redis } from 'ioredis'; -import type { BuildJob, PollJob, PublishJob, RecurringJob, S3Job, StartupJob } from './types'; +import type { + BuildJob, + GradingJob, + PollJob, + PublishJob, + RecurringJob, + S3Job, + StartupJob +} from './types'; import { QueueName } from './types'; import { env } from '$env/dynamic/private'; import OTEL from '$lib/otel'; @@ -123,6 +131,8 @@ function createQueues() { const S3 = new Queue(QueueName.S3, getQueueConfig()); /** Queue for Product Publishing */ const Releases = new Queue(QueueName.Releases, getQueueConfig()); + /** Queue for Grading report generation */ + const Grading = new Queue(QueueName.Grading, getQueueConfig()); /** Queue for jobs that poll BuildEngine, such as checking the status of a build */ const Polling = new Queue(QueueName.Polling, getQueueConfig()); /** Queue for jobs that run on startup, such as creating the CodeBuild project */ @@ -133,6 +143,7 @@ function createQueues() { Builds, S3, Releases, + Grading, Polling, SystemStartup, SystemRecurring diff --git a/src/lib/server/bullmq/types.ts b/src/lib/server/bullmq/types.ts index c6cc206e..5e510027 100644 --- a/src/lib/server/bullmq/types.ts +++ b/src/lib/server/bullmq/types.ts @@ -20,6 +20,7 @@ export enum QueueName { Builds = 'Builds', S3 = 'S3', Releases = 'Releases', + Grading = 'Grading', Polling = 'Polling', System_Startup = 'System (Startup)', System_Recurring = 'System (Recurring)' @@ -35,6 +36,8 @@ export enum JobType { // Publishing Jobs Release_Product = 'Release Product', Release_Cancel = 'Cancel Release', + // Grading Jobs + Grading_Generate = 'Generate Grading Report', // S3 Jobs S3_CopyArtifacts = 'Copy Artifacts to S3', S3_CopyError = 'Copy Errors to S3', @@ -85,6 +88,13 @@ export namespace Release { } } +export namespace Grading { + export interface Generate { + type: JobType.Grading_Generate; + gradingResultUUID: string; + } +} + export namespace S3 { export interface CopyArtifacts { type: JobType.S3_CopyArtifacts; @@ -112,6 +122,7 @@ export type Job = JobTypeMap[keyof JobTypeMap]; export type BuildJob = JobTypeMap[JobType.Build_Product | JobType.Build_Cancel]; export type S3Job = JobTypeMap[JobType.S3_CopyArtifacts | JobType.S3_CopyError]; export type PublishJob = JobTypeMap[JobType.Release_Product | JobType.Release_Cancel]; +export type GradingJob = JobTypeMap[JobType.Grading_Generate]; export type PollJob = JobTypeMap[JobType.Poll_Build | JobType.Poll_Release]; export type StartupJob = JobTypeMap[ | JobType.System_CreateCodeBuildProject @@ -125,6 +136,7 @@ export type JobTypeMap = { [JobType.Poll_Release]: Polling.Release; [JobType.Release_Product]: Release.Product; [JobType.Release_Cancel]: Release.Cancel; + [JobType.Grading_Generate]: Grading.Generate; [JobType.S3_CopyArtifacts]: S3.CopyArtifacts; [JobType.S3_CopyError]: S3.CopyErrors; [JobType.System_CreateCodeBuildProject]: System.CreateCodeBuildProject; diff --git a/src/lib/server/job-executors/grading.ts b/src/lib/server/job-executors/grading.ts new file mode 100644 index 00000000..5042ce9c --- /dev/null +++ b/src/lib/server/job-executors/grading.ts @@ -0,0 +1,105 @@ +import type { Prisma } from '@prisma/client'; +import type { Job } from 'bullmq'; +import { Lambda } from '../aws/lambda'; +import { S3 } from '../aws/s3'; +import { AWSVars } from '../aws/vars'; +import type { BullMQ } from '../bullmq'; +import { Grading } from '../models/grading'; +import { prisma } from '../prisma'; +import { trimStrings } from '$lib/valibot'; + +export async function generate(job: Job): Promise { + try { + const grading = await findGradingResult(job.data.gradingResultUUID); + if (!grading) { + throw new Error(`Grading result ${job.data.gradingResultUUID} not found`); + } + + const functionName = AWSVars.gradingLambdaFunctionName(); + if (!functionName) { + throw new Error('BUILD_ENGINE_GRADING_LAMBDA_FUNCTION_NAME is not configured'); + } + + await updateGrading(grading.uuid, { status: Grading.Status.Active }); + job.updateProgress(10); + + const prefix = Grading.reportPrefix(grading.uuid); + const htmlKey = `${prefix}/report.html`; + const jsonKey = `${prefix}/report.json`; + + const lambda = new Lambda(); + const payload = { + reportId: grading.uuid, + project: { + id: grading.project.id, + appId: grading.project.app_id, + name: grading.project.project_name, + languageCode: grading.project.language_code, + s3Url: grading.project.url + }, + // Used for secrets + publisherId: grading.publisher_id, + reportLanguage: 'en' + }; + job.log(`Invoking grading lambda with payload: ${JSON.stringify(payload)}`); + const lambdaResult = await lambda.invokeJson(functionName, payload); + await updateGrading(grading.uuid, { + lambda_request_id: lambdaResult.requestId, + result: JSON.stringify(lambdaResult.payload) + }); + job.log(`Lambda result: ${JSON.stringify(lambdaResult)}`); + if (lambdaResult.payload && typeof lambdaResult.payload === 'object') { + if (lambdaResult.payload.FunctionError) { + throw new Error(`Lambda function error: ${JSON.stringify(lambdaResult.payload)}`.trim()); + } + } + job.updateProgress(75); + + const s3 = new S3(); + const [htmlExists, jsonExists] = await Promise.all([ + s3.objectExists(htmlKey), + s3.objectExists(jsonKey) + ]); + if (!htmlExists || !jsonExists) { + throw new Error( + `Grading Lambda did not create expected report objects: ${[ + htmlExists ? null : htmlKey, + jsonExists ? null : jsonKey + ] + .filter(Boolean) + .join(', ')}` + ); + } + + const updated = await updateGrading(grading.uuid, { + status: Grading.Status.Success, + result: JSON.stringify(lambdaResult.payload) + }); + job.updateProgress(100); + return updated; + } catch (e) { + job.log(`${e}`); + await updateGrading(job.data.gradingResultUUID, { + status: Grading.Status.Failure, + result: String(e) + }); + throw e; + } +} + +async function findGradingResult(uuid: string) { + return await prisma.gradingResult.findUnique({ + where: { uuid }, + include: { + project: true + } + }); +} + +async function updateGrading(uuid: string, data: Prisma.gradingResultUpdateInput) { + const trimmed = trimStrings(data, 'grading'); + return await prisma.gradingResult.update({ + where: { uuid }, + data: trimmed + }); +} diff --git a/src/lib/server/job-executors/index.ts b/src/lib/server/job-executors/index.ts index 24a06146..28331cd0 100644 --- a/src/lib/server/job-executors/index.ts +++ b/src/lib/server/job-executors/index.ts @@ -1,4 +1,5 @@ export * as Build from './build'; +export * as Grading from './grading'; export * as Polling from './polling'; export * as Release from './release'; export * as S3 from './s3'; diff --git a/src/lib/server/models/grading.ts b/src/lib/server/models/grading.ts new file mode 100644 index 00000000..3a311524 --- /dev/null +++ b/src/lib/server/models/grading.ts @@ -0,0 +1,76 @@ +import type { Prisma } from '@prisma/client'; +import { AWSVars } from '$lib/server/aws/vars'; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Grading { + export enum Status { + Initialized = 'initialized', + Active = 'active', + Success = 'success', + Failure = 'failure' + } + + export type ResultRow = Prisma.gradingResultGetPayload; + + export type ResultWithProject = Prisma.gradingResultGetPayload<{ + include: { project: true }; + }>; + + export type ResponseBody = Omit & { + reports: { + html?: string; + json?: string; + }; + _links: Record; + }; + + export const reportFiles = ['report.html', 'report.json'] as const; + + export function reportPrefix(uuid: string) { + return `reports/${uuid}`; + } + + export function reportUrlBase(uuid: string, bucket: string) { + return `https://${bucket}.s3.amazonaws.com/${reportPrefix(uuid)}/`; + } + + export function reports(row: Pick) { + const base = `https://${AWSVars.artifacts()}.s3.amazonaws.com/${reportPrefix(row.uuid)}/`; + return { + html: base + 'report.html', + json: base + 'report.json' + }; + } + + export function response( + row: ResultRow, + origin: string = process.env.ORIGIN || 'http://localhost:8443', + extraLinks: Record = {} + ): ResponseBody { + return { + uuid: row.uuid, + project_id: row.project_id, + status: row.status, + result: row.result, + created: row.created, + updated: row.updated, + publisher_id: row.publisher_id, + reports: + row.status === Grading.Status.Success + ? Grading.reports(row) + : { html: undefined, json: undefined }, + _links: { + self: { + href: `${origin}/project/${row.project_id}/grading/${row.uuid}` + }, + project: { + href: `${origin}/project/${row.project_id}` + }, + latest: { + href: `${origin}/project/${row.project_id}/grading/latest` + }, + ...extraLinks + } + }; + } +} diff --git a/src/lib/valibot.ts b/src/lib/valibot.ts index cbd25fd4..9983cb11 100644 --- a/src/lib/valibot.ts +++ b/src/lib/valibot.ts @@ -95,6 +95,12 @@ export const stringLimits = { targets: 255, artifact_url_base: 255, artifact_files: 255 + }, + grading: { + status: 255, + result: 2000, + publisher_id: 255, + lambda_request_id: 255 } } as const; diff --git a/src/routes/(api)/project/[id=idNumber]/grading/+server.ts b/src/routes/(api)/project/[id=idNumber]/grading/+server.ts new file mode 100644 index 00000000..4cba4058 --- /dev/null +++ b/src/routes/(api)/project/[id=idNumber]/grading/+server.ts @@ -0,0 +1,109 @@ +import * as v from 'valibot'; +import type { RequestHandler } from './$types'; +import { BullMQ, getQueues } from '$lib/server/bullmq'; +import { Grading } from '$lib/server/models/grading'; +import { prisma } from '$lib/server/prisma'; +import { ErrorResponse } from '$lib/utils'; +import { stringLimits } from '$lib/valibot'; + +const gradingSchema = v.strictObject({ + publisher_id: v.pipe(v.string(), v.maxBytes(stringLimits.grading.publisher_id)) +}); + +type ProjectForGrading = { + id: number; + url: string | null; +}; + +async function findProject(id: number, clientId: number | null) { + return await prisma.project.findFirst({ + where: { + id, + ...(clientId ? { client_id: clientId } : {}) + }, + select: { + id: true, + url: true + } + }); +} + +function origin() { + return process.env.ORIGIN || 'http://localhost:8443'; +} + +// POST /project/[id]/grading +export const POST: RequestHandler = async ({ request, params, locals }) => { + const parsed = v.safeParse(gradingSchema, await request.json()); + if (!parsed.success) return ErrorResponse(400, JSON.stringify(v.flatten(parsed.issues))); + + const project = (await findProject( + Number(params.id), + locals.clientId + )) as ProjectForGrading | null; + if (!project) return ErrorResponse(404, 'Project not found'); + if (!project.url?.startsWith('s3://')) { + return ErrorResponse(400, 'Project does not have an s3:// URL'); + } + + const grading = await prisma.gradingResult.create({ + data: { + project_id: project.id, + status: Grading.Status.Initialized, + publisher_id: parsed.output.publisher_id + } + }); + if (!grading) return ErrorResponse(500, 'Unable to create grading result'); + + await getQueues().Grading.add( + `Generate Grading Report #${grading.uuid} for Project ${project.id}`, + { + type: BullMQ.JobType.Grading_Generate, + gradingResultUUID: grading.uuid + } + ); + + return new Response( + JSON.stringify( + Grading.response(grading, origin(), { + list: { + href: `${origin()}/project/${project.id}/grading` + } + }) + ), + { status: 202 } + ); +}; + +// GET /project/[id]/grading +export const GET: RequestHandler = async ({ params, locals, url }) => { + const project = await findProject(Number(params.id), locals.clientId); + if (!project) return ErrorResponse(404, 'Project not found'); + + const rawLimit = Number(url.searchParams.get('limit') ?? 20); + const limit = Math.max(1, Math.min(Number.isFinite(rawLimit) ? rawLimit : 20, 100)); + const rows = await prisma.gradingResult.findMany({ + where: { + project_id: project.id + }, + orderBy: [{ created: 'desc' }], + take: limit + }); + + return new Response( + JSON.stringify({ + gradingResults: rows.map((row) => Grading.response(row, origin())), + _links: { + self: { + href: `${origin()}/project/${project.id}/grading` + }, + project: { + href: `${origin()}/project/${project.id}` + }, + latest: { + href: `${origin()}/project/${project.id}/grading/latest` + } + } + }) + ); +}; diff --git a/src/routes/(api)/project/[id=idNumber]/grading/[gradingResultId]/+server.ts b/src/routes/(api)/project/[id=idNumber]/grading/[gradingResultId]/+server.ts new file mode 100644 index 00000000..5df24403 --- /dev/null +++ b/src/routes/(api)/project/[id=idNumber]/grading/[gradingResultId]/+server.ts @@ -0,0 +1,30 @@ +import type { RequestHandler } from './$types'; +import { Grading } from '$lib/server/models/grading'; +import { prisma } from '$lib/server/prisma'; +import { ErrorResponse } from '$lib/utils'; + +function origin() { + return process.env.ORIGIN || 'http://localhost:8443'; +} + +// GET /project/[id]/grading/[gradingResultId] +export const GET: RequestHandler = async ({ params, locals }) => { + const project = await prisma.project.findFirst({ + where: { + id: Number(params.id), + ...(locals.clientId ? { client_id: locals.clientId } : {}) + }, + select: { id: true } + }); + if (!project) return ErrorResponse(404, 'Project not found'); + + const grading = await prisma.gradingResult.findUnique({ + where: { + uuid: params.gradingResultId, + project_id: project.id + } + }); + if (!grading) return ErrorResponse(404, 'Grading result not found'); + + return new Response(JSON.stringify(Grading.response(grading, origin()))); +}; diff --git a/src/routes/(api)/project/[id=idNumber]/grading/latest/+server.ts b/src/routes/(api)/project/[id=idNumber]/grading/latest/+server.ts new file mode 100644 index 00000000..882ba6ac --- /dev/null +++ b/src/routes/(api)/project/[id=idNumber]/grading/latest/+server.ts @@ -0,0 +1,30 @@ +import type { RequestHandler } from './$types'; +import { Grading } from '$lib/server/models/grading'; +import { prisma } from '$lib/server/prisma'; +import { ErrorResponse } from '$lib/utils'; + +function origin() { + return process.env.ORIGIN || 'http://localhost:8443'; +} + +// GET /project/[id]/grading/latest +export const GET: RequestHandler = async ({ params, locals }) => { + const project = await prisma.project.findFirst({ + where: { + id: Number(params.id), + ...(locals.clientId ? { client_id: locals.clientId } : {}) + }, + select: { id: true } + }); + if (!project) return ErrorResponse(404, 'Project not found'); + + const grading = await prisma.gradingResult.findFirst({ + where: { + project_id: project.id + }, + orderBy: [{ created: 'desc' }] + }); + if (!grading) return ErrorResponse(404, 'Grading result not found'); + + return new Response(JSON.stringify(Grading.response(grading, origin()))); +}; diff --git a/src/routes/(ui)/+page.svelte b/src/routes/(ui)/+page.svelte index b730a1c5..e948e485 100644 --- a/src/routes/(ui)/+page.svelte +++ b/src/routes/(ui)/+page.svelte @@ -58,6 +58,14 @@

Project Administration » +
+

Grading

+

+ View project grading reports, inspect report status, open generated HTML and JSON report + files, or request a new grading report for a project. +

+ Grading Administration » +

Operation Queue

diff --git a/src/routes/(ui)/grading-admin/+page.server.ts b/src/routes/(ui)/grading-admin/+page.server.ts new file mode 100644 index 00000000..d815830d --- /dev/null +++ b/src/routes/(ui)/grading-admin/+page.server.ts @@ -0,0 +1,49 @@ +import { fail, superValidate } from 'sveltekit-superforms'; +import { valibot } from 'sveltekit-superforms/adapters'; +import type { Actions, PageServerLoad } from './$types'; +import { Grading } from '$lib/server/models/grading'; +import { prisma } from '$lib/server/prisma'; +import { tableSchema } from '$lib/valibot'; + +export const load = (async () => { + const gradingResults = await prisma.gradingResult.findMany({ + take: 20, + orderBy: { created: 'desc' }, + include: { project: true } + }); + return { + gradingResults: gradingResults.map((r) => Grading.response(r)), + count: await prisma.gradingResult.count(), + form: await superValidate( + { + page: { + page: 0, + size: 20 + } + }, + valibot(tableSchema) + ) + }; +}) satisfies PageServerLoad; + +export const actions: Actions = { + page: async function ({ request }) { + const form = await superValidate(request, valibot(tableSchema)); + if (!form.valid) return fail(400, { form, ok: false }); + + const gradingResults = await prisma.gradingResult.findMany({ + orderBy: form.data.sort ? { [form.data.sort.field]: form.data.sort.direction } : undefined, + skip: form.data.page.page * form.data.page.size, + take: form.data.page.size, + include: { project: true } + }); + + return { + form, + ok: true, + query: { + data: gradingResults + } + }; + } +}; diff --git a/src/routes/(ui)/grading-admin/+page.svelte b/src/routes/(ui)/grading-admin/+page.svelte new file mode 100644 index 00000000..fd6f6343 --- /dev/null +++ b/src/routes/(ui)/grading-admin/+page.svelte @@ -0,0 +1,138 @@ + + +

+ +
  • Home
  • +
  • {$title}
  • +
    +

    {$title}

    + Create Report +

    + Showing + {$form.page.page * $form.page.size + 1}-{Math.min( + ($form.page.page + 1) * $form.page.size, + data.count + )} + + of + {data.count} + items +

    + 0 + }, + { + id: 'project_id', + header: 'Project ID', + compare: () => 0 + }, + { + id: 'status', + header: 'Status', + compare: () => 0 + }, + { + id: 'result', + header: 'Result', + compare: () => 0 + }, + { + id: 'publisher_id', + header: 'Publisher', + compare: () => 0 + }, + { + id: 'created', + header: 'Created', + compare: () => 0 + }, + { + id: 'menu', + header: '' + } + ]} + serverSide={true} + startDesc={true} + onSort={(field, direction) => form.update((data) => ({ ...data, sort: { field, direction } }))} + > + {#snippet row(gradingResult, index)} + + {index + 1} + {gradingResult.uuid} + + + {gradingResult.project_id} + + + {gradingResult.status} + {gradingResult.result} + {gradingResult.publisher_id} + {getTimeDateString(gradingResult.created)} + + {#if gradingResult.reports.html} + + + + {/if} + {#if gradingResult.reports.json} + + + + {/if} + + + + + + {/snippet} + +
    +
    + +
    +
    +
    diff --git a/src/routes/(ui)/grading-admin/create/+page.server.ts b/src/routes/(ui)/grading-admin/create/+page.server.ts new file mode 100644 index 00000000..19275fd1 --- /dev/null +++ b/src/routes/(ui)/grading-admin/create/+page.server.ts @@ -0,0 +1,65 @@ +import { redirect } from '@sveltejs/kit'; +import { fail, superValidate } from 'sveltekit-superforms'; +import { valibot } from 'sveltekit-superforms/adapters'; +import * as v from 'valibot'; +import type { Actions, PageServerLoad } from './$types'; +import { BullMQ, getQueues } from '$lib/server/bullmq'; +import { Grading } from '$lib/server/models/grading'; +import { prisma } from '$lib/server/prisma'; +import { idSchema, paramNumber, stringLimits } from '$lib/valibot'; + +const createSchema = v.object({ + project_id: idSchema, + publisher_id: v.pipe(v.string(), v.maxBytes(stringLimits.grading.publisher_id)) +}); + +export const load = (async ({ url }) => { + const projectId = v.safeParse(v.pipe(paramNumber, idSchema), url.searchParams.get('project_id')); + return { + form: await superValidate( + { + project_id: projectId.success ? projectId.output : 0, + publisher_id: '' + }, + valibot(createSchema) + ) + }; +}) satisfies PageServerLoad; + +export const actions: Actions = { + default: async function ({ request }) { + const form = await superValidate(request, valibot(createSchema)); + if (!form.valid) return fail(400, { form, ok: false }); + + const project = await prisma.project.findUnique({ + where: { id: form.data.project_id }, + select: { id: true, url: true } + }); + if (!project) return fail(404, { form, ok: false, message: 'Project not found' }); + if (!project.url?.startsWith('s3://')) { + return fail(400, { + form, + ok: false, + message: 'Project does not have an s3:// URL' + }); + } + + const gradingResult = await prisma.gradingResult.create({ + data: { + project_id: project.id, + publisher_id: form.data.publisher_id, + status: Grading.Status.Initialized + } + }); + + await getQueues().Grading.add( + `Generate Grading Report #${gradingResult.uuid} for Project ${project.id}`, + { + type: BullMQ.JobType.Grading_Generate, + gradingResultUUID: gradingResult.uuid + } + ); + + redirect(303, `/grading-admin/view?id=${gradingResult.uuid}`); + } +}; diff --git a/src/routes/(ui)/grading-admin/create/+page.svelte b/src/routes/(ui)/grading-admin/create/+page.svelte new file mode 100644 index 00000000..c7dc8006 --- /dev/null +++ b/src/routes/(ui)/grading-admin/create/+page.svelte @@ -0,0 +1,61 @@ + + + +
  • Home
  • +
  • Grading Reports
  • +
  • {$title}
  • +
    +

    {$title}

    + +{#if $message} +
    {$message}
    +{/if} + +
    + + +   + + + +   + + +
    + + diff --git a/src/routes/(ui)/grading-admin/view/+page.server.ts b/src/routes/(ui)/grading-admin/view/+page.server.ts new file mode 100644 index 00000000..647f9b22 --- /dev/null +++ b/src/routes/(ui)/grading-admin/view/+page.server.ts @@ -0,0 +1,31 @@ +import { error } from '@sveltejs/kit'; +import * as v from 'valibot'; +import type { PageServerLoad } from './$types'; +import { Grading } from '$lib/server/models/grading'; +import { prisma } from '$lib/server/prisma'; + +export const load = (async ({ url }) => { + const id = v.safeParse(v.pipe(v.string(), v.uuid()), url.searchParams.get('id')); + if (!id.success) { + error(400, `missing id param`); + } + + const gradingResult = await prisma.gradingResult.findFirst({ + where: { + uuid: id.output + }, + include: { + project: true + }, + orderBy: { + created: 'desc' + } + }); + + if (!gradingResult) error(404); + + return { + rawGradingResult: gradingResult, + gradingResult: Grading.response(gradingResult) + }; +}) satisfies PageServerLoad; diff --git a/src/routes/(ui)/grading-admin/view/+page.svelte b/src/routes/(ui)/grading-admin/view/+page.svelte new file mode 100644 index 00000000..175c1866 --- /dev/null +++ b/src/routes/(ui)/grading-admin/view/+page.svelte @@ -0,0 +1,92 @@ + + + +
  • Home
  • +
  • Grading Reports
  • +
  • {data.gradingResult.uuid}
  • +
    + +

    {$title}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ID{data.gradingResult.uuid}
    Project ID + + {data.gradingResult.project_id} + +
    Project Name{data.rawGradingResult.project.project_name}
    Publisher{data.gradingResult.publisher_id}
    Status{data.gradingResult.status}
    Result{data.gradingResult.result}
    Reports + {#if data.gradingResult.reports.html} + HTML +   + {/if} + {#if data.gradingResult.reports.json} + JSON + {/if} +
    Lambda Request ID{data.rawGradingResult.lambda_request_id}
    Created{getTimeDateString(data.gradingResult.created)}
    Updated{getTimeDateString(data.gradingResult.updated)}
    diff --git a/src/routes/(ui)/project-admin/view/+page.server.ts b/src/routes/(ui)/project-admin/view/+page.server.ts index 0e5247ab..048bb336 100644 --- a/src/routes/(ui)/project-admin/view/+page.server.ts +++ b/src/routes/(ui)/project-admin/view/+page.server.ts @@ -1,6 +1,7 @@ import { error } from '@sveltejs/kit'; import * as v from 'valibot'; import type { PageServerLoad } from './$types'; +import { Grading } from '$lib/server/models/grading'; import { prisma } from '$lib/server/prisma'; import { idSchema, paramNumber } from '$lib/valibot'; @@ -13,12 +14,24 @@ export const load = (async ({ url }) => { const project = await prisma.project.findUnique({ where: { id: id.output + }, + include: { + gradingResult: { + take: 5, + orderBy: { + created: 'desc' + } + } } }); if (!project) error(404); + const projectToReturn = { + ...project, + gradingResult: project.gradingResult.map((r) => Grading.response(r)) + }; return { - project + project: projectToReturn }; }) satisfies PageServerLoad; diff --git a/src/routes/(ui)/project-admin/view/+page.svelte b/src/routes/(ui)/project-admin/view/+page.svelte index d4c2c7c5..2571df21 100644 --- a/src/routes/(ui)/project-admin/view/+page.svelte +++ b/src/routes/(ui)/project-admin/view/+page.svelte @@ -2,6 +2,7 @@ import type { PageData } from './$types'; import { page } from '$app/state'; import Breadcrumbs from '$lib/components/Breadcrumbs.svelte'; + import IconContainer from '$lib/components/IconContainer.svelte'; import { title } from '$lib/stores'; import { getTimeDateString } from '$lib/utils/time'; @@ -24,6 +25,10 @@ @@ -98,3 +103,48 @@
    + +

    Recent Grading Reports

    + + + + + + + + + + + + + + {#each data.project.gradingResult as gradingResult} + + + + + + + + + {:else} + + + + {/each} + +
    IDStatusResultPublisherCreated
    {gradingResult.uuid}{gradingResult.status}{gradingResult.result}{gradingResult.publisher_id}{getTimeDateString(gradingResult.created)} + {#if gradingResult.reports.html} + + + + {/if} + {#if gradingResult.reports.json} + + + + {/if} + + + +
    No grading reports