diff --git a/.gitignore b/.gitignore index b48465dcc..39129a59e 100644 --- a/.gitignore +++ b/.gitignore @@ -142,8 +142,6 @@ docker_data /CLAUDE.md /COPILOT.md /GEMINI.md -/_reference/select-component-test-plan.md - /.cursorrules /AGENTS.md /AI_CONTEXT.md diff --git a/_reference/localEmailViewer/README.md b/_reference/localEmailViewer/README.md index 3f8a6fc71..cbfe9e555 100644 --- a/_reference/localEmailViewer/README.md +++ b/_reference/localEmailViewer/README.md @@ -1,7 +1,62 @@ -This will connect to your dockers local stack session and render the email in HTML. +This app connects to your Docker LocalStack endpoints and gives you a compact inspector for: + +- SES generated emails +- CloudWatch log groups, streams, and recent events +- Secrets Manager secrets and values +- S3 buckets and object previews + +```shell +npm start +``` + +Or: ```shell node index.js ``` -http://localhost:3334 +Open: http://localhost:3334 + +Features: + +- SES email workspace with manual refresh, live refresh, search, HTML/text/raw views, + attachment downloads, and new-message highlighting +- CloudWatch Logs workspace with log group selection, stream filtering, adjustable time window, + adjustable event limit, live refresh, in-browser log search, log-level highlighting, wrap toggle, + and optional tail-to-newest mode +- Secrets Manager workspace with live refresh, search, expandable secret metadata, lazy-loaded + secret values, masked-by-default secret viewing, and quick copy actions +- S3 Explorer workspace with bucket selection, prefix filtering, object search, lazy object + previews, + object key/URI copy actions, and downloads +- Shared LocalStack service health strip plus a reset action for clearing saved viewer state +- Compact single-page UI for switching between the local stack tools you use most + +Code layout: + +- `index.js`: small Express bootstrap and route registration +- `server/config.js`: LocalStack endpoints, defaults, and AWS client setup +- `server/localstack-service.js`: SES, Logs, Secrets, and S3 data loading helpers +- `server/page.js`: server-rendered HTML shell, CSS, and client config payload +- `public/client-app.js`: browser-side UI state, rendering, refresh logic, and interactions + +Optional environment variables: + +```shell +PORT=3334 +SES_VIEWER_ENDPOINT=http://localhost:4566/_aws/ses +SES_VIEWER_REFRESH_MS=10000 +SES_VIEWER_FETCH_TIMEOUT_MS=5000 +CLOUDWATCH_VIEWER_ENDPOINT=http://localhost:4566 +CLOUDWATCH_VIEWER_REGION=ca-central-1 +CLOUDWATCH_VIEWER_LOG_GROUP=development +CLOUDWATCH_VIEWER_WINDOW_MS=900000 +CLOUDWATCH_VIEWER_LIMIT=200 +SECRETS_VIEWER_ENDPOINT=http://localhost:4566 +SECRETS_VIEWER_REGION=ca-central-1 +S3_VIEWER_ENDPOINT=http://localhost:4566 +S3_VIEWER_REGION=ca-central-1 +S3_VIEWER_BUCKET= +S3_VIEWER_PREVIEW_BYTES=262144 +S3_VIEWER_IMAGE_PREVIEW_BYTES=1048576 +``` diff --git a/_reference/localEmailViewer/index.js b/_reference/localEmailViewer/index.js index 1dd17f779..67df19055 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -1,96 +1,342 @@ -// index.js - import express from "express"; -import fetch from "node-fetch"; -import { simpleParser } from "mailparser"; +import { readFileSync } from "node:fs"; +import { + CLOUDWATCH_DEFAULT_LIMIT, + CLOUDWATCH_DEFAULT_WINDOW_MS, + CLOUDWATCH_ENDPOINT, + CLOUDWATCH_REGION, + DEFAULT_REFRESH_MS, + PORT, + S3_ENDPOINT, + S3_REGION, + SES_ENDPOINT, + SECRETS_ENDPOINT, + SECRETS_REGION +} from "./server/config.js"; +import { getClientConfig, renderHtml } from "./server/page.js"; +import { + buildAttachmentDisposition, + buildInlineDisposition, + clampNumber, + findSesMessageById, + loadLogEvents, + loadLogGroups, + loadLogStreams, + loadMessageAttachment, + loadMessages, + loadS3Buckets, + loadS3ObjectDownload, + loadS3ObjectPreview, + loadS3Objects, + loadSecretValue, + loadSecrets, + loadServiceHealthSummary +} from "./server/localstack-service.js"; const app = express(); -const PORT = 3334; +const CLIENT_APP_PATH = new URL("./public/client-app.js", import.meta.url); +const CLIENT_APP_SOURCE = readFileSync(CLIENT_APP_PATH, "utf8"); -app.get("/", async (req, res) => { +app.use((req, res, next) => { + res.set("Cache-Control", "no-store"); + next(); +}); + +app.get("/", (req, res) => { + res.type("html").send(renderHtml()); +}); + +app.get("/app.js", (req, res) => { + res.type("application/javascript").send(`${CLIENT_APP_SOURCE}\n\nclientApp(${JSON.stringify(getClientConfig())});\n`); +}); + +app.get("/health", (req, res) => { + res.json({ + ok: true, + endpoint: SES_ENDPOINT, + endpoints: { + ses: SES_ENDPOINT, + cloudWatchLogs: CLOUDWATCH_ENDPOINT, + secretsManager: SECRETS_ENDPOINT, + s3: S3_ENDPOINT + }, + port: PORT, + defaultRefreshMs: DEFAULT_REFRESH_MS + }); +}); + +app.get("/api/service-health", async (req, res) => { try { - const response = await fetch("http://localhost:4566/_aws/ses"); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - const data = await response.json(); - const messagesHtml = await parseMessages(data.messages); - res.send(renderHtml(messagesHtml)); + res.json(await loadServiceHealthSummary()); } catch (error) { - console.error("Error fetching messages:", error); - res.status(500).send("Error fetching messages"); + console.error("Error fetching service health:", error); + res.status(502).json({ + error: "Unable to fetch LocalStack service health", + details: error.message + }); } }); -async function parseMessages(messages) { - const parsedMessages = await Promise.all( - messages.map(async (message, index) => { - try { - const parsed = await simpleParser(message.RawData); - return ` -
-
-
Message ${index + 1}
-
From: ${message.Source}
-
To: ${parsed.to.text || "No To Address"}
-
Subject: ${parsed.subject || "No Subject"}
-
Region: ${message.Region}
-
Timestamp: ${message.Timestamp}
-
-
${parsed.html || parsed.textAsHtml || "No HTML content available"}
-
- `; - } catch (error) { - console.error("Error parsing email:", error); - return ` -
-
Message ${index + 1}
-
From: ${message.Source}
-
Region: ${message.Region}
-
Timestamp: ${message.Timestamp}
-
Error parsing email content
-
- `; - } - }) - ); - return parsedMessages.join(""); -} +app.get("/api/messages", async (req, res) => { + try { + res.json(await loadMessages()); + } catch (error) { + console.error("Error fetching messages:", error); + res.status(502).json({ + error: "Unable to fetch messages from LocalStack SES", + details: error.message, + endpoint: SES_ENDPOINT + }); + } +}); -function renderHtml(messagesHtml) { - return ` - - - - - - Email Messages Viewer - - - - -
-

Email Messages Viewer

-
${messagesHtml}
-
- - - `; -} +app.get("/api/messages/:id/raw", async (req, res) => { + try { + const message = await findSesMessageById(req.params.id); + + if (!message) { + res.status(404).type("text/plain").send("Message not found"); + return; + } + + res.type("text/plain").send(message.RawData || ""); + } catch (error) { + console.error("Error fetching raw message:", error); + res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`); + } +}); + +app.get("/api/messages/:id/attachments/:index", async (req, res) => { + try { + const attachmentIndex = Number.parseInt(req.params.index, 10); + + if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) { + res.status(400).type("text/plain").send("Attachment index must be a non-negative integer"); + return; + } + + const attachment = await loadMessageAttachment(req.params.id, attachmentIndex); + + if (!attachment) { + res.status(404).type("text/plain").send("Attachment not found"); + return; + } + + res.setHeader("Content-Type", attachment.contentType); + res.setHeader("Content-Disposition", buildAttachmentDisposition(attachment.filename)); + res.setHeader("Content-Length", String(attachment.content.length)); + res.send(attachment.content); + } catch (error) { + console.error("Error downloading attachment:", error); + res.status(502).type("text/plain").send(`Unable to download attachment: ${error.message}`); + } +}); + +app.get("/api/logs/groups", async (req, res) => { + try { + const groups = await loadLogGroups(); + res.json({ + endpoint: CLOUDWATCH_ENDPOINT, + region: CLOUDWATCH_REGION, + groups + }); + } catch (error) { + console.error("Error fetching log groups:", error); + res.status(502).json({ + error: "Unable to fetch CloudWatch log groups from LocalStack", + details: error.message, + endpoint: CLOUDWATCH_ENDPOINT + }); + } +}); + +app.get("/api/logs/streams", async (req, res) => { + try { + const logGroupName = String(req.query.group || ""); + + if (!logGroupName) { + res.status(400).json({ error: "Query parameter 'group' is required" }); + return; + } + + res.json({ + logGroupName, + streams: await loadLogStreams(logGroupName) + }); + } catch (error) { + console.error("Error fetching log streams:", error); + res.status(502).json({ + error: "Unable to fetch CloudWatch log streams from LocalStack", + details: error.message, + endpoint: CLOUDWATCH_ENDPOINT + }); + } +}); + +app.get("/api/logs/events", async (req, res) => { + try { + const logGroupName = String(req.query.group || ""); + const logStreamName = String(req.query.stream || ""); + const windowMs = clampNumber(req.query.windowMs, CLOUDWATCH_DEFAULT_WINDOW_MS, 60 * 1000, 24 * 60 * 60 * 1000); + const limit = clampNumber(req.query.limit, CLOUDWATCH_DEFAULT_LIMIT, 25, 500); + + if (!logGroupName) { + res.status(400).json({ error: "Query parameter 'group' is required" }); + return; + } + + res.json(await loadLogEvents({ logGroupName, logStreamName, windowMs, limit })); + } catch (error) { + console.error("Error fetching log events:", error); + res.status(502).json({ + error: "Unable to fetch CloudWatch log events from LocalStack", + details: error.message, + endpoint: CLOUDWATCH_ENDPOINT + }); + } +}); + +app.get("/api/secrets", async (req, res) => { + try { + res.json(await loadSecrets()); + } catch (error) { + console.error("Error fetching secrets:", error); + res.status(502).json({ + error: "Unable to fetch Secrets Manager secrets from LocalStack", + details: error.message, + endpoint: SECRETS_ENDPOINT + }); + } +}); + +app.get("/api/secrets/value", async (req, res) => { + try { + const secretId = String(req.query.id || ""); + + if (!secretId) { + res.status(400).json({ error: "Query parameter 'id' is required" }); + return; + } + + res.json(await loadSecretValue(secretId)); + } catch (error) { + if (error?.name === "ResourceNotFoundException") { + res.status(404).json({ + error: "Secret not found", + details: error.message, + endpoint: SECRETS_ENDPOINT + }); + return; + } + + console.error("Error fetching secret value:", error); + res.status(502).json({ + error: "Unable to fetch Secrets Manager value from LocalStack", + details: error.message, + endpoint: SECRETS_ENDPOINT + }); + } +}); + +app.get("/api/s3/buckets", async (req, res) => { + try { + res.json(await loadS3Buckets()); + } catch (error) { + console.error("Error fetching S3 buckets:", error); + res.status(502).json({ + error: "Unable to fetch S3 buckets from LocalStack", + details: error.message, + endpoint: S3_ENDPOINT + }); + } +}); + +app.get("/api/s3/objects", async (req, res) => { + try { + const bucket = String(req.query.bucket || ""); + const prefix = String(req.query.prefix || ""); + + if (!bucket) { + res.status(400).json({ error: "Query parameter 'bucket' is required" }); + return; + } + + res.json(await loadS3Objects({ bucket, prefix })); + } catch (error) { + console.error("Error fetching S3 objects:", error); + res.status(502).json({ + error: "Unable to fetch S3 objects from LocalStack", + details: error.message, + endpoint: S3_ENDPOINT + }); + } +}); + +app.get("/api/s3/object", async (req, res) => { + try { + const bucket = String(req.query.bucket || ""); + const key = String(req.query.key || ""); + + if (!bucket || !key) { + res.status(400).json({ error: "Query parameters 'bucket' and 'key' are required" }); + return; + } + + res.json(await loadS3ObjectPreview({ bucket, key })); + } catch (error) { + if (error?.name === "NoSuchKey" || error?.name === "NotFound") { + res.status(404).json({ + error: "Object not found", + details: error.message, + endpoint: S3_ENDPOINT + }); + return; + } + + console.error("Error fetching S3 object preview:", error); + res.status(502).json({ + error: "Unable to fetch S3 object preview from LocalStack", + details: error.message, + endpoint: S3_ENDPOINT + }); + } +}); + +app.get("/api/s3/download", async (req, res) => { + try { + const bucket = String(req.query.bucket || ""); + const key = String(req.query.key || ""); + const inline = String(req.query.inline || "") === "1"; + + if (!bucket || !key) { + res.status(400).type("text/plain").send("Query parameters 'bucket' and 'key' are required"); + return; + } + + const object = await loadS3ObjectDownload({ bucket, key }); + + res.setHeader("Content-Type", object.contentType); + res.setHeader( + "Content-Disposition", + inline ? buildInlineDisposition(object.filename) : buildAttachmentDisposition(object.filename) + ); + res.setHeader("Content-Length", String(object.content.length)); + res.send(object.content); + } catch (error) { + if (error?.name === "NoSuchKey" || error?.name === "NotFound") { + res.status(404).type("text/plain").send("Object not found"); + return; + } + + console.error("Error downloading S3 object:", error); + res.status(502).type("text/plain").send(`Unable to download S3 object: ${error.message}`); + } +}); app.listen(PORT, () => { - console.log(`Server is running on http://localhost:${PORT}`); + console.log(`LocalStack inspector is running on http://localhost:${PORT}`); + console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`); + console.log(`Watching LocalStack CloudWatch Logs endpoint at ${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`); + console.log(`Watching LocalStack Secrets Manager endpoint at ${SECRETS_ENDPOINT} (${SECRETS_REGION})`); + console.log(`Watching LocalStack S3 endpoint at ${S3_ENDPOINT} (${S3_REGION})`); }); diff --git a/_reference/localEmailViewer/package-lock.json b/_reference/localEmailViewer/package-lock.json index 2c7ecad93..b01591261 100644 --- a/_reference/localEmailViewer/package-lock.json +++ b/_reference/localEmailViewer/package-lock.json @@ -9,11 +9,968 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-cloudwatch-logs": "^3.1012.0", + "@aws-sdk/client-s3": "^3.1013.0", + "@aws-sdk/client-secrets-manager": "^3.1013.0", "express": "^5.1.0", "mailparser": "^3.7.4", "node-fetch": "^3.3.2" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudwatch-logs": { + "version": "3.1012.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1012.0.tgz", + "integrity": "sha512-nkMczmeCG9a1YD48zDKZLX7wLGb5ycXMJXVFnXF8xVxQ2v2uo2xFz1ZZganWHSQxR6ZfBPG7R9OTkDrVx79ZPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.21", + "@aws-sdk/credential-provider-node": "^3.972.22", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.22", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.8", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-retry": "^4.4.43", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.42", + "@smithy/util-defaults-mode-node": "^4.2.45", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1013.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1013.0.tgz", + "integrity": "sha512-vFdyRyRatF+xP9Fi+4alZkmzZadqOAM34Pm6SUZsYtumNrWkgMc/pFWITnsq6eltM8qcV/vcinQ1ZBXWm/PlKg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/credential-provider-node": "^3.972.23", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.2", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.22", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.23", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.9", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-retry": "^4.4.43", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.42", + "@smithy/util-defaults-mode-node": "^4.2.45", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-secrets-manager": { + "version": "3.1013.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1013.0.tgz", + "integrity": "sha512-jVleaMhnwz7JsYZ8vo3otRdhNl1vmJPfwYQtAfb0T29TwfI9pvfRuWD4CXbm0DPmCJzhN7YT+tqn1dLhk83Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/credential-provider-node": "^3.972.23", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.23", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.9", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-retry": "^4.4.43", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.42", + "@smithy/util-defaults-mode-node": "^4.2.45", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.22.tgz", + "integrity": "sha512-lY6g5L95jBNgOUitUhfV2N/W+i08jHEl3xuLODYSQH5Sf50V+LkVYBSyZRLtv2RyuXZXiV7yQ+acpswK1tlrOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.14", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.20.tgz", + "integrity": "sha512-vI0QN96DFx3g9AunfOWF3CS4cMkqFiR/WM/FyP9QHr5rZ2dKPkYwP3tCgAOvGuu9CXI7dC1vU2FVUuZ+tfpNvQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.22.tgz", + "integrity": "sha512-aS/81smalpe7XDnuQfOq4LIPuaV2PRKU2aMTrHcqO5BD4HwO5kESOHNcec2AYfBtLtIDqgF6RXisgBnfK/jt0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.22.tgz", + "integrity": "sha512-rpF8fBT0LllMDp78s62aL2A/8MaccjyJ0ORzqu+ZADeECLSrrCWIeeXsuRam+pxiAMkI1uIyDZJmgLGdadkPXw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/credential-provider-env": "^3.972.20", + "@aws-sdk/credential-provider-http": "^3.972.22", + "@aws-sdk/credential-provider-login": "^3.972.22", + "@aws-sdk/credential-provider-process": "^3.972.20", + "@aws-sdk/credential-provider-sso": "^3.972.22", + "@aws-sdk/credential-provider-web-identity": "^3.972.22", + "@aws-sdk/nested-clients": "^3.996.12", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.22.tgz", + "integrity": "sha512-u33CO9zeNznlVSg9tWTCRYxaGkqr1ufU6qeClpmzAabXZa8RZxQoVXxL5T53oZJFzQYj+FImORCSsi7H7B77gQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/nested-clients": "^3.996.12", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.23.tgz", + "integrity": "sha512-U8tyLbLOZItuVWTH0ay9gWo4xMqZwqQbg1oMzdU4FQSkTpqXemm4X0uoKBR6llqAStgBp30ziKFJHTA43l4qMw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.20", + "@aws-sdk/credential-provider-http": "^3.972.22", + "@aws-sdk/credential-provider-ini": "^3.972.22", + "@aws-sdk/credential-provider-process": "^3.972.20", + "@aws-sdk/credential-provider-sso": "^3.972.22", + "@aws-sdk/credential-provider-web-identity": "^3.972.22", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.20.tgz", + "integrity": "sha512-QRfk7GbA4/HDRjhP3QYR6QBr/QKreVoOzvvlRHnOuGgYJkeoPgPY3LAI1kK1ZMgZ4hH9KiGp757/ntol+INAig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.22.tgz", + "integrity": "sha512-4vqlSaUbBj4aNPVKfB6yXuIQ2Z2mvLfIGba2OzzF6zUkN437/PGWsxBU2F8QPSFHti6seckvyCXidU3H+R8NvQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/nested-clients": "^3.996.12", + "@aws-sdk/token-providers": "3.1013.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.22.tgz", + "integrity": "sha512-/wN1CYg2rVLhW8/jLxMWacQrkpaynnL+4j/Z+e6X1PfoE6NiC0BeOw3i0JmtZrKun85wNV5GmspvuWJihfeeUw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/nested-clients": "^3.996.12", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.2.tgz", + "integrity": "sha512-4soN/N4R6ptdnHw7hXPVDZMIIL+vhN8rwtLdDyS0uD7ExhadtJzolTBIM5eKSkbw5uBEbIwtJc8HCG2NM6tN/g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", + "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.22.tgz", + "integrity": "sha512-dkUcRxF4rVpPbyHpxjCApGK6b7JpnSeo7tDoNakpRKmiLMCqgy4tlGBgeEYJnZgLrA4xc5jVKuXgvgqKqU18Kw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.23.tgz", + "integrity": "sha512-HQu8QoqGZZTvg0Spl9H39QTsSMFwgu+8yz/QGKndXFLk9FZMiCiIgBCVlTVKMDvVbgqIzD9ig+/HmXsIL2Rb+g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.12.tgz", + "integrity": "sha512-KLdQGJPSm98uLINolQ0Tol8OAbk7g0Y7zplHJ1K83vbMIH13aoCvR6Tho66xueW4l4aZlEgVGLWBnD8ifUMsGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.23", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.9", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-retry": "^4.4.43", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.42", + "@smithy/util-defaults-mode-node": "^4.2.45", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.8.tgz", + "integrity": "sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.10.tgz", + "integrity": "sha512-yJSbFTedh1McfqXa9wZzjchqQ2puq5PI/qRz5kUjg2UXS5mO4MBYBbeXaZ2rp/h+ZbkcYEdo4Qsiah9psyoxrA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.22", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1013.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1013.0.tgz", + "integrity": "sha512-IL1c54UvbuERrs9oLm5rvkzMciwhhpn1FL0SlC3XUMoLlFhdBsWJgQKK8O5fsQLxbFVqjbjFx9OBkrn44X9PHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/nested-clients": "^3.996.12", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.9.tgz", + "integrity": "sha512-jeFqqp8KD/P5O+qeKxyGeu7WEVIZFNprnkaDjGmBOjwxYwafCBhpxTgV1TlW6L8e76Vh/siNylNmN/OmSIFBUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.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/xml-builder": { + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.14.tgz", + "integrity": "sha512-G/Yd8Bnnyh8QrqLf8jWJbixEnScUFW24e/wOBGYdw1Cl4r80KX/DvHyM2GVZ2vTp7J4gTEr8IXJlTadA8+UfuQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "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/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -27,6 +984,739 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", + "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", + "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.27", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz", + "integrity": "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.44", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.44.tgz", + "integrity": "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", + "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", + "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.7.tgz", + "integrity": "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.43", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.43.tgz", + "integrity": "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.47", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.47.tgz", + "integrity": "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.13", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.20", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", + "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", + "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -60,6 +1750,12 @@ "node": ">=18" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -373,6 +2069,41 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -856,6 +2587,21 @@ "node": ">= 0.8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-to-regexp": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", @@ -1113,6 +2859,18 @@ "node": ">= 0.8" } }, + "node_modules/strnum": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/tlds": { "version": "1.259.0", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz", @@ -1131,6 +2889,12 @@ "node": ">=0.6" } }, + "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/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/_reference/localEmailViewer/package.json b/_reference/localEmailViewer/package.json index 5f553cf17..6972c7f95 100644 --- a/_reference/localEmailViewer/package.json +++ b/_reference/localEmailViewer/package.json @@ -4,13 +4,17 @@ "main": "index.js", "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node index.js", + "check": "node --check index.js && node --check public/client-app.js && node --check server/config.js && node --check server/localstack-service.js && node --check server/page.js" }, "keywords": [], "author": "", "license": "ISC", - "description": "", + "description": "LocalStack inspector for SES emails, CloudWatch logs, Secrets Manager, and S3", "dependencies": { + "@aws-sdk/client-cloudwatch-logs": "^3.1012.0", + "@aws-sdk/client-s3": "^3.1013.0", + "@aws-sdk/client-secrets-manager": "^3.1013.0", "express": "^5.1.0", "mailparser": "^3.7.4", "node-fetch": "^3.3.2" diff --git a/_reference/localEmailViewer/public/client-app.js b/_reference/localEmailViewer/public/client-app.js new file mode 100644 index 000000000..2e446c8fe --- /dev/null +++ b/_reference/localEmailViewer/public/client-app.js @@ -0,0 +1,3154 @@ +function clientApp(config) { + const THEME_STORAGE_KEY = "localstack-inspector-theme"; + const PANEL_STORAGE_KEY = "localstack-inspector-panel"; + const EMAIL_PREFERENCES_STORAGE_KEY = "localstack-inspector-email-preferences"; + const LOG_PREFERENCES_STORAGE_KEY = "localstack-inspector-log-preferences"; + const SECRET_PREFERENCES_STORAGE_KEY = "localstack-inspector-secret-preferences"; + const S3_PREFERENCES_STORAGE_KEY = "localstack-inspector-s3-preferences"; + const HEALTH_REFRESH_MS = 30000; + const REFRESH_INTERVALS = [5000, 10000, 15000, 30000, 60000]; + const LOG_WINDOWS = [300000, 900000, 3600000, 21600000, 86400000]; + const LOG_LIMITS = [100, 200, 300, 500]; + const storedEmailPreferences = getStoredPreferences(EMAIL_PREFERENCES_STORAGE_KEY); + const storedLogPreferences = getStoredPreferences(LOG_PREFERENCES_STORAGE_KEY); + const storedSecretPreferences = getStoredPreferences(SECRET_PREFERENCES_STORAGE_KEY); + const storedS3Preferences = getStoredPreferences(S3_PREFERENCES_STORAGE_KEY); + + const appState = { + panel: getInitialPanel(), + logsReady: false, + secretsReady: false, + s3Ready: false, + theme: getInitialTheme() + }; + + const state = { + messages: [], + filtered: [], + search: getStoredText(storedEmailPreferences?.search), + auto: getStoredBoolean(storedEmailPreferences?.auto, true), + interval: getStoredNumber(storedEmailPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + parseErrors: 0, + newest: "", + newIds: new Set(), + knownIds: new Set(), + openIds: new Set(), + views: {}, + raw: {}, + listSignature: "" + }; + + const logsState = { + groups: [], + streams: [], + events: [], + filtered: [], + group: getStoredText(storedLogPreferences?.group, config.defaultLogGroup || ""), + stream: getStoredText(storedLogPreferences?.stream), + search: getStoredText(storedLogPreferences?.search), + auto: getStoredBoolean(storedLogPreferences?.auto, true), + interval: getStoredNumber(storedLogPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + windowMs: getStoredNumber(storedLogPreferences?.windowMs, LOG_WINDOWS, config.defaultLogWindowMs), + limit: getStoredNumber(storedLogPreferences?.limit, LOG_LIMITS, config.defaultLogLimit), + wrapLines: getStoredBoolean(storedLogPreferences?.wrapLines, true), + tailNewest: getStoredBoolean(storedLogPreferences?.tailNewest, false), + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + newest: 0, + searchedLogStreams: 0, + openIds: new Set(), + listSignature: "" + }; + + const secretsState = { + items: [], + filtered: [], + search: getStoredText(storedSecretPreferences?.search), + auto: getStoredBoolean(storedSecretPreferences?.auto, true), + interval: getStoredNumber(storedSecretPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + newest: "", + openIds: new Set(), + values: {}, + revealedIds: new Set(), + listSignature: "" + }; + + const s3State = { + buckets: [], + objects: [], + filtered: [], + bucket: getStoredText(storedS3Preferences?.bucket, config.defaultS3Bucket || ""), + prefix: getStoredText(storedS3Preferences?.prefix), + search: getStoredText(storedS3Preferences?.search), + auto: getStoredBoolean(storedS3Preferences?.auto, true), + interval: getStoredNumber(storedS3Preferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + newest: "", + openIds: new Set(), + previews: {}, + listSignature: "" + }; + + const healthState = { + services: {}, + loading: false, + error: "", + updatedAt: 0, + source: "initial" + }; + + const el = { + themeToggle: document.getElementById("themeToggle"), + resetStateButton: document.getElementById("resetStateButton"), + healthRefreshButton: document.getElementById("healthRefreshButton"), + healthStrip: document.getElementById("healthStrip"), + emailsPanel: document.getElementById("emailsPanel"), + logsPanel: document.getElementById("logsPanel"), + secretsPanel: document.getElementById("secretsPanel"), + s3Panel: document.getElementById("s3Panel"), + refreshButton: document.getElementById("refreshButton"), + autoToggle: document.getElementById("autoToggle"), + intervalSelect: document.getElementById("intervalSelect"), + searchInput: document.getElementById("searchInput"), + clearSearchButton: document.getElementById("clearSearchButton"), + expandAllButton: document.getElementById("expandAllButton"), + collapseAllButton: document.getElementById("collapseAllButton"), + scrollToTopButton: document.getElementById("scrollToTopButton"), + statusChip: document.getElementById("statusChip"), + totalStat: document.getElementById("totalStat"), + visibleStat: document.getElementById("visibleStat"), + newStat: document.getElementById("newStat"), + newestStat: document.getElementById("newestStat"), + updatedStat: document.getElementById("updatedStat"), + fetchStat: document.getElementById("fetchStat"), + fetchDetail: document.getElementById("fetchDetail"), + banner: document.getElementById("banner"), + empty: document.getElementById("empty"), + list: document.getElementById("list"), + emailsContentPane: document.getElementById("emailsContentPane"), + logsRefreshButton: document.getElementById("logsRefreshButton"), + logsAutoToggle: document.getElementById("logsAutoToggle"), + logsIntervalSelect: document.getElementById("logsIntervalSelect"), + logsGroupSelect: document.getElementById("logsGroupSelect"), + logsStreamSelect: document.getElementById("logsStreamSelect"), + logsWindowSelect: document.getElementById("logsWindowSelect"), + logsLimitSelect: document.getElementById("logsLimitSelect"), + logsSearchInput: document.getElementById("logsSearchInput"), + logsClearSearchButton: document.getElementById("logsClearSearchButton"), + logsWrapToggle: document.getElementById("logsWrapToggle"), + logsTailToggle: document.getElementById("logsTailToggle"), + logsExpandAllButton: document.getElementById("logsExpandAllButton"), + logsCollapseAllButton: document.getElementById("logsCollapseAllButton"), + logsScrollToTopButton: document.getElementById("logsScrollToTopButton"), + logsStatusChip: document.getElementById("logsStatusChip"), + logsTotalStat: document.getElementById("logsTotalStat"), + logsVisibleStat: document.getElementById("logsVisibleStat"), + logsStreamsStat: document.getElementById("logsStreamsStat"), + logsNewestStat: document.getElementById("logsNewestStat"), + logsUpdatedStat: document.getElementById("logsUpdatedStat"), + logsFetchStat: document.getElementById("logsFetchStat"), + logsFetchDetail: document.getElementById("logsFetchDetail"), + logsBanner: document.getElementById("logsBanner"), + logsEmpty: document.getElementById("logsEmpty"), + logsList: document.getElementById("logsList"), + logsContentPane: document.getElementById("logsContentPane"), + secretsRefreshButton: document.getElementById("secretsRefreshButton"), + secretsAutoToggle: document.getElementById("secretsAutoToggle"), + secretsIntervalSelect: document.getElementById("secretsIntervalSelect"), + secretsSearchInput: document.getElementById("secretsSearchInput"), + secretsClearSearchButton: document.getElementById("secretsClearSearchButton"), + secretsExpandAllButton: document.getElementById("secretsExpandAllButton"), + secretsCollapseAllButton: document.getElementById("secretsCollapseAllButton"), + secretsScrollToTopButton: document.getElementById("secretsScrollToTopButton"), + secretsStatusChip: document.getElementById("secretsStatusChip"), + secretsTotalStat: document.getElementById("secretsTotalStat"), + secretsVisibleStat: document.getElementById("secretsVisibleStat"), + secretsLoadedStat: document.getElementById("secretsLoadedStat"), + secretsNewestStat: document.getElementById("secretsNewestStat"), + secretsUpdatedStat: document.getElementById("secretsUpdatedStat"), + secretsFetchStat: document.getElementById("secretsFetchStat"), + secretsFetchDetail: document.getElementById("secretsFetchDetail"), + secretsBanner: document.getElementById("secretsBanner"), + secretsEmpty: document.getElementById("secretsEmpty"), + secretsList: document.getElementById("secretsList"), + secretsContentPane: document.getElementById("secretsContentPane"), + s3RefreshButton: document.getElementById("s3RefreshButton"), + s3AutoToggle: document.getElementById("s3AutoToggle"), + s3IntervalSelect: document.getElementById("s3IntervalSelect"), + s3BucketSelect: document.getElementById("s3BucketSelect"), + s3PrefixInput: document.getElementById("s3PrefixInput"), + s3ApplyPrefixButton: document.getElementById("s3ApplyPrefixButton"), + s3SearchInput: document.getElementById("s3SearchInput"), + s3ClearSearchButton: document.getElementById("s3ClearSearchButton"), + s3ExpandAllButton: document.getElementById("s3ExpandAllButton"), + s3CollapseAllButton: document.getElementById("s3CollapseAllButton"), + s3ScrollToTopButton: document.getElementById("s3ScrollToTopButton"), + s3StatusChip: document.getElementById("s3StatusChip"), + s3TotalStat: document.getElementById("s3TotalStat"), + s3VisibleStat: document.getElementById("s3VisibleStat"), + s3BucketsStat: document.getElementById("s3BucketsStat"), + s3NewestStat: document.getElementById("s3NewestStat"), + s3UpdatedStat: document.getElementById("s3UpdatedStat"), + s3FetchStat: document.getElementById("s3FetchStat"), + s3FetchDetail: document.getElementById("s3FetchDetail"), + s3Banner: document.getElementById("s3Banner"), + s3Empty: document.getElementById("s3Empty"), + s3List: document.getElementById("s3List"), + s3ContentPane: document.getElementById("s3ContentPane") + }; + + el.autoToggle.checked = state.auto; + el.intervalSelect.value = String(state.interval); + el.searchInput.value = state.search; + el.logsAutoToggle.checked = logsState.auto; + el.logsIntervalSelect.value = String(logsState.interval); + el.logsWindowSelect.value = String(logsState.windowMs); + el.logsLimitSelect.value = String(logsState.limit); + el.logsSearchInput.value = logsState.search; + el.logsWrapToggle.checked = logsState.wrapLines; + el.logsTailToggle.checked = logsState.tailNewest; + el.secretsAutoToggle.checked = secretsState.auto; + el.secretsIntervalSelect.value = String(secretsState.interval); + el.secretsSearchInput.value = secretsState.search; + el.s3AutoToggle.checked = s3State.auto; + el.s3IntervalSelect.value = String(s3State.interval); + el.s3PrefixInput.value = s3State.prefix; + el.s3SearchInput.value = s3State.search; + applyTheme(appState.theme); + persistPanel(); + persistEmailPreferences(); + persistLogPreferences(); + persistSecretPreferences(); + persistS3Preferences(); + wire(); + renderWorkspace(); + renderAll(); + renderLogsAll(); + renderSecretsAll(); + renderS3All(); + renderHealthStrip(); + if (appState.panel === "logs") { + ensureLogsReady(); + } else if (appState.panel === "secrets") { + ensureSecretsReady(); + } else if (appState.panel === "s3") { + ensureS3Ready(); + } else { + refreshMessages("initial"); + } + refreshHealthSummary("initial"); + window.setInterval(() => { + if (!document.hidden) { + refreshHealthSummary("auto"); + } + }, HEALTH_REFRESH_MS); + window.setInterval(() => { + renderLiveClock(); + renderLogsLiveClock(); + renderSecretsLiveClock(); + renderS3LiveClock(); + renderHealthStrip(); + }, 1000); + + function wire() { + el.themeToggle.addEventListener("click", () => { + applyTheme(appState.theme === "dark" ? "light" : "dark"); + }); + + el.resetStateButton.addEventListener("click", () => { + resetSavedState(); + }); + + el.healthRefreshButton.addEventListener("click", () => { + refreshHealthSummary("manual"); + }); + + el.healthStrip.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-health-panel]"); + + if (!button) { + return; + } + + await setPanel(button.dataset.healthPanel); + }); + + el.refreshButton.addEventListener("click", () => refreshMessages("manual")); + + el.autoToggle.addEventListener("change", () => { + state.auto = el.autoToggle.checked; + persistEmailPreferences(); + scheduleRefresh(); + renderStatus(); + }); + + el.intervalSelect.addEventListener("change", () => { + state.interval = Number(el.intervalSelect.value) || config.defaultRefreshMs; + persistEmailPreferences(); + scheduleRefresh(); + renderStatus(); + }); + + el.searchInput.addEventListener("input", (event) => { + state.search = event.target.value; + applyFilter(); + }); + + el.clearSearchButton.addEventListener("click", () => { + state.search = ""; + el.searchInput.value = ""; + applyFilter(); + }); + + el.expandAllButton.addEventListener("click", () => { + state.filtered.forEach((message) => state.openIds.add(message.id)); + syncCardExpansion(); + }); + + el.collapseAllButton.addEventListener("click", () => { + state.openIds.clear(); + syncCardExpansion(); + }); + + el.scrollToTopButton.addEventListener("click", () => { + scrollPaneToTop(el.emailsContentPane); + }); + + el.emailsContentPane.addEventListener("scroll", () => { + updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); + }); + + el.list.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-action]"); + + if (!button) { + return; + } + + const id = button.dataset.id; + const message = getMessage(id); + + if (!message) { + return; + } + + if (button.dataset.action === "view") { + state.views[id] = button.dataset.view; + renderList(); + return; + } + + if (button.dataset.action === "load-raw") { + await loadRaw(id); + renderList(); + return; + } + + if (button.dataset.action === "copy-raw") { + const raw = await loadRaw(id); + + if (raw) { + await copyText(raw); + setStatus("Raw message copied to the clipboard.", "ok"); + } + } + }); + + el.logsRefreshButton.addEventListener("click", () => refreshLogs("manual")); + + el.logsAutoToggle.addEventListener("change", () => { + logsState.auto = el.logsAutoToggle.checked; + persistLogPreferences(); + scheduleLogsRefresh(); + renderLogsStatus(); + }); + + el.logsIntervalSelect.addEventListener("change", () => { + logsState.interval = Number(el.logsIntervalSelect.value) || config.defaultRefreshMs; + persistLogPreferences(); + scheduleLogsRefresh(); + renderLogsStatus(); + }); + + el.logsWindowSelect.addEventListener("change", () => { + logsState.windowMs = Number(el.logsWindowSelect.value) || config.defaultLogWindowMs; + persistLogPreferences(); + refreshLogs("window"); + }); + + el.logsLimitSelect.addEventListener("change", () => { + logsState.limit = Number(el.logsLimitSelect.value) || config.defaultLogLimit; + persistLogPreferences(); + refreshLogs("limit"); + }); + + el.logsSearchInput.addEventListener("input", (event) => { + logsState.search = event.target.value; + applyLogsFilter(); + }); + + el.logsClearSearchButton.addEventListener("click", () => { + logsState.search = ""; + el.logsSearchInput.value = ""; + applyLogsFilter(); + }); + + el.logsWrapToggle.addEventListener("change", () => { + logsState.wrapLines = el.logsWrapToggle.checked; + persistLogPreferences(); + renderLogsList(); + }); + + el.logsTailToggle.addEventListener("change", () => { + logsState.tailNewest = el.logsTailToggle.checked; + persistLogPreferences(); + }); + + el.logsExpandAllButton.addEventListener("click", () => { + logsState.filtered.forEach((event) => logsState.openIds.add(event.id)); + renderLogsList(); + }); + + el.logsCollapseAllButton.addEventListener("click", () => { + logsState.openIds.clear(); + renderLogsList(); + }); + + el.logsScrollToTopButton.addEventListener("click", () => { + scrollPaneToTop(el.logsContentPane); + }); + + el.logsContentPane.addEventListener("scroll", () => { + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + }); + + el.logsGroupSelect.addEventListener("change", async () => { + logsState.group = el.logsGroupSelect.value; + logsState.stream = ""; + persistLogPreferences(); + await refreshLogStreams(); + await refreshLogs("group"); + }); + + el.logsStreamSelect.addEventListener("change", () => { + logsState.stream = el.logsStreamSelect.value; + persistLogPreferences(); + refreshLogs("stream"); + }); + + el.logsList.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-log-action]"); + + if (!button) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const logEvent = getLogEvent(button.dataset.id); + + if (!logEvent) { + return; + } + + if (button.dataset.logAction === "copy") { + await copyText(formatLogMessage(logEvent.message)); + setLogsStatus("Log payload copied to the clipboard.", "ok"); + } + }); + + el.secretsRefreshButton.addEventListener("click", () => refreshSecrets("manual")); + + el.secretsAutoToggle.addEventListener("change", () => { + secretsState.auto = el.secretsAutoToggle.checked; + persistSecretPreferences(); + scheduleSecretsRefresh(); + renderSecretsStatus(); + }); + + el.secretsIntervalSelect.addEventListener("change", () => { + secretsState.interval = Number(el.secretsIntervalSelect.value) || config.defaultRefreshMs; + persistSecretPreferences(); + scheduleSecretsRefresh(); + renderSecretsStatus(); + }); + + el.secretsSearchInput.addEventListener("input", (event) => { + secretsState.search = event.target.value; + applySecretsFilter(); + }); + + el.secretsClearSearchButton.addEventListener("click", () => { + secretsState.search = ""; + el.secretsSearchInput.value = ""; + applySecretsFilter(); + }); + + el.secretsExpandAllButton.addEventListener("click", () => { + secretsState.filtered.forEach((secret) => secretsState.openIds.add(secret.id)); + syncSecretExpansion(); + }); + + el.secretsCollapseAllButton.addEventListener("click", () => { + secretsState.openIds.clear(); + syncSecretExpansion(); + }); + + el.secretsScrollToTopButton.addEventListener("click", () => { + scrollPaneToTop(el.secretsContentPane); + }); + + el.secretsContentPane.addEventListener("scroll", () => { + updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + }); + + el.secretsList.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-secret-action]"); + + if (!button) { + return; + } + + const id = button.dataset.id; + const secret = getSecret(id); + + if (!secret) { + return; + } + + if (button.dataset.secretAction === "load-value") { + await ensureSecretValue(id, { force: false }); + return; + } + + if (button.dataset.secretAction === "reload-value") { + await ensureSecretValue(id, { force: true }); + return; + } + + if (button.dataset.secretAction === "copy-value") { + const entry = await ensureSecretValue(id, { force: false }); + + if (entry?.status === "loaded") { + await copyText(entry.copyValue); + setSecretsStatus("Secret value copied to the clipboard.", "ok"); + } + + return; + } + + if (button.dataset.secretAction === "toggle-reveal") { + toggleSecretReveal(id); + return; + } + + if (button.dataset.secretAction === "copy-name") { + await copyText(secret.name || ""); + setSecretsStatus("Secret name copied to the clipboard.", "ok"); + return; + } + + if (button.dataset.secretAction === "copy-arn") { + await copyText(secret.arn || ""); + setSecretsStatus("Secret ARN copied to the clipboard.", "ok"); + return; + } + + if (button.dataset.secretAction === "copy-env") { + const entry = await ensureSecretValue(id, { force: false }); + + if (entry?.status === "loaded") { + await copyText(`${secret.name}=${entry.copyValue}`); + setSecretsStatus("Secret env line copied to the clipboard.", "ok"); + } + } + }); + + el.s3RefreshButton.addEventListener("click", async () => { + await refreshS3Buckets(); + await refreshS3("manual"); + }); + + el.s3AutoToggle.addEventListener("change", () => { + s3State.auto = el.s3AutoToggle.checked; + persistS3Preferences(); + scheduleS3Refresh(); + renderS3Status(); + }); + + el.s3IntervalSelect.addEventListener("change", () => { + s3State.interval = Number(el.s3IntervalSelect.value) || config.defaultRefreshMs; + persistS3Preferences(); + scheduleS3Refresh(); + renderS3Status(); + }); + + el.s3BucketSelect.addEventListener("change", () => { + s3State.bucket = el.s3BucketSelect.value; + persistS3Preferences(); + refreshS3("bucket"); + }); + + el.s3PrefixInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + s3State.prefix = el.s3PrefixInput.value.trim(); + persistS3Preferences(); + refreshS3("prefix"); + } + }); + + el.s3ApplyPrefixButton.addEventListener("click", () => { + s3State.prefix = el.s3PrefixInput.value.trim(); + persistS3Preferences(); + refreshS3("prefix"); + }); + + el.s3SearchInput.addEventListener("input", (event) => { + s3State.search = event.target.value; + applyS3Filter(); + }); + + el.s3ClearSearchButton.addEventListener("click", () => { + s3State.search = ""; + el.s3SearchInput.value = ""; + applyS3Filter(); + }); + + el.s3ExpandAllButton.addEventListener("click", () => { + s3State.filtered.forEach((object) => s3State.openIds.add(object.id)); + syncS3Expansion(); + }); + + el.s3CollapseAllButton.addEventListener("click", () => { + s3State.openIds.clear(); + syncS3Expansion(); + }); + + el.s3ScrollToTopButton.addEventListener("click", () => { + scrollPaneToTop(el.s3ContentPane); + }); + + el.s3ContentPane.addEventListener("scroll", () => { + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + }); + + el.s3List.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-s3-action]"); + + if (!button) { + return; + } + + const id = button.dataset.id; + const object = getS3Object(id); + + if (!object) { + return; + } + + if (button.dataset.s3Action === "load-preview") { + await ensureS3Preview(id, { force: false }); + return; + } + + if (button.dataset.s3Action === "reload-preview") { + await ensureS3Preview(id, { force: true }); + return; + } + + if (button.dataset.s3Action === "copy-key") { + await copyText(object.key); + setS3Status("Object key copied to the clipboard.", "ok"); + return; + } + + if (button.dataset.s3Action === "copy-uri") { + await copyText(`s3://${object.bucket}/${object.key}`); + setS3Status("S3 URI copied to the clipboard.", "ok"); + } + }); + + document.addEventListener("visibilitychange", () => { + window.clearTimeout(state.timer); + window.clearTimeout(logsState.timer); + window.clearTimeout(secretsState.timer); + window.clearTimeout(s3State.timer); + + if (document.hidden) { + renderStatus(); + renderLogsStatus(); + renderSecretsStatus(); + renderS3Status(); + return; + } + + refreshHealthSummary("visibility"); + + if (appState.panel === "s3") { + if (s3State.auto) { + refreshS3("visibility"); + } else { + renderS3Status(); + } + return; + } + + if (appState.panel === "secrets") { + if (secretsState.auto) { + refreshSecrets("visibility"); + } else { + renderSecretsStatus(); + } + return; + } + + if (appState.panel === "logs") { + if (logsState.auto) { + refreshLogs("visibility"); + } else { + renderLogsStatus(); + } + return; + } + + if (state.auto) { + refreshMessages("visibility"); + } else { + renderStatus(); + } + }); + + window.addEventListener("keydown", (event) => { + const isField = + event.target instanceof HTMLElement && + (event.target.matches("input,textarea,select") || event.target.isContentEditable); + + if (!isField && event.key.toLowerCase() === "r") { + event.preventDefault(); + + if (appState.panel === "logs") { + refreshLogs("keyboard"); + return; + } + + if (appState.panel === "secrets") { + refreshSecrets("keyboard"); + return; + } + + if (appState.panel === "s3") { + refreshS3("keyboard"); + return; + } + + refreshMessages("keyboard"); + } + }); + + updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + } + + async function setPanel(panel) { + if (!panel || panel === appState.panel) { + if (panel === "logs") { + await ensureLogsReady(); + } else if (panel === "secrets") { + await ensureSecretsReady(); + } else if (panel === "s3") { + await ensureS3Ready(); + } + + renderWorkspace(); + return; + } + + appState.panel = panel; + persistPanel(); + renderWorkspace(); + + if (panel === "logs") { + await ensureLogsReady(); + } else if (panel === "secrets") { + await ensureSecretsReady(); + } else if (panel === "s3") { + await ensureS3Ready(); + } else if (!state.updatedAt && !state.loading) { + await refreshMessages("panel"); + } + + scheduleRefresh(); + scheduleLogsRefresh(); + scheduleSecretsRefresh(); + scheduleS3Refresh(); + renderStatus(); + renderLogsStatus(); + renderSecretsStatus(); + renderS3Status(); + } + + function renderWorkspace() { + el.emailsPanel.hidden = appState.panel !== "emails"; + el.logsPanel.hidden = appState.panel !== "logs"; + el.secretsPanel.hidden = appState.panel !== "secrets"; + el.s3Panel.hidden = appState.panel !== "s3"; + renderHealthStrip(); + } + + async function ensureLogsReady() { + if (appState.logsReady) { + return; + } + + await refreshLogGroups(); + appState.logsReady = !logsState.error; + + if (logsState.group) { + await refreshLogs("initial"); + } + } + + async function ensureSecretsReady() { + if (appState.secretsReady) { + return; + } + + await refreshSecrets("initial"); + appState.secretsReady = !secretsState.error; + } + + async function ensureS3Ready() { + if (appState.s3Ready) { + return; + } + + await refreshS3Buckets(); + appState.s3Ready = !s3State.error; + + if (s3State.bucket) { + await refreshS3("initial"); + } + } + + async function refreshMessages(source) { + if (state.loading) { + return; + } + + let shouldRenderList = false; + + state.loading = true; + state.source = source; + state.error = ""; + renderStatus(); + renderFetch(); + + try { + const response = await fetch("/api/messages", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const messages = Array.isArray(payload.messages) ? payload.messages : []; + const nextIds = new Set(messages.map((message) => message.id)); + const nextSignature = computeListSignature(messages); + + shouldRenderList = nextSignature !== state.listSignature; + state.newIds = + state.updatedAt && shouldRenderList + ? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id)) + : state.newIds; + state.knownIds = nextIds; + state.messages = messages; + state.duration = payload.fetchDurationMs || 0; + state.parseErrors = payload.parseErrors || 0; + state.newest = payload.latestMessageTimestamp || ""; + state.updatedAt = Date.now(); + state.listSignature = nextSignature; + + pruneState(); + applyFilter(shouldRenderList); + setStatus(`Updated ${messages.length} message${messages.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + state.error = error.message || "Unknown refresh error"; + setStatus(`Refresh failed: ${state.error}`, "bad"); + } finally { + state.loading = false; + scheduleRefresh(); + renderAll({ renderList: shouldRenderList }); + } + } + + async function refreshLogGroups() { + try { + const response = await fetch("/api/logs/groups", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + logsState.groups = Array.isArray(payload.groups) ? payload.groups : []; + + const availableGroups = logsState.groups.map((group) => group.name).filter(Boolean); + + if (!availableGroups.includes(logsState.group)) { + logsState.group = availableGroups.includes(config.defaultLogGroup) + ? config.defaultLogGroup + : availableGroups[0] || ""; + } + + logsState.error = ""; + await refreshLogStreams(); + } catch (error) { + logsState.error = error.message || "Unknown log group refresh error"; + } finally { + persistLogPreferences(); + renderLogsAll(); + } + } + + async function refreshLogStreams() { + if (!logsState.group) { + logsState.streams = []; + logsState.stream = ""; + renderLogsAll(); + return; + } + + try { + const response = await fetch(`/api/logs/streams?group=${encodeURIComponent(logsState.group)}`, { + cache: "no-store" + }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + logsState.streams = Array.isArray(payload.streams) ? payload.streams : []; + + if (!logsState.streams.some((stream) => stream.name === logsState.stream)) { + logsState.stream = ""; + } + + logsState.error = ""; + } catch (error) { + logsState.streams = []; + logsState.stream = ""; + logsState.error = error.message || "Unknown log stream refresh error"; + } finally { + persistLogPreferences(); + renderLogsAll(); + } + } + + async function refreshLogs(source) { + if (logsState.loading) { + return; + } + + if (!appState.logsReady) { + await ensureLogsReady(); + return; + } + + if (!logsState.group) { + logsState.error = logsState.groups.length ? "Choose a log group to load events." : "No log groups found."; + renderLogsAll(); + return; + } + + let shouldRenderList = false; + + logsState.loading = true; + logsState.source = source; + logsState.error = ""; + renderLogsStatus(); + renderLogsFetch(); + + try { + const params = new URLSearchParams({ + group: logsState.group, + windowMs: String(logsState.windowMs), + limit: String(logsState.limit) + }); + + if (logsState.stream) { + params.set("stream", logsState.stream); + } + + const response = await fetch(`/api/logs/events?${params.toString()}`, { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const events = Array.isArray(payload.events) ? payload.events : []; + const nextSignature = computeLogListSignature(events); + shouldRenderList = nextSignature !== logsState.listSignature; + logsState.events = events; + logsState.duration = payload.fetchDurationMs || 0; + logsState.newest = payload.latestTimestamp || 0; + logsState.updatedAt = Date.now(); + logsState.searchedLogStreams = payload.searchedLogStreams || (logsState.stream ? 1 : logsState.streams.length); + logsState.listSignature = nextSignature; + + pruneLogsState(); + applyLogsFilter(shouldRenderList); + setLogsStatus(`Updated ${logsState.events.length} log event${logsState.events.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + logsState.error = error.message || "Unknown log refresh error"; + setLogsStatus(`Refresh failed: ${logsState.error}`, "bad"); + } finally { + logsState.loading = false; + scheduleLogsRefresh(); + renderLogsAll({ renderList: shouldRenderList }); + + if (shouldRenderList && logsState.tailNewest) { + scrollPaneToTop(el.logsContentPane); + } + } + } + + async function refreshSecrets(source) { + if (secretsState.loading) { + return; + } + + let shouldRenderList = false; + + secretsState.loading = true; + secretsState.source = source; + secretsState.error = ""; + renderSecretsStatus(); + renderSecretsFetch(); + + try { + const response = await fetch("/api/secrets", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const items = Array.isArray(payload.secrets) ? payload.secrets : []; + const nextSignature = computeSecretsListSignature(items); + shouldRenderList = nextSignature !== secretsState.listSignature; + secretsState.items = items; + secretsState.duration = payload.fetchDurationMs || 0; + secretsState.newest = payload.latestTimestamp || ""; + secretsState.updatedAt = Date.now(); + secretsState.listSignature = nextSignature; + + pruneSecretsState(); + applySecretsFilter(shouldRenderList); + appState.secretsReady = true; + setSecretsStatus(`Updated ${items.length} secret${items.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + secretsState.error = error.message || "Unknown secrets refresh error"; + appState.secretsReady = false; + setSecretsStatus(`Refresh failed: ${secretsState.error}`, "bad"); + } finally { + secretsState.loading = false; + scheduleSecretsRefresh(); + renderSecretsAll({ renderList: shouldRenderList }); + } + } + + async function refreshHealthSummary(source) { + if (healthState.loading && source === "auto") { + return; + } + + healthState.loading = true; + healthState.source = source; + renderHealthStrip(); + + try { + const response = await fetch("/api/service-health", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + healthState.services = payload.services || {}; + healthState.updatedAt = Date.now(); + healthState.error = ""; + } catch (error) { + healthState.error = error.message || "Unknown health refresh error"; + } finally { + healthState.loading = false; + renderHealthStrip(); + } + } + + async function refreshS3Buckets() { + try { + const response = await fetch("/api/s3/buckets", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + s3State.buckets = Array.isArray(payload.buckets) ? payload.buckets : []; + + const availableBuckets = s3State.buckets.map((bucket) => bucket.name).filter(Boolean); + + if (!availableBuckets.includes(s3State.bucket)) { + s3State.bucket = availableBuckets.includes(config.defaultS3Bucket) + ? config.defaultS3Bucket + : availableBuckets[0] || ""; + } + + s3State.error = ""; + } catch (error) { + s3State.buckets = []; + s3State.bucket = ""; + s3State.error = error.message || "Unknown S3 bucket refresh error"; + } finally { + appState.s3Ready = !s3State.error; + persistS3Preferences(); + renderS3All(); + } + } + + async function refreshS3(source) { + if (s3State.loading) { + return; + } + + if (!appState.s3Ready) { + await ensureS3Ready(); + return; + } + + if (!s3State.bucket) { + s3State.error = s3State.buckets.length ? "Choose a bucket to load objects." : "No S3 buckets found."; + renderS3All(); + return; + } + + let shouldRenderList = false; + + s3State.loading = true; + s3State.source = source; + s3State.error = ""; + renderS3Status(); + renderS3Fetch(); + + try { + const params = new URLSearchParams({ + bucket: s3State.bucket + }); + + if (s3State.prefix) { + params.set("prefix", s3State.prefix); + } + + const response = await fetch(`/api/s3/objects?${params.toString()}`, { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const objects = Array.isArray(payload.objects) ? payload.objects : []; + const nextSignature = computeS3ListSignature(objects); + shouldRenderList = nextSignature !== s3State.listSignature; + s3State.objects = objects; + s3State.duration = payload.fetchDurationMs || 0; + s3State.newest = payload.latestTimestamp || ""; + s3State.updatedAt = Date.now(); + s3State.listSignature = nextSignature; + + pruneS3State(); + applyS3Filter(shouldRenderList); + appState.s3Ready = true; + setS3Status(`Updated ${objects.length} object${objects.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + s3State.error = error.message || "Unknown S3 refresh error"; + appState.s3Ready = false; + setS3Status(`Refresh failed: ${s3State.error}`, "bad"); + } finally { + s3State.loading = false; + scheduleS3Refresh(); + renderS3All({ renderList: shouldRenderList }); + } + } + + function applyLogsFilter(shouldRenderList = true) { + const search = logsState.search.trim().toLowerCase(); + logsState.filtered = !search + ? [...logsState.events] + : logsState.events.filter((event) => logHaystack(event).includes(search)); + persistLogPreferences(); + renderLogsAll({ renderList: shouldRenderList }); + } + + function applySecretsFilter(shouldRenderList = true) { + const search = secretsState.search.trim().toLowerCase(); + secretsState.filtered = !search + ? [...secretsState.items] + : secretsState.items.filter((secret) => secretHaystack(secret).includes(search)); + persistSecretPreferences(); + renderSecretsAll({ renderList: shouldRenderList }); + } + + function applyS3Filter(shouldRenderList = true) { + const search = s3State.search.trim().toLowerCase(); + s3State.filtered = !search + ? [...s3State.objects] + : s3State.objects.filter((object) => s3Haystack(object).includes(search)); + persistS3Preferences(); + renderS3All({ renderList: shouldRenderList }); + } + + function pruneState() { + const ids = new Set(state.messages.map((message) => message.id)); + state.openIds = new Set([...state.openIds].filter((id) => ids.has(id))); + state.newIds = new Set([...state.newIds].filter((id) => ids.has(id))); + + Object.keys(state.views).forEach((id) => { + if (!ids.has(id)) { + delete state.views[id]; + } + }); + + Object.keys(state.raw).forEach((id) => { + if (!ids.has(id)) { + delete state.raw[id]; + } + }); + } + + function pruneLogsState() { + const ids = new Set(logsState.events.map((event) => event.id)); + logsState.openIds = new Set([...logsState.openIds].filter((id) => ids.has(id))); + } + + function pruneSecretsState() { + const ids = new Set(secretsState.items.map((secret) => secret.id)); + secretsState.openIds = new Set([...secretsState.openIds].filter((id) => ids.has(id))); + + Object.keys(secretsState.values).forEach((id) => { + if (!ids.has(id)) { + delete secretsState.values[id]; + } + }); + + secretsState.revealedIds = new Set([...secretsState.revealedIds].filter((id) => ids.has(id))); + } + + function pruneS3State() { + const ids = new Set(s3State.objects.map((object) => object.id)); + s3State.openIds = new Set([...s3State.openIds].filter((id) => ids.has(id))); + + Object.keys(s3State.previews).forEach((id) => { + if (!ids.has(id)) { + delete s3State.previews[id]; + } + }); + } + + function applyFilter(shouldRenderList = true) { + const search = state.search.trim().toLowerCase(); + state.filtered = !search + ? [...state.messages] + : state.messages.filter((message) => haystack(message).includes(search)); + persistEmailPreferences(); + renderAll({ renderList: shouldRenderList }); + } + + function computeListSignature(messages) { + return messages + .map((message) => + [ + message.id, + message.timestampMs || 0, + message.rawSizeBytes || 0, + message.attachmentCount || 0, + message.hasHtml ? 1 : 0, + message.preview || "", + message.parseError || "" + ].join("::") + ) + .join("|"); + } + + function computeLogListSignature(events) { + return events + .map((event) => + [event.id, event.timestamp || 0, event.ingestionTime || 0, event.logStreamName || "", event.message || ""].join( + "::" + ) + ) + .join("|"); + } + + function computeSecretsListSignature(items) { + return items + .map((secret) => + [ + secret.id, + secret.name || "", + secret.arn || "", + secret.description || "", + secret.lastChangedDate || "", + secret.createdDate || "", + secret.rotationEnabled ? 1 : 0, + secret.owningService || "", + secret.primaryRegion || "", + secret.versionCount || 0, + secret.tagCount || 0 + ].join("::") + ) + .join("|"); + } + + function computeS3ListSignature(objects) { + return objects + .map((object) => + [object.id, object.key || "", object.size || 0, object.lastModified || "", object.etag || ""].join("::") + ) + .join("|"); + } + + function haystack(message) { + return [ + message.subject, + message.from, + message.to, + message.replyTo, + message.preview, + message.textContent, + message.region, + ...(message.attachments || []).flatMap((attachment) => [attachment.filename, attachment.contentType]) + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + } + + function logHaystack(event) { + return [event.logStreamName, event.message, event.preview].filter(Boolean).join(" ").toLowerCase(); + } + + function secretHaystack(secret) { + return [ + secret.name, + secret.arn, + secret.description, + secret.primaryRegion, + secret.owningService, + ...(secret.tags || []).flatMap((tag) => [tag.key, tag.value]) + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + } + + function s3Haystack(object) { + return [object.bucket, object.key, object.storageClass, object.etag].filter(Boolean).join(" ").toLowerCase(); + } + + function scheduleRefresh() { + window.clearTimeout(state.timer); + + if (appState.panel !== "emails" || !state.auto || document.hidden || state.loading) { + return; + } + + state.timer = window.setTimeout(() => refreshMessages("auto"), state.interval); + } + + function scheduleLogsRefresh() { + window.clearTimeout(logsState.timer); + + if (appState.panel !== "logs" || !logsState.auto || document.hidden || logsState.loading) { + return; + } + + logsState.timer = window.setTimeout(() => refreshLogs("auto"), logsState.interval); + } + + function scheduleSecretsRefresh() { + window.clearTimeout(secretsState.timer); + + if (appState.panel !== "secrets" || !secretsState.auto || document.hidden || secretsState.loading) { + return; + } + + secretsState.timer = window.setTimeout(() => refreshSecrets("auto"), secretsState.interval); + } + + function scheduleS3Refresh() { + window.clearTimeout(s3State.timer); + + if (appState.panel !== "s3" || !s3State.auto || document.hidden || s3State.loading) { + return; + } + + s3State.timer = window.setTimeout(() => refreshS3("auto"), s3State.interval); + } + + function renderAll(options = {}) { + const { renderList: shouldRenderList = true } = options; + renderStats(); + renderFetch(); + renderStatus(); + if (shouldRenderList) { + renderList(); + } + renderLiveClock(); + } + + function renderLogsAll(options = {}) { + const { renderList: shouldRenderList = true } = options; + renderLogsFilters(); + renderLogsStats(); + renderLogsFetch(); + renderLogsStatus(); + if (shouldRenderList) { + renderLogsList(); + } + renderLogsLiveClock(); + } + + function renderSecretsAll(options = {}) { + const { renderList: shouldRenderList = true } = options; + renderSecretsStats(); + renderSecretsFetch(); + renderSecretsStatus(); + if (shouldRenderList) { + renderSecretsList(); + } + renderSecretsLiveClock(); + } + + function renderS3All(options = {}) { + const { renderList: shouldRenderList = true } = options; + renderS3Filters(); + renderS3Stats(); + renderS3Fetch(); + renderS3Status(); + if (shouldRenderList) { + renderS3List(); + } + renderS3LiveClock(); + } + + function renderHealthStrip() { + const serviceOrder = [ + { key: "emails", panel: "emails", icon: "✉️", label: "SES Emails", shortLabel: "SES" }, + { key: "logs", panel: "logs", icon: "📜", label: "CloudWatch Logs", shortLabel: "Logs" }, + { key: "secrets", panel: "secrets", icon: "🔐", label: "Secrets Manager", shortLabel: "Secrets" }, + { key: "s3", panel: "s3", icon: "🪣", label: "S3 Explorer", shortLabel: "S3" } + ]; + + el.healthStrip.innerHTML = serviceOrder + .map((service) => { + const entry = healthState.services?.[service.key]; + const toneClass = healthState.loading && !entry ? "warn" : entry?.ok ? "ok" : entry ? "bad" : ""; + const activeClass = service.panel === appState.panel ? "active" : ""; + const className = ["healthBadge", toneClass, activeClass].filter(Boolean).join(" "); + const summary = entry?.summary || (healthState.loading ? "Checking..." : "Waiting"); + const detail = entry?.detail || (healthState.error ? healthState.error : "Not checked yet"); + const updatedMeta = healthState.updatedAt + ? `Updated ${formatRelative(healthState.updatedAt)} via ${healthState.source}` + : ""; + const titleParts = [`${service.label}: ${detail}`]; + + if (updatedMeta) { + titleParts.push(updatedMeta); + } + + return ``; + }) + .join(""); + } + + function renderStats() { + el.totalStat.textContent = String(state.messages.length); + el.visibleStat.textContent = `${state.filtered.length} visible${state.search ? " after search" : ""}`; + el.newStat.textContent = String(state.newIds.size); + el.newestStat.textContent = state.newest ? formatDateTime(state.newest) : "No messages"; + } + + function renderLogsFilters() { + const groups = logsState.groups.length + ? logsState.groups.map((group) => ``) + : ['']; + const streams = [ + '', + ...logsState.streams.map( + (stream) => `` + ) + ]; + + el.logsGroupSelect.innerHTML = groups.join(""); + el.logsStreamSelect.innerHTML = streams.join(""); + + if (logsState.group) { + el.logsGroupSelect.value = logsState.group; + } + + el.logsStreamSelect.value = logsState.stream; + el.logsWrapToggle.checked = logsState.wrapLines; + el.logsTailToggle.checked = logsState.tailNewest; + } + + function renderS3Filters() { + const bucketOptions = s3State.buckets.length + ? s3State.buckets.map( + (bucket) => `` + ) + : ['']; + + el.s3BucketSelect.innerHTML = bucketOptions.join(""); + + if (s3State.bucket) { + el.s3BucketSelect.value = s3State.bucket; + } + + el.s3PrefixInput.value = s3State.prefix; + el.s3SearchInput.value = s3State.search; + } + + function renderLogsStats() { + el.logsTotalStat.textContent = String(logsState.events.length); + el.logsVisibleStat.textContent = `${logsState.filtered.length} visible${logsState.search ? " after search" : ""}`; + el.logsStreamsStat.textContent = String(logsState.streams.length || logsState.searchedLogStreams || 0); + el.logsNewestStat.textContent = logsState.newest ? formatDateTime(logsState.newest) : "No events"; + } + + function renderSecretsStats() { + el.secretsTotalStat.textContent = String(secretsState.items.length); + el.secretsVisibleStat.textContent = `${secretsState.filtered.length} visible${secretsState.search ? " after search" : ""}`; + el.secretsLoadedStat.textContent = String( + Object.values(secretsState.values).filter((entry) => entry?.status === "loaded").length + ); + el.secretsNewestStat.textContent = secretsState.newest ? formatDateTime(secretsState.newest) : "No secrets"; + } + + function renderS3Stats() { + el.s3TotalStat.textContent = String(s3State.objects.length); + el.s3VisibleStat.textContent = `${s3State.filtered.length} visible${s3State.search ? " after search" : ""}`; + el.s3BucketsStat.textContent = String(s3State.buckets.length); + el.s3NewestStat.textContent = s3State.newest ? formatDateTime(s3State.newest) : "No objects"; + } + + function renderFetch() { + if (state.loading) { + el.fetchStat.textContent = "Refreshing..."; + el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; + return; + } + + if (state.error) { + el.fetchStat.textContent = "Needs attention"; + el.fetchDetail.textContent = state.error; + return; + } + + if (!state.updatedAt) { + el.fetchStat.textContent = "Idle"; + el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; + return; + } + + el.fetchStat.textContent = `${state.duration}ms`; + el.fetchDetail.textContent = `${state.parseErrors} parse error${state.parseErrors === 1 ? "" : "s"}. Endpoint: ${config.endpoint}`; + } + + function renderLogsFetch() { + if (logsState.loading) { + el.logsFetchStat.textContent = "Refreshing..."; + el.logsFetchDetail.textContent = `Endpoint: ${config.cloudWatchEndpoint} (${config.cloudWatchRegion})`; + return; + } + + if (logsState.error) { + el.logsFetchStat.textContent = "Needs attention"; + el.logsFetchDetail.textContent = logsState.error; + return; + } + + if (!logsState.updatedAt) { + el.logsFetchStat.textContent = "Idle"; + el.logsFetchDetail.textContent = `Endpoint: ${config.cloudWatchEndpoint} (${config.cloudWatchRegion})`; + return; + } + + el.logsFetchStat.textContent = `${logsState.duration}ms`; + el.logsFetchDetail.textContent = `${logsState.group || "No group selected"}. Endpoint: ${config.cloudWatchEndpoint}`; + } + + function renderSecretsFetch() { + if (secretsState.loading) { + el.secretsFetchStat.textContent = "Refreshing..."; + el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; + return; + } + + if (secretsState.error) { + el.secretsFetchStat.textContent = "Needs attention"; + el.secretsFetchDetail.textContent = secretsState.error; + return; + } + + if (!secretsState.updatedAt) { + el.secretsFetchStat.textContent = "Idle"; + el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; + return; + } + + el.secretsFetchStat.textContent = `${secretsState.duration}ms`; + el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; + } + + function renderS3Fetch() { + if (s3State.loading) { + el.s3FetchStat.textContent = "Refreshing..."; + el.s3FetchDetail.textContent = `Endpoint: ${config.s3Endpoint} (${config.s3Region})`; + return; + } + + if (s3State.error) { + el.s3FetchStat.textContent = "Needs attention"; + el.s3FetchDetail.textContent = s3State.error; + return; + } + + if (!s3State.updatedAt) { + el.s3FetchStat.textContent = "Idle"; + el.s3FetchDetail.textContent = `Endpoint: ${config.s3Endpoint} (${config.s3Region})`; + return; + } + + el.s3FetchStat.textContent = `${s3State.duration}ms`; + el.s3FetchDetail.textContent = `${s3State.bucket || "No bucket selected"}. Endpoint: ${config.s3Endpoint}`; + } + + function renderStatus() { + el.statusChip.className = "status"; + + if (state.loading) { + el.statusChip.classList.add("warn"); + el.statusChip.textContent = "Refreshing messages..."; + return; + } + + if (state.error) { + el.statusChip.classList.add("bad"); + el.statusChip.textContent = `Refresh failed: ${state.error}`; + return; + } + + if (!state.auto) { + el.statusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.statusChip.classList.add("warn"); + el.statusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!state.updatedAt) { + el.statusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((state.updatedAt + state.interval - Date.now()) / 1000)); + el.statusChip.classList.add("ok"); + el.statusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderLogsStatus() { + el.logsStatusChip.className = "status"; + + if (logsState.loading) { + el.logsStatusChip.classList.add("warn"); + el.logsStatusChip.textContent = "Refreshing logs..."; + return; + } + + if (logsState.error) { + el.logsStatusChip.classList.add("bad"); + el.logsStatusChip.textContent = `Refresh failed: ${logsState.error}`; + return; + } + + if (!logsState.auto) { + el.logsStatusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.logsStatusChip.classList.add("warn"); + el.logsStatusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!logsState.updatedAt) { + el.logsStatusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((logsState.updatedAt + logsState.interval - Date.now()) / 1000)); + el.logsStatusChip.classList.add("ok"); + el.logsStatusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderSecretsStatus() { + el.secretsStatusChip.className = "status"; + + if (secretsState.loading) { + el.secretsStatusChip.classList.add("warn"); + el.secretsStatusChip.textContent = "Refreshing secrets..."; + return; + } + + if (secretsState.error) { + el.secretsStatusChip.classList.add("bad"); + el.secretsStatusChip.textContent = `Refresh failed: ${secretsState.error}`; + return; + } + + if (!secretsState.auto) { + el.secretsStatusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.secretsStatusChip.classList.add("warn"); + el.secretsStatusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!secretsState.updatedAt) { + el.secretsStatusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((secretsState.updatedAt + secretsState.interval - Date.now()) / 1000)); + el.secretsStatusChip.classList.add("ok"); + el.secretsStatusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderS3Status() { + el.s3StatusChip.className = "status"; + + if (s3State.loading) { + el.s3StatusChip.classList.add("warn"); + el.s3StatusChip.textContent = "Refreshing objects..."; + return; + } + + if (s3State.error) { + el.s3StatusChip.classList.add("bad"); + el.s3StatusChip.textContent = `Refresh failed: ${s3State.error}`; + return; + } + + if (!s3State.auto) { + el.s3StatusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.s3StatusChip.classList.add("warn"); + el.s3StatusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!s3State.updatedAt) { + el.s3StatusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((s3State.updatedAt + s3State.interval - Date.now()) / 1000)); + el.s3StatusChip.classList.add("ok"); + el.s3StatusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderLiveClock() { + if (!state.updatedAt) { + el.updatedStat.textContent = "Not refreshed yet"; + return; + } + + el.updatedStat.textContent = `Updated ${formatRelative(state.updatedAt)} via ${state.source}`; + renderStatus(); + } + + function renderLogsLiveClock() { + if (!logsState.updatedAt) { + el.logsUpdatedStat.textContent = "Not refreshed yet"; + return; + } + + el.logsUpdatedStat.textContent = `Updated ${formatRelative(logsState.updatedAt)} via ${logsState.source}`; + renderLogsStatus(); + } + + function renderSecretsLiveClock() { + if (!secretsState.updatedAt) { + el.secretsUpdatedStat.textContent = "Not refreshed yet"; + return; + } + + el.secretsUpdatedStat.textContent = `Updated ${formatRelative(secretsState.updatedAt)} via ${secretsState.source}`; + renderSecretsStatus(); + } + + function renderS3LiveClock() { + if (!s3State.updatedAt) { + el.s3UpdatedStat.textContent = "Not refreshed yet"; + return; + } + + el.s3UpdatedStat.textContent = `Updated ${formatRelative(s3State.updatedAt)} via ${s3State.source}`; + renderS3Status(); + } + + function renderList() { + el.banner.hidden = !state.error; + el.banner.textContent = state.error ? `Refresh failed: ${state.error}` : ""; + + if (!state.filtered.length) { + el.list.innerHTML = ""; + el.empty.hidden = false; + el.empty.textContent = state.messages.length + ? "No messages match the current search." + : "No emails yet. Send one through LocalStack SES and refresh."; + updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); + return; + } + + el.empty.hidden = true; + el.list.innerHTML = state.filtered.map(renderCard).join(""); + bindCardToggles(); + syncCardExpansion(); + updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); + } + + function renderLogsList() { + el.logsBanner.hidden = !logsState.error; + el.logsBanner.textContent = logsState.error ? `Refresh failed: ${logsState.error}` : ""; + + if (!logsState.group && !logsState.groups.length) { + el.logsList.innerHTML = ""; + el.logsEmpty.hidden = false; + el.logsEmpty.textContent = "No CloudWatch log groups found in LocalStack yet."; + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + return; + } + + if (!logsState.filtered.length) { + el.logsList.innerHTML = ""; + el.logsEmpty.hidden = false; + el.logsEmpty.textContent = logsState.events.length + ? "No log events match the current search." + : "No log events found for the selected group, stream, and time window."; + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + return; + } + + el.logsEmpty.hidden = true; + el.logsList.innerHTML = logsState.filtered.map((event) => renderLogEvent(event)).join(""); + bindLogToggles(); + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + } + + function renderSecretsList() { + el.secretsBanner.hidden = !secretsState.error; + el.secretsBanner.textContent = secretsState.error ? `Refresh failed: ${secretsState.error}` : ""; + + if (!secretsState.filtered.length) { + el.secretsList.innerHTML = ""; + el.secretsEmpty.hidden = false; + el.secretsEmpty.textContent = secretsState.items.length + ? "No secrets match the current search." + : "No Secrets Manager entries found in LocalStack yet."; + updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + return; + } + + el.secretsEmpty.hidden = true; + el.secretsList.innerHTML = secretsState.filtered.map((secret) => renderSecretCard(secret)).join(""); + bindSecretToggles(); + syncSecretExpansion(); + updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + } + + function renderS3List() { + el.s3Banner.hidden = !s3State.error; + el.s3Banner.textContent = s3State.error ? `Refresh failed: ${s3State.error}` : ""; + + if (!s3State.bucket && !s3State.buckets.length) { + el.s3List.innerHTML = ""; + el.s3Empty.hidden = false; + el.s3Empty.textContent = "No S3 buckets found in LocalStack yet."; + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + return; + } + + if (!s3State.filtered.length) { + el.s3List.innerHTML = ""; + el.s3Empty.hidden = false; + el.s3Empty.textContent = s3State.objects.length + ? "No S3 objects match the current search." + : "No S3 objects found for the selected bucket and prefix."; + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + return; + } + + el.s3Empty.hidden = true; + el.s3List.innerHTML = s3State.filtered.map((object) => renderS3ObjectCard(object)).join(""); + bindS3Toggles(); + syncS3Expansion(); + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + } + + function renderLogEvent(event) { + const level = detectLogLevel(event); + const levelTag = level ? `${escapeHtml(level.label)}` : ""; + + return ` +
+ +
+
+ ${escapeHtml(formatDateTime(event.timestamp))} + ${escapeHtml(event.logStreamName || "Unknown stream")} + ${levelTag} +
+
+ + ${escapeHtml(formatRelative(event.timestamp || Date.now()))} +
+
+

${renderLogPreviewContent(event)}

+
+
+
${renderLogBodyContent(event.message)}
+
+
+ `; + } + + function renderSecretCard(secret) { + const valueState = secretsState.values[secret.id]; + const tags = []; + + if (secret.owningService) { + tags.push(`${escapeHtml(secret.owningService)}`); + } + + if (secret.primaryRegion) { + tags.push(`${escapeHtml(secret.primaryRegion)}`); + } + + if (secret.rotationEnabled) { + tags.push('Rotation on'); + } else { + tags.push('Rotation off'); + } + + if (secret.tagCount) { + tags.push(`${secret.tagCount} tag${secret.tagCount === 1 ? "" : "s"}`); + } + + if (valueState?.status === "loaded") { + tags.push(`${escapeHtml(valueState.label)}`); + } + + if (valueState?.status === "error") { + tags.push('Value load failed'); + } + + return ` +
+ +
+
+

🔐 ${escapeHtml(secret.name)}

+

${escapeHtml(secret.description || secret.arn || "No description")}

+
+ ${escapeHtml(formatDateTime(secret.lastChangedDate || secret.createdDate))} +
+
${tags.join("")}
+

${escapeHtml(buildSecretPreview(secret))}

+
+
+
+ ${metaCard("Name", secret.name)} + ${metaCard("ARN", secret.arn || "Not available")} + ${metaCard("Description", secret.description || "None")} + ${metaCard("Last changed", formatDateTime(secret.lastChangedDate || secret.createdDate))} + ${metaCard("Created", formatDateTime(secret.createdDate))} + ${metaCard("Last accessed", formatDateTime(secret.lastAccessedDate))} + ${metaCard("Primary region", secret.primaryRegion || "Not set")} + ${metaCard("Owning service", secret.owningService || "Not set")} +
+ +
+ ${renderSecretValuePanel(secret, valueState)} +
+
+
+ `; + } + + function renderS3ObjectCard(object) { + const previewState = s3State.previews[object.id]; + const tags = [ + `${escapeHtml(object.bucket)}`, + `${escapeHtml(formatBytes(object.size))}` + ]; + + if (object.storageClass) { + tags.push(`${escapeHtml(object.storageClass)}`); + } + + if (previewState?.status === "loaded") { + tags.push(`${escapeHtml(previewState.previewType)}`); + } + + if (previewState?.status === "error") { + tags.push('Preview failed'); + } + + return ` +
+ +
+
+

🪣 ${escapeHtml(object.key)}

+

${escapeHtml(object.bucket)} • ${escapeHtml(formatBytes(object.size))}

+
+ ${escapeHtml(formatDateTime(object.lastModified))} +
+
${tags.join("")}
+

${escapeHtml(buildS3Preview(object))}

+
+
+
+
+ + + ⬇️ Download +
+
+ +
+ ${metaCard("Bucket", object.bucket)} + ${metaCard("Key", object.key)} + ${metaCard("Size", formatBytes(object.size))} + ${metaCard("Modified", formatDateTime(object.lastModified))} + ${metaCard("Storage class", object.storageClass || "STANDARD")} + ${metaCard("ETag", object.etag || "Not available")} +
+ +
${renderS3PreviewPanel(object, previewState)}
+
+
+ `; + } + + function bindLogToggles() { + el.logsList.querySelectorAll(".logEvent").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + logsState.openIds.add(id); + } else { + logsState.openIds.delete(id); + } + }); + }); + } + + function bindSecretToggles() { + el.secretsList.querySelectorAll(".secretCard").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + secretsState.openIds.add(id); + ensureSecretValue(id, { force: false }); + } else { + secretsState.openIds.delete(id); + } + }); + }); + } + + function bindS3Toggles() { + el.s3List.querySelectorAll(".s3Card").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + s3State.openIds.add(id); + ensureS3Preview(id, { force: false }); + } else { + s3State.openIds.delete(id); + } + }); + }); + } + + function bindCardToggles() { + el.list.querySelectorAll(".card").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + state.openIds.add(id); + } else { + state.openIds.delete(id); + } + + hydrate(details, getMessage(id)); + }); + }); + } + + function renderCard(message) { + const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); + const tags = []; + + if (state.newIds.has(message.id)) { + tags.push('New'); + } + + if (message.attachmentCount) { + tags.push( + `${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"}` + ); + } + + tags.push(`${message.hasHtml ? "HTML" : "Text only"}`); + + if (message.parseError) { + tags.push('Parse issue'); + } + + return ` +
+ +
+
+

${escapeHtml(message.subject)}

+

${escapeHtml(message.from)} to ${escapeHtml(message.to)}

+
+ ${escapeHtml(formatDateTime(message.timestamp))} +
+
${tags.join("")}
+

${escapeHtml(message.preview)}

+
+
+
+
+ ${ + message.hasHtml + ? `` + : "" + } + + +
+
+ +
+
+ +
+ ${metaCard("From", message.from)} + ${metaCard("To", message.to)} + ${metaCard("Reply-To", message.replyTo || "None")} + ${metaCard("Sent", formatDateTime(message.timestamp))} + ${metaCard("Region", message.region || "Unknown region")} + ${metaCard("LocalStack Id", message.id)} + ${metaCard("Message-Id", message.messageId || "Not available")} + ${metaCard("Raw size", formatBytes(message.rawSizeBytes))} + ${message.parseError ? metaCard("Parse error", message.parseError) : ""} +
+ + ${ + message.attachments?.length + ? `
${message.attachments + .map((attachment) => { + const size = attachment.size ? `, ${formatBytes(attachment.size)}` : ""; + const icon = resolveAttachmentIcon(attachment); + return `${icon} ${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})`; + }) + .join("")}
` + : "" + } + +
${renderPanel(message, view)}
+
+
+ `; + } + + function renderPanel(message, view) { + if (view === "rendered" && message.hasHtml) { + return ``; + } + + if (view === "raw") { + const raw = state.raw[message.id]; + + if (!raw) { + return `
Raw MIME source is loaded on demand.
`; + } + + if (raw.status === "loading") { + return '
Loading raw source...
'; + } + + if (raw.status === "error") { + return `
Unable to load raw source: ${escapeHtml(raw.error)}
`; + } + + return `
${escapeHtml(raw.value)}
`; + } + + return `
${escapeHtml(message.textContent || "No plain-text content available for this message.")}
`; + } + + function renderSecretValuePanel(secret, valueState) { + if (!valueState) { + return `
Secret values are loaded on demand.
`; + } + + if (valueState.status === "loading") { + return '
Loading secret value...
'; + } + + if (valueState.status === "error") { + return `
Unable to load secret value: ${escapeHtml(valueState.error)}
`; + } + + const revealed = secretsState.revealedIds.has(secret.id); + const maskedHtml = escapeHtml(maskSecretValue(valueState.copyValue)); + + return ` +
+
+ ${escapeHtml(valueState.label)} + ${ + valueState.versionId + ? `Version ${escapeHtml(valueState.versionId.slice(0, 8))}` + : "" + } + ${ + valueState.versionStages.length + ? `${escapeHtml(valueState.versionStages.join(", "))}` + : "" + } +
+
+ + + + + + +
+
+
${revealed ? valueState.displayHtml : maskedHtml}
+ `; + } + + function renderS3PreviewPanel(object, previewState) { + if (!previewState) { + return `
Object previews load on demand.
`; + } + + if (previewState.status === "loading") { + return '
Loading object preview...
'; + } + + if (previewState.status === "error") { + return `
Unable to load object preview: ${escapeHtml(previewState.error)}
`; + } + + const truncatedTag = previewState.truncated ? 'Preview truncated' : ""; + let previewContent = `
No inline preview available for this object type.
`; + + if (previewState.previewType === "image" && previewState.imageDataUrl) { + previewContent = `${escapeHtml(object.key)}`; + } else if (previewState.previewType === "json") { + previewContent = `
${highlightJsonText(prettyJsonOrText(previewState.previewText))}
`; + } else if (previewState.previewType === "text" || previewState.previewType === "html") { + previewContent = `
${escapeHtml(previewState.previewText || "No preview text available.")}
`; + } + + return ` +
+
+ ${escapeHtml(previewState.previewType)} + ${truncatedTag} + ${previewState.contentType ? `${escapeHtml(previewState.contentType)}` : ""} +
+
+ + ⬇️ Download +
+
+ ${previewContent} + `; + } + + function metaCard(label, value) { + return `
${escapeHtml(label)}
${escapeHtml(value)}
`; + } + + function syncCardExpansion() { + const applyCardState = () => { + el.list.querySelectorAll(".card").forEach((details) => { + const id = details.dataset.id; + const shouldOpen = Boolean(id && state.openIds.has(id)); + + if (shouldOpen && !details.open) { + details.open = true; + } + + if (!shouldOpen && details.open) { + details.open = false; + return; + } + + if (shouldOpen) { + hydrate(details, getMessage(id)); + } + }); + }; + + applyCardState(); + window.requestAnimationFrame(applyCardState); + } + + function syncSecretExpansion() { + const applySecretState = () => { + el.secretsList.querySelectorAll(".secretCard").forEach((details) => { + const id = details.dataset.id; + const shouldOpen = Boolean(id && secretsState.openIds.has(id)); + + if (shouldOpen && !details.open) { + details.open = true; + } + + if (!shouldOpen && details.open) { + details.open = false; + return; + } + + if (shouldOpen) { + ensureSecretValue(id, { force: false }); + } + }); + }; + + applySecretState(); + window.requestAnimationFrame(applySecretState); + } + + function syncS3Expansion() { + const applyS3State = () => { + el.s3List.querySelectorAll(".s3Card").forEach((details) => { + const id = details.dataset.id; + const shouldOpen = Boolean(id && s3State.openIds.has(id)); + + if (shouldOpen && !details.open) { + details.open = true; + } + + if (!shouldOpen && details.open) { + details.open = false; + return; + } + + if (shouldOpen) { + ensureS3Preview(id, { force: false }); + } + }); + }; + + applyS3State(); + window.requestAnimationFrame(applyS3State); + } + + function resolveAttachmentIcon(attachment) { + const filename = String(attachment?.filename || "").toLowerCase(); + const contentType = String(attachment?.contentType || "").toLowerCase(); + + if (filename.endsWith(".pdf") || contentType.includes("pdf")) { + return "📄"; + } + + if ( + [".doc", ".docx", ".txt", ".rtf", ".md"].some((extension) => filename.endsWith(extension)) || + contentType.includes("word") || + contentType.startsWith("text/") + ) { + return "📝"; + } + + if ( + [".xls", ".xlsx", ".csv"].some((extension) => filename.endsWith(extension)) || + contentType.includes("sheet") || + contentType.includes("csv") + ) { + return "📊"; + } + + if ( + filename.endsWith(".json") || + filename.endsWith(".xml") || + filename.endsWith(".yaml") || + filename.endsWith(".yml") + ) { + return "🧾"; + } + + if (contentType.startsWith("image/")) { + return "🖼️"; + } + + if (contentType.startsWith("audio/")) { + return "🎵"; + } + + if (contentType.startsWith("video/")) { + return "🎬"; + } + + if ( + [".zip", ".rar", ".7z", ".tar", ".gz"].some((extension) => filename.endsWith(extension)) || + contentType.includes("zip") || + contentType.includes("compressed") + ) { + return "🗜️"; + } + + return "📎"; + } + + function hydrate(details, message) { + if (!details || !details.open || !message) { + return; + } + + const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); + + if (view !== "rendered" || !message.hasHtml) { + return; + } + + const iframe = details.querySelector("[data-frame]"); + + if (iframe) { + iframe.referrerPolicy = "no-referrer"; + iframe.sandbox = ""; + iframe.srcdoc = message.renderedHtml || ""; + } + } + + function getMessage(id) { + return state.messages.find((message) => message.id === id); + } + + function getLogEvent(id) { + return logsState.events.find((event) => event.id === id); + } + + function getSecret(id) { + return secretsState.items.find((secret) => secret.id === id); + } + + function getS3Object(id) { + return s3State.objects.find((object) => object.id === id); + } + + async function loadRaw(id) { + if (state.raw[id]?.status === "loaded") { + return state.raw[id].value; + } + + if (state.raw[id]?.status === "loading") { + return null; + } + + state.raw[id] = { status: "loading" }; + renderList(); + + try { + const response = await fetch(`/api/messages/${encodeURIComponent(id)}/raw`, { cache: "no-store" }); + + if (!response.ok) { + throw new Error((await response.text()) || `Request failed with ${response.status}`); + } + + const value = await response.text(); + state.raw[id] = { status: "loaded", value }; + return value; + } catch (error) { + state.raw[id] = { status: "error", error: error.message || "Unknown raw load error" }; + setStatus("Could not load the raw message source.", "bad"); + return null; + } finally { + renderList(); + } + } + + async function ensureSecretValue(id, options = {}) { + const { force = false } = options; + + if (!id) { + return null; + } + + if (!force && secretsState.values[id]?.status === "loaded") { + return secretsState.values[id]; + } + + if (secretsState.values[id]?.status === "loading") { + return null; + } + + secretsState.values[id] = { status: "loading" }; + renderSecretsAll(); + + try { + const response = await fetch(`/api/secrets/value?id=${encodeURIComponent(id)}`, { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const secretString = typeof payload.secretString === "string" ? payload.secretString : ""; + const secretBinary = typeof payload.secretBinary === "string" ? payload.secretBinary : ""; + const parsedString = secretString ? tryParseJsonText(secretString) : { ok: false, value: null }; + const entry = { + status: "loaded", + label: secretBinary ? "Binary" : parsedString.ok ? "JSON" : secretString ? "Text" : "Empty", + copyValue: secretBinary + ? secretBinary + : parsedString.ok + ? JSON.stringify(parsedString.value, null, 2) + : secretString || "No secret value.", + displayHtml: secretBinary + ? escapeHtml(secretBinary) + : parsedString.ok + ? highlightJsonText(JSON.stringify(parsedString.value, null, 2)) + : escapeHtml(secretString || "No secret value."), + isJson: parsedString.ok, + isBinary: Boolean(secretBinary), + versionId: payload.versionId || "", + versionStages: Array.isArray(payload.versionStages) ? payload.versionStages : [], + createdDate: payload.createdDate || "", + arn: payload.arn || "", + name: payload.name || "" + }; + + secretsState.values[id] = entry; + return entry; + } catch (error) { + secretsState.values[id] = { + status: "error", + error: error.message || "Unknown secret value error" + }; + setSecretsStatus("Could not load the secret value.", "bad"); + return null; + } finally { + renderSecretsAll(); + } + } + + async function ensureS3Preview(id, options = {}) { + const { force = false } = options; + + if (!id) { + return null; + } + + if (!force && s3State.previews[id]?.status === "loaded") { + return s3State.previews[id]; + } + + if (s3State.previews[id]?.status === "loading") { + return null; + } + + const object = getS3Object(id); + + if (!object) { + return null; + } + + s3State.previews[id] = { status: "loading" }; + renderS3All(); + + try { + const response = await fetch( + `/api/s3/object?bucket=${encodeURIComponent(object.bucket)}&key=${encodeURIComponent(object.key)}`, + { cache: "no-store" } + ); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const entry = { + status: "loaded", + previewType: payload.previewType || "binary", + previewText: payload.previewText || "", + imageDataUrl: payload.imageDataUrl || "", + contentType: payload.contentType || "", + contentLength: payload.contentLength || 0, + truncated: Boolean(payload.truncated), + metadata: payload.metadata || {} + }; + + s3State.previews[id] = entry; + return entry; + } catch (error) { + s3State.previews[id] = { + status: "error", + error: error.message || "Unknown S3 preview error" + }; + setS3Status("Could not load the S3 object preview.", "bad"); + return null; + } finally { + renderS3All(); + } + } + + async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + } catch { + const input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + } + + function setStatus(message, tone) { + el.statusChip.className = "status"; + + if (tone) { + el.statusChip.classList.add(tone); + } + + el.statusChip.textContent = message; + } + + function setLogsStatus(message, tone) { + el.logsStatusChip.className = "status"; + + if (tone) { + el.logsStatusChip.classList.add(tone); + } + + el.logsStatusChip.textContent = message; + } + + function setSecretsStatus(message, tone) { + el.secretsStatusChip.className = "status"; + + if (tone) { + el.secretsStatusChip.classList.add(tone); + } + + el.secretsStatusChip.textContent = message; + } + + function setS3Status(message, tone) { + el.s3StatusChip.className = "status"; + + if (tone) { + el.s3StatusChip.classList.add(tone); + } + + el.s3StatusChip.textContent = message; + } + + function getInitialPanel() { + const storedPanel = readStoredValue(PANEL_STORAGE_KEY); + return ["emails", "logs", "secrets", "s3"].includes(storedPanel) ? storedPanel : "emails"; + } + + function getInitialTheme() { + const storedTheme = readStoredValue(THEME_STORAGE_KEY); + + if (storedTheme === "dark" || storedTheme === "light") { + return storedTheme; + } + + return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"; + } + + function applyTheme(theme) { + const nextTheme = theme === "dark" ? "dark" : "light"; + appState.theme = nextTheme; + document.body.dataset.theme = nextTheme; + + try { + window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme); + } catch {} + + renderThemeToggle(); + } + + function renderThemeToggle() { + if (!el.themeToggle) { + return; + } + + const isDark = appState.theme === "dark"; + el.themeToggle.textContent = isDark ? "🌙 Dark theme" : "☀️ Light theme"; + el.themeToggle.setAttribute("aria-pressed", isDark ? "true" : "false"); + el.themeToggle.setAttribute("aria-label", isDark ? "Switch to light theme" : "Switch to dark theme"); + el.themeToggle.title = isDark ? "Switch to light theme" : "Switch to dark theme"; + } + + function persistPanel() { + writeStoredValue(PANEL_STORAGE_KEY, appState.panel); + } + + function persistEmailPreferences() { + writeStoredJson(EMAIL_PREFERENCES_STORAGE_KEY, { + search: state.search, + auto: state.auto, + interval: state.interval + }); + } + + function persistLogPreferences() { + writeStoredJson(LOG_PREFERENCES_STORAGE_KEY, { + group: logsState.group, + stream: logsState.stream, + search: logsState.search, + auto: logsState.auto, + interval: logsState.interval, + windowMs: logsState.windowMs, + limit: logsState.limit, + wrapLines: logsState.wrapLines, + tailNewest: logsState.tailNewest + }); + } + + function persistSecretPreferences() { + writeStoredJson(SECRET_PREFERENCES_STORAGE_KEY, { + search: secretsState.search, + auto: secretsState.auto, + interval: secretsState.interval + }); + } + + function persistS3Preferences() { + writeStoredJson(S3_PREFERENCES_STORAGE_KEY, { + bucket: s3State.bucket, + prefix: s3State.prefix, + search: s3State.search, + auto: s3State.auto, + interval: s3State.interval + }); + } + + function resetSavedState() { + [ + THEME_STORAGE_KEY, + PANEL_STORAGE_KEY, + EMAIL_PREFERENCES_STORAGE_KEY, + LOG_PREFERENCES_STORAGE_KEY, + SECRET_PREFERENCES_STORAGE_KEY, + S3_PREFERENCES_STORAGE_KEY + ].forEach((key) => { + try { + window.localStorage.removeItem(key); + } catch {} + }); + + window.location.reload(); + } + + function getStoredPreferences(key) { + try { + const rawValue = window.localStorage.getItem(key); + + if (!rawValue) { + return null; + } + + const parsedValue = JSON.parse(rawValue); + return parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue) ? parsedValue : null; + } catch { + return null; + } + } + + function readStoredValue(key) { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + } + + function writeStoredValue(key, value) { + try { + window.localStorage.setItem(key, String(value)); + } catch {} + } + + function writeStoredJson(key, value) { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch {} + } + + function getStoredText(value, fallback = "") { + return typeof value === "string" ? value : fallback; + } + + function getStoredBoolean(value, fallback) { + return typeof value === "boolean" ? value : fallback; + } + + function getStoredNumber(value, allowedValues, fallback) { + const normalizedValue = Number(value); + return Number.isFinite(normalizedValue) && allowedValues.includes(normalizedValue) ? normalizedValue : fallback; + } + + function scrollPaneToTop(element) { + if (!element) { + return; + } + + element.scrollTo({ top: 0, behavior: "smooth" }); + } + + function updatePaneTopButtonVisibility(pane, button) { + if (!pane || !button) { + return; + } + + button.classList.toggle("visible", pane.scrollTop > 140); + } + + function formatDateTime(value) { + if (!value) { + return "Unknown time"; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? "Unknown time" + : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); + } + + function formatRelative(timestampMs) { + const seconds = Math.max(1, Math.round((Date.now() - timestampMs) / 1000)); + + if (seconds < 60) { + return `${seconds}s ago`; + } + + const minutes = Math.round(seconds / 60); + + if (minutes < 60) { + return `${minutes}m ago`; + } + + const hours = Math.round(minutes / 60); + + if (hours < 24) { + return `${hours}h ago`; + } + + return `${Math.round(hours / 24)}d ago`; + } + + function formatBytes(value) { + if (!value) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let index = 0; + + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + + return `${index === 0 ? size : size.toFixed(1)} ${units[index]}`; + } + + function formatLogMessage(message) { + const value = String(message || "").trim(); + + if (!value) { + return "No log payload."; + } + + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } + } + + function buildSecretPreview(secret) { + const fragments = []; + + if (secret.description) { + fragments.push(secret.description); + } + + if (secret.owningService) { + fragments.push(`Service: ${secret.owningService}`); + } + + if (secret.primaryRegion) { + fragments.push(`Region: ${secret.primaryRegion}`); + } + + if (secret.tagCount) { + fragments.push(`${secret.tagCount} tag${secret.tagCount === 1 ? "" : "s"}`); + } + + if (!fragments.length) { + fragments.push("No description or tags yet."); + } + + return fragments.join(" • "); + } + + function toggleSecretReveal(id) { + if (secretsState.revealedIds.has(id)) { + secretsState.revealedIds.delete(id); + } else { + secretsState.revealedIds.add(id); + } + + renderSecretsAll(); + } + + function maskSecretValue(value) { + const source = String(value || ""); + + if (!source) { + return "Secret is loaded but empty."; + } + + const lines = source.split("\n"); + return lines.map((line) => "•".repeat(Math.max(12, Math.min(line.length || 0, 48)))).join("\n"); + } + + function buildS3Preview(object) { + const fragments = []; + + if (object.storageClass) { + fragments.push(object.storageClass); + } + + fragments.push(formatBytes(object.size)); + + if (object.etag) { + fragments.push(`ETag ${object.etag.slice(0, 12)}`); + } + + return fragments.join(" • "); + } + + function prettyJsonOrText(value) { + const parsed = tryParseJsonText(value); + return parsed.ok ? JSON.stringify(parsed.value, null, 2) : String(value || ""); + } + + function detectLogLevel(event) { + const parsed = tryParseJsonText(event?.message); + const candidates = parsed.ok + ? [parsed.value?.level, parsed.value?.severity, parsed.value?.logLevel, parsed.value?.status, parsed.value?.lvl] + : [String(event?.message || "").match(/\b(error|warn|warning|info|debug|trace|fatal)\b/i)?.[0] || ""]; + const normalized = String(candidates.find(Boolean) || "").toLowerCase(); + + if (["fatal", "error", "critical"].includes(normalized)) { + return { label: normalized.toUpperCase(), className: "levelError" }; + } + + if (["warn", "warning"].includes(normalized)) { + return { label: "WARN", className: "levelWarn" }; + } + + if (["info", "notice"].includes(normalized)) { + return { label: normalized.toUpperCase(), className: "levelInfo" }; + } + + if (["debug", "trace"].includes(normalized)) { + return { label: normalized.toUpperCase(), className: "levelDebug" }; + } + + return null; + } + + function renderLogPreviewContent(event) { + const parsedLog = tryParseJsonText(event?.message); + + if (!parsedLog.ok) { + return escapeHtml(event?.preview || "No preview available."); + } + + const compactJson = JSON.stringify(parsedLog.value); + const previewText = compactJson.length > 220 ? `${compactJson.slice(0, 217)}...` : compactJson; + return highlightJsonText(previewText); + } + + function renderLogBodyContent(message) { + const parsedLog = tryParseJsonText(message); + + if (!parsedLog.ok) { + return escapeHtml(formatLogMessage(message)); + } + + return highlightJsonText(JSON.stringify(parsedLog.value, null, 2)); + } + + function tryParseJsonText(message) { + const value = String(message || "").trim(); + + if (!value) { + return { ok: false, value: null }; + } + + try { + return { ok: true, value: JSON.parse(value) }; + } catch { + return { ok: false, value: null }; + } + } + + function highlightJsonText(value) { + const source = String(value ?? ""); + const tokenRegex = + /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/g; + let html = ""; + let lastIndex = 0; + + for (const match of source.matchAll(tokenRegex)) { + const [token] = match; + const index = match.index ?? 0; + let className = "jsonNumber"; + + html += escapeHtml(source.slice(lastIndex, index)); + + if (token.endsWith(":")) { + className = "jsonKey"; + } else if (token === "true" || token === "false") { + className = "jsonBoolean"; + } else if (token === "null") { + className = "jsonNull"; + } else if (token.startsWith('"')) { + className = "jsonString"; + } + + html += `${escapeHtml(token)}`; + lastIndex = index + token.length; + } + + html += escapeHtml(source.slice(lastIndex)); + return html; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + async function safeJson(response) { + try { + return await response.json(); + } catch { + return null; + } + } +} diff --git a/_reference/localEmailViewer/server/config.js b/_reference/localEmailViewer/server/config.js new file mode 100644 index 000000000..c5d4ed154 --- /dev/null +++ b/_reference/localEmailViewer/server/config.js @@ -0,0 +1,45 @@ +import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { S3Client } from "@aws-sdk/client-s3"; + +export const PORT = Number(process.env.PORT || 3334); +export const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses"; +export const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000); +export const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000); +export const CLOUDWATCH_ENDPOINT = process.env.CLOUDWATCH_VIEWER_ENDPOINT || "http://localhost:4566"; +export const CLOUDWATCH_REGION = + process.env.CLOUDWATCH_VIEWER_REGION || process.env.AWS_DEFAULT_REGION || "ca-central-1"; +export const CLOUDWATCH_DEFAULT_GROUP = process.env.CLOUDWATCH_VIEWER_LOG_GROUP || "development"; +export const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW_MS || 15 * 60 * 1000); +export const CLOUDWATCH_DEFAULT_LIMIT = Number(process.env.CLOUDWATCH_VIEWER_LIMIT || 200); +export const SECRETS_ENDPOINT = process.env.SECRETS_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT; +export const SECRETS_REGION = process.env.SECRETS_VIEWER_REGION || CLOUDWATCH_REGION; +export const S3_ENDPOINT = process.env.S3_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT; +export const S3_REGION = process.env.S3_VIEWER_REGION || CLOUDWATCH_REGION; +export const S3_DEFAULT_BUCKET = process.env.S3_VIEWER_BUCKET || ""; +export const S3_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_PREVIEW_BYTES || 256 * 1024); +export const S3_IMAGE_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_IMAGE_PREVIEW_BYTES || 1024 * 1024); + +export const LOCALSTACK_CREDENTIALS = { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test" +}; + +export const cloudWatchLogsClient = new CloudWatchLogsClient({ + region: CLOUDWATCH_REGION, + endpoint: CLOUDWATCH_ENDPOINT, + credentials: LOCALSTACK_CREDENTIALS +}); + +export const secretsManagerClient = new SecretsManagerClient({ + region: SECRETS_REGION, + endpoint: SECRETS_ENDPOINT, + credentials: LOCALSTACK_CREDENTIALS +}); + +export const s3Client = new S3Client({ + region: S3_REGION, + endpoint: S3_ENDPOINT, + credentials: LOCALSTACK_CREDENTIALS, + forcePathStyle: true +}); diff --git a/_reference/localEmailViewer/server/localstack-service.js b/_reference/localEmailViewer/server/localstack-service.js new file mode 100644 index 000000000..ad2b7473f --- /dev/null +++ b/_reference/localEmailViewer/server/localstack-service.js @@ -0,0 +1,845 @@ +import fetch from "node-fetch"; +import { + DescribeLogGroupsCommand, + DescribeLogStreamsCommand, + FilterLogEventsCommand +} from "@aws-sdk/client-cloudwatch-logs"; +import { GetSecretValueCommand, ListSecretsCommand } from "@aws-sdk/client-secrets-manager"; +import { GetObjectCommand, HeadObjectCommand, ListBucketsCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; +import { simpleParser } from "mailparser"; +import { + CLOUDWATCH_ENDPOINT, + CLOUDWATCH_REGION, + FETCH_TIMEOUT_MS, + S3_ENDPOINT, + S3_IMAGE_PREVIEW_MAX_BYTES, + S3_PREVIEW_MAX_BYTES, + S3_REGION, + SES_ENDPOINT, + SECRETS_ENDPOINT, + SECRETS_REGION, + cloudWatchLogsClient, + s3Client, + secretsManagerClient +} from "./config.js"; + +async function loadMessages() { + const startedAt = Date.now(); + const sesMessages = await fetchSesMessages(); + const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index))); + + messages.sort((left, right) => { + if ((right.timestampMs || 0) !== (left.timestampMs || 0)) { + return (right.timestampMs || 0) - (left.timestampMs || 0); + } + + return right.index - left.index; + }); + + return { + endpoint: SES_ENDPOINT, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalMessages: messages.length, + parseErrors: messages.filter((message) => Boolean(message.parseError)).length, + latestMessageTimestamp: messages[0]?.timestamp || "", + messages + }; +} + +async function fetchSesMessages() { + const response = await fetch(SES_ENDPOINT, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }); + + if (!response.ok) { + throw new Error(`SES endpoint responded with ${response.status}`); + } + + const data = await response.json(); + return Array.isArray(data.messages) ? data.messages : []; +} + +async function loadLogGroups() { + const groups = []; + let nextToken; + let pageCount = 0; + + do { + const response = await cloudWatchLogsClient.send( + new DescribeLogGroupsCommand({ + nextToken, + limit: 50 + }) + ); + + groups.push( + ...(response.logGroups || []).map((group) => ({ + name: group.logGroupName || "", + arn: group.arn || "", + storedBytes: group.storedBytes || 0, + retentionInDays: group.retentionInDays || 0, + creationTime: group.creationTime || 0 + })) + ); + + nextToken = response.nextToken; + pageCount += 1; + } while (nextToken && pageCount < 10); + + return groups.sort((left, right) => left.name.localeCompare(right.name)); +} + +async function loadLogStreams(logGroupName) { + const streams = []; + let nextToken; + let pageCount = 0; + + do { + const response = await cloudWatchLogsClient.send( + new DescribeLogStreamsCommand({ + logGroupName, + descending: true, + orderBy: "LastEventTime", + nextToken, + limit: 50 + }) + ); + + streams.push( + ...(response.logStreams || []).map((stream) => ({ + name: stream.logStreamName || "", + arn: stream.arn || "", + lastEventTimestamp: stream.lastEventTimestamp || 0, + lastIngestionTime: stream.lastIngestionTime || 0, + storedBytes: stream.storedBytes || 0 + })) + ); + + nextToken = response.nextToken; + pageCount += 1; + } while (nextToken && pageCount < 6 && streams.length < 250); + + return streams; +} + +async function loadLogEvents({ logGroupName, logStreamName, windowMs, limit }) { + const startedAt = Date.now(); + const eventMap = new Map(); + const startTime = Date.now() - windowMs; + let nextToken; + let previousToken = ""; + let pageCount = 0; + let searchedLogStreams = 0; + + do { + const response = await cloudWatchLogsClient.send( + new FilterLogEventsCommand({ + logGroupName, + logStreamNames: logStreamName ? [logStreamName] : undefined, + startTime, + endTime: Date.now(), + limit, + nextToken + }) + ); + + for (const event of response.events || []) { + const id = + event.eventId || `${event.logStreamName || "stream"}-${event.timestamp || 0}-${event.ingestionTime || 0}`; + + if (!eventMap.has(id)) { + const message = String(event.message || "").trim(); + eventMap.set(id, { + id, + timestamp: event.timestamp || 0, + ingestionTime: event.ingestionTime || 0, + logStreamName: event.logStreamName || "", + message, + preview: buildLogPreview(message) + }); + } + } + + searchedLogStreams = Math.max(searchedLogStreams, (response.searchedLogStreams || []).length); + previousToken = nextToken || ""; + nextToken = response.nextToken; + pageCount += 1; + } while (nextToken && nextToken !== previousToken && pageCount < 10 && eventMap.size < limit); + + const events = [...eventMap.values()] + .sort((left, right) => { + if ((right.timestamp || 0) !== (left.timestamp || 0)) { + return (right.timestamp || 0) - (left.timestamp || 0); + } + + return left.logStreamName.localeCompare(right.logStreamName); + }) + .slice(0, limit); + + return { + endpoint: CLOUDWATCH_ENDPOINT, + region: CLOUDWATCH_REGION, + logGroupName, + logStreamName, + fetchDurationMs: Date.now() - startedAt, + latestTimestamp: events[0]?.timestamp || 0, + searchedLogStreams, + totalEvents: events.length, + events + }; +} + +async function loadSecrets() { + const startedAt = Date.now(); + const secrets = []; + let nextToken; + let pageCount = 0; + + do { + const response = await secretsManagerClient.send( + new ListSecretsCommand({ + NextToken: nextToken, + MaxResults: 50 + }) + ); + + secrets.push( + ...(response.SecretList || []).map((secret, index) => ({ + id: secret.ARN || secret.Name || `secret-${index}`, + name: secret.Name || "Unnamed secret", + arn: secret.ARN || "", + description: secret.Description || "", + createdDate: normalizeTimestamp(secret.CreatedDate), + lastChangedDate: normalizeTimestamp(secret.LastChangedDate), + lastAccessedDate: normalizeTimestamp(secret.LastAccessedDate), + deletedDate: normalizeTimestamp(secret.DeletedDate), + primaryRegion: secret.PrimaryRegion || "", + owningService: secret.OwningService || "", + rotationEnabled: Boolean(secret.RotationEnabled), + versionCount: Object.keys(secret.SecretVersionsToStages || {}).length, + tagCount: Array.isArray(secret.Tags) ? secret.Tags.length : 0, + tags: (secret.Tags || []) + .map((tag) => ({ + key: tag.Key || "", + value: tag.Value || "" + })) + .filter((tag) => tag.key || tag.value) + })) + ); + + nextToken = response.NextToken; + pageCount += 1; + } while (nextToken && pageCount < 10 && secrets.length < 500); + + secrets.sort((left, right) => { + const leftTime = Date.parse(left.lastChangedDate || left.createdDate || 0) || 0; + const rightTime = Date.parse(right.lastChangedDate || right.createdDate || 0) || 0; + + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + return left.name.localeCompare(right.name); + }); + + return { + endpoint: SECRETS_ENDPOINT, + region: SECRETS_REGION, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalSecrets: secrets.length, + latestTimestamp: secrets[0]?.lastChangedDate || secrets[0]?.createdDate || "", + secrets + }; +} + +async function loadSecretValue(secretId) { + const startedAt = Date.now(); + const response = await secretsManagerClient.send( + new GetSecretValueCommand({ + SecretId: secretId + }) + ); + + const secretBinary = response.SecretBinary + ? typeof response.SecretBinary === "string" + ? response.SecretBinary + : Buffer.from(response.SecretBinary).toString("base64") + : ""; + + return { + endpoint: SECRETS_ENDPOINT, + region: SECRETS_REGION, + fetchDurationMs: Date.now() - startedAt, + id: secretId, + name: response.Name || "", + arn: response.ARN || "", + versionId: response.VersionId || "", + versionStages: Array.isArray(response.VersionStages) ? response.VersionStages : [], + createdDate: normalizeTimestamp(response.CreatedDate), + secretString: typeof response.SecretString === "string" ? response.SecretString : "", + secretBinary + }; +} + +async function loadS3Buckets() { + const startedAt = Date.now(); + const response = await s3Client.send(new ListBucketsCommand({})); + const buckets = (response.Buckets || []) + .map((bucket) => ({ + name: bucket.Name || "", + creationDate: normalizeTimestamp(bucket.CreationDate) + })) + .filter((bucket) => bucket.name) + .sort((left, right) => left.name.localeCompare(right.name)); + + return { + endpoint: S3_ENDPOINT, + region: S3_REGION, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalBuckets: buckets.length, + buckets + }; +} + +async function loadS3Objects({ bucket, prefix }) { + const startedAt = Date.now(); + const objects = []; + let continuationToken; + let pageCount = 0; + + do { + const response = await s3Client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: prefix || undefined, + ContinuationToken: continuationToken, + MaxKeys: 200 + }) + ); + + objects.push( + ...(response.Contents || []).map((object, index) => ({ + id: `${bucket}::${object.Key || index}`, + bucket, + key: object.Key || "", + size: object.Size || 0, + lastModified: normalizeTimestamp(object.LastModified), + etag: String(object.ETag || "").replace(/^"|"$/g, ""), + storageClass: object.StorageClass || "STANDARD" + })) + ); + + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + pageCount += 1; + } while (continuationToken && pageCount < 10 && objects.length < 1000); + + objects.sort((left, right) => { + const leftTime = Date.parse(left.lastModified || 0) || 0; + const rightTime = Date.parse(right.lastModified || 0) || 0; + + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + return left.key.localeCompare(right.key); + }); + + return { + endpoint: S3_ENDPOINT, + region: S3_REGION, + bucket, + prefix, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalObjects: objects.length, + latestTimestamp: objects[0]?.lastModified || "", + objects + }; +} + +async function loadS3ObjectPreview({ bucket, key }) { + const startedAt = Date.now(); + const head = await s3Client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: key + }) + ); + + const contentType = head.ContentType || guessObjectContentType(key); + const contentLength = Number(head.ContentLength || 0); + const previewType = resolveS3PreviewType(contentType, key); + const result = { + endpoint: S3_ENDPOINT, + region: S3_REGION, + bucket, + key, + fetchDurationMs: 0, + contentType, + contentLength, + etag: String(head.ETag || "").replace(/^"|"$/g, ""), + lastModified: normalizeTimestamp(head.LastModified), + metadata: head.Metadata || {}, + previewType, + previewText: "", + imageDataUrl: "", + truncated: false + }; + + const shouldLoadTextPreview = previewType === "json" || previewType === "text" || previewType === "html"; + const shouldLoadImagePreview = + previewType === "image" && contentLength > 0 && contentLength <= S3_IMAGE_PREVIEW_MAX_BYTES; + + if ((shouldLoadTextPreview || shouldLoadImagePreview) && contentLength > 0) { + const previewBytes = Math.max(1, Math.min(contentLength || S3_PREVIEW_MAX_BYTES, S3_PREVIEW_MAX_BYTES)); + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: bucket, + Key: key, + Range: `bytes=0-${previewBytes - 1}` + }) + ); + const content = Buffer.from(await response.Body.transformToByteArray()); + result.truncated = contentLength > content.length; + + if (shouldLoadImagePreview) { + result.imageDataUrl = `data:${contentType};base64,${content.toString("base64")}`; + } else { + result.previewText = content.toString("utf8"); + } + } + + result.fetchDurationMs = Date.now() - startedAt; + return result; +} + +async function loadServiceHealthSummary() { + const startedAt = Date.now(); + const [sesResult, logsResult, secretsResult, s3Result] = await Promise.allSettled([ + fetchSesMessages(), + loadLogGroups(), + loadSecrets(), + loadS3Buckets() + ]); + + return { + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + services: { + emails: summarizeHealthResult({ + icon: "✉️", + panel: "emails", + label: "SES Emails", + result: sesResult, + count: sesResult.status === "fulfilled" ? sesResult.value.length : 0, + detail: SES_ENDPOINT, + noun: "email" + }), + logs: summarizeHealthResult({ + icon: "📜", + panel: "logs", + label: "CloudWatch Logs", + result: logsResult, + count: logsResult.status === "fulfilled" ? logsResult.value.length : 0, + detail: `${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`, + noun: "group" + }), + secrets: summarizeHealthResult({ + icon: "🔐", + panel: "secrets", + label: "Secrets Manager", + result: secretsResult, + count: secretsResult.status === "fulfilled" ? secretsResult.value.totalSecrets : 0, + detail: `${SECRETS_ENDPOINT} (${SECRETS_REGION})`, + noun: "secret" + }), + s3: summarizeHealthResult({ + icon: "🪣", + panel: "s3", + label: "S3 Explorer", + result: s3Result, + count: s3Result.status === "fulfilled" ? s3Result.value.totalBuckets : 0, + detail: `${S3_ENDPOINT} (${S3_REGION})`, + noun: "bucket" + }) + } + }; +} + +async function findSesMessageById(id) { + const messages = await fetchSesMessages(); + return messages.find((message, index) => resolveMessageId(message, index) === id) || null; +} + +async function parseSesMessageById(id) { + const message = await findSesMessageById(id); + + if (!message) { + return null; + } + + return simpleParser(message.RawData || ""); +} + +async function toMessageViewModel(message, index) { + const id = resolveMessageId(message, index); + + try { + const parsed = await simpleParser(message.RawData || ""); + const textContent = normalizeText(parsed.text || ""); + const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || ""); + const timestamp = normalizeTimestamp(message.Timestamp || parsed.date); + + return { + id, + index, + from: formatAddressList(parsed.from) || message.Source || "Unknown sender", + to: formatAddressList(parsed.to) || "No To Address", + replyTo: formatAddressList(parsed.replyTo), + subject: parsed.subject || "No Subject", + region: message.Region || "", + timestamp, + timestampMs: timestamp ? Date.parse(timestamp) : 0, + messageId: parsed.messageId || "", + rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), + attachmentCount: parsed.attachments.length, + attachments: parsed.attachments.map((attachment, attachmentIndex) => ({ + index: attachmentIndex, + filename: resolveAttachmentFilename(attachment, attachmentIndex), + contentType: attachment.contentType || "application/octet-stream", + size: attachment.size || 0 + })), + preview: buildPreview(textContent, renderedHtml), + textContent, + renderedHtml, + hasHtml: Boolean(renderedHtml), + parseError: "" + }; + } catch (error) { + return { + id, + index, + from: message.Source || "Unknown sender", + to: "Unknown recipient", + replyTo: "", + subject: "Unable to parse message", + region: message.Region || "", + timestamp: normalizeTimestamp(message.Timestamp), + timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0, + messageId: "", + rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), + attachmentCount: 0, + attachments: [], + preview: "This message could not be parsed. Open the raw view to inspect the MIME source.", + textContent: "", + renderedHtml: "", + hasHtml: false, + parseError: error.message + }; + } +} + +function resolveMessageId(message, index = 0) { + return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`; +} + +function resolveAttachmentFilename(attachment, index = 0) { + if (attachment?.filename) { + return attachment.filename; + } + + return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`; +} + +function attachmentExtension(contentType) { + const normalized = String(contentType || "") + .split(";")[0] + .trim() + .toLowerCase(); + + return ( + { + "application/json": ".json", + "application/pdf": ".pdf", + "application/zip": ".zip", + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "text/calendar": ".ics", + "text/csv": ".csv", + "text/html": ".html", + "text/plain": ".txt" + }[normalized] || "" + ); +} + +function buildAttachmentDisposition(filename) { + const fallback = String(filename || "attachment") + .replace(/[^\x20-\x7e]/g, "_") + .replace(/["\\]/g, "_"); + + return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`; +} + +function buildInlineDisposition(filename) { + const fallback = String(filename || "file") + .replace(/[^\x20-\x7e]/g, "_") + .replace(/["\\]/g, "_"); + + return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "file")}`; +} + +function basenameFromKey(key) { + const value = String(key || ""); + const parts = value.split("/").filter(Boolean); + return parts[parts.length - 1] || "file"; +} + +function guessObjectContentType(key) { + const normalizedKey = String(key || "").toLowerCase(); + + if (normalizedKey.endsWith(".json")) { + return "application/json"; + } + + if (normalizedKey.endsWith(".csv")) { + return "text/csv"; + } + + if (normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) { + return "text/html"; + } + + if (normalizedKey.endsWith(".txt") || normalizedKey.endsWith(".log") || normalizedKey.endsWith(".md")) { + return "text/plain"; + } + + if (normalizedKey.endsWith(".png")) { + return "image/png"; + } + + if (normalizedKey.endsWith(".jpg") || normalizedKey.endsWith(".jpeg")) { + return "image/jpeg"; + } + + if (normalizedKey.endsWith(".gif")) { + return "image/gif"; + } + + if (normalizedKey.endsWith(".webp")) { + return "image/webp"; + } + + if (normalizedKey.endsWith(".svg")) { + return "image/svg+xml"; + } + + if (normalizedKey.endsWith(".pdf")) { + return "application/pdf"; + } + + return "application/octet-stream"; +} + +function resolveS3PreviewType(contentType, key) { + const normalizedType = String(contentType || "").toLowerCase(); + const normalizedKey = String(key || "").toLowerCase(); + + if (normalizedType.includes("json") || normalizedKey.endsWith(".json")) { + return "json"; + } + + if (normalizedType.startsWith("image/")) { + return "image"; + } + + if (normalizedType.includes("html") || normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) { + return "html"; + } + + if ( + normalizedType.startsWith("text/") || + [".txt", ".log", ".csv", ".xml", ".yml", ".yaml", ".md"].some((extension) => normalizedKey.endsWith(extension)) + ) { + return "text"; + } + + return "binary"; +} + +function summarizeHealthResult({ icon, panel, label, result, count, detail, noun }) { + if (result.status === "fulfilled") { + return { + ok: true, + icon, + panel, + label, + count, + summary: `${count} ${noun}${count === 1 ? "" : "s"}`, + detail + }; + } + + return { + ok: false, + icon, + panel, + label, + count: 0, + summary: "Needs attention", + detail: result.reason?.message || detail + }; +} + +function normalizeTimestamp(value) { + if (!value) { + return ""; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toISOString(); +} + +function normalizeText(value) { + return String(value || "") + .replace(/\r\n/g, "\n") + .trim(); +} + +function buildPreview(textContent, renderedHtml) { + const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim(); + + if (!source) { + return "No message preview available."; + } + + return source.length > 220 ? `${source.slice(0, 217)}...` : source; +} + +function buildLogPreview(message) { + const source = String(message || "") + .replace(/\s+/g, " ") + .trim(); + + if (!source) { + return "No log preview available."; + } + + return source.length > 220 ? `${source.slice(0, 217)}...` : source; +} + +function clampNumber(value, fallback, min, max) { + const parsed = Number(value); + + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.min(Math.max(parsed, min), max); +} + +function buildRenderedHtml(html) { + if (!html) { + return ""; + } + + const value = String(html); + const hasDocument = /]/i.test(value) || / + + + + + + + + ${value} +`; +} + +function stripTags(value) { + return String(value || "") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " "); +} + +function formatAddressList(addresses) { + if (!addresses?.value?.length) { + return ""; + } + + return addresses.value + .map(({ name, address }) => { + if (name && address) { + return `${name} <${address}>`; + } + + return address || name || ""; + }) + .filter(Boolean) + .join(", "); +} + +async function loadMessageAttachment(messageId, attachmentIndex) { + const parsed = await parseSesMessageById(messageId); + + if (!parsed) { + return null; + } + + const attachment = parsed.attachments?.[attachmentIndex]; + + if (!attachment) { + return null; + } + + return { + filename: resolveAttachmentFilename(attachment, attachmentIndex), + contentType: attachment.contentType || "application/octet-stream", + content: Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || "") + }; +} + +async function loadS3ObjectDownload({ bucket, key }) { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: bucket, + Key: key + }) + ); + + return { + filename: basenameFromKey(key), + contentType: response.ContentType || guessObjectContentType(key), + content: Buffer.from(await response.Body.transformToByteArray()) + }; +} + +export { + buildAttachmentDisposition, + buildInlineDisposition, + clampNumber, + findSesMessageById, + loadLogEvents, + loadLogGroups, + loadLogStreams, + loadMessageAttachment, + loadMessages, + loadS3Buckets, + loadS3ObjectDownload, + loadS3ObjectPreview, + loadS3Objects, + loadSecretValue, + loadSecrets, + loadServiceHealthSummary +}; diff --git a/_reference/localEmailViewer/server/page.js b/_reference/localEmailViewer/server/page.js new file mode 100644 index 000000000..15cb6deea --- /dev/null +++ b/_reference/localEmailViewer/server/page.js @@ -0,0 +1,495 @@ +import { + CLOUDWATCH_DEFAULT_GROUP, + CLOUDWATCH_DEFAULT_LIMIT, + CLOUDWATCH_DEFAULT_WINDOW_MS, + CLOUDWATCH_ENDPOINT, + CLOUDWATCH_REGION, + DEFAULT_REFRESH_MS, + S3_DEFAULT_BUCKET, + S3_ENDPOINT, + S3_REGION, + SECRETS_ENDPOINT, + SECRETS_REGION, + SES_ENDPOINT +} from "./config.js"; + +function getClientConfig() { + return { + defaultRefreshMs: DEFAULT_REFRESH_MS, + endpoint: SES_ENDPOINT, + cloudWatchEndpoint: CLOUDWATCH_ENDPOINT, + cloudWatchRegion: CLOUDWATCH_REGION, + secretsEndpoint: SECRETS_ENDPOINT, + secretsRegion: SECRETS_REGION, + s3Endpoint: S3_ENDPOINT, + s3Region: S3_REGION, + defaultS3Bucket: S3_DEFAULT_BUCKET, + defaultLogGroup: CLOUDWATCH_DEFAULT_GROUP, + defaultLogWindowMs: CLOUDWATCH_DEFAULT_WINDOW_MS, + defaultLogLimit: CLOUDWATCH_DEFAULT_LIMIT + }; +} + +function renderHtml() { + return ` + + + + + LocalStack Inspector + + + +
+
+
+
+

LocalStack Toolbox

+

Inspector

+
+
+
+ + +
+
+
+ Stack +
+ +
+
+
+ +
+
+
+ + + + Waiting for first refresh... +
+
+ + + + +
+
+ +
+
Total00 visible
+
New0New since last refresh
+
NewestNo messagesNot refreshed yet
+
FetchIdleEndpoint: ${escapeHtml(SES_ENDPOINT)}
+
+ +
+
+ + +
+
+ +
+
+
+
+ + + + + + +
+ + + +`; +} + +function renderStyles() { + return ` + :root{--panel:rgba(255,255,255,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--log-shell:linear-gradient(180deg,rgba(239,246,255,.98),rgba(248,251,255,.99));--log-body:#f8fbff;--secret-shell:linear-gradient(180deg,rgba(239,251,246,.98),rgba(247,253,249,.99));--secret-body:#f6fcf8;--bucket-shell:linear-gradient(180deg,rgba(255,249,232,.98),rgba(255,252,243,.99));--bucket-body:#fffcf2;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--log-line:rgba(48,113,169,.22);--secret-line:rgba(31,143,101,.2);--bucket-line:rgba(181,137,37,.22);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--info:#3071a9;--info-soft:rgba(48,113,169,.1);--secret:#1f8f65;--secret-soft:rgba(31,143,101,.1);--bucket:#9d6b00;--bucket-soft:rgba(181,137,37,.12);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);--log-shadow:0 16px 32px rgba(48,113,169,.12);--secret-shadow:0 16px 32px rgba(31,143,101,.12);--bucket-shadow:0 16px 32px rgba(181,137,37,.12);} + *{box-sizing:border-box} + html,body{margin:0;height:100%;overflow:hidden} + body{color-scheme:light;background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif;transition:background-color .18s ease,color .18s ease} + button,input,select,textarea{font:inherit} + button{cursor:pointer} + .page{display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;max-width:1360px;height:100vh;height:100dvh;margin:0 auto;padding:14px;overflow:hidden} + .hero{display:block;margin-bottom:0} + .heroShell,.toolControls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)} + .card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)} + .heroShell,.toolControls{border-radius:18px} + .heroShell{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px} + .toolControls{padding:12px} + .heroIdentity{display:grid;gap:3px;min-width:0} + .eyebrow{margin:0 0 4px;color:var(--accent);font-size:.72rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase} + h1{margin:0;font-size:clamp(1.8rem,3.6vw,2.85rem);line-height:.96;letter-spacing:-.05em} + .lede{margin:8px 0 0;max-width:54ch;color:var(--muted);font-size:.92rem} + .heroTopRow{display:flex;flex:1 1 360px;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} + .heroActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} + .heroStatusRow{display:flex;flex:1 1 100%;flex-wrap:wrap;gap:8px;align-items:center} + .heroStatusLabel{color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase} + .helper{margin:0;color:var(--muted);font-size:.89rem} + .healthStrip{display:flex;flex:1 1 520px;flex-wrap:wrap;gap:6px;align-items:center;min-width:0} + .healthBadge{display:inline-flex;align-items:center;gap:8px;min-height:30px;max-width:100%;padding:0 10px;border-radius:999px;border:1px solid rgba(31,41,51,.1);background:rgba(255,255,255,.78);box-shadow:0 8px 18px rgba(15,23,42,.06);text-align:left;transition:transform .12s ease,background-color .12s ease,border-color .12s ease,box-shadow .12s ease} + .healthBadgeName{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:800;white-space:nowrap} + .healthBadgeSummary{min-width:0;overflow:hidden;color:var(--muted);font-size:.78rem;font-weight:700;text-overflow:ellipsis;white-space:nowrap} + .healthBadge.ok{border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.1)} + .healthBadge.bad{border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.1)} + .healthBadge.warn{border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.1)} + .healthBadge.active{border-color:rgba(207,109,60,.28);background:rgba(207,109,60,.16);box-shadow:0 10px 24px rgba(207,109,60,.12)} + .healthBadge.active .healthBadgeName,.healthBadge.active .healthBadgeSummary{color:var(--ink)} + .healthRefreshButton{flex:0 0 auto;padding:0 10px} + .primary,.ghost,.mini,.tab{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} + .themeToggle{white-space:nowrap} + .workspacePanel{display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:6px;min-height:0} + .workspacePanel[hidden]{display:none} + .toolControls{display:grid;gap:8px} + .contentPane{height:100%;min-height:0;overflow:auto;scroll-behavior:smooth;padding-right:4px} + .contentStack{display:grid;gap:8px;min-width:100%;padding-bottom:18px} + .paneTopWrap{display:flex;justify-content:flex-end;position:sticky;bottom:14px;pointer-events:none;padding-right:10px} + .paneTopButton{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;border-radius:999px;border:1px solid rgba(255,255,255,.32);background:rgba(31,41,51,.42);color:#fff;font-size:1.1rem;line-height:1;backdrop-filter:blur(12px);box-shadow:0 10px 24px rgba(31,41,51,.18);opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none;transition:opacity .16s ease,transform .16s ease,background-color .12s ease;z-index:6} + .paneTopButton.visible{opacity:.78;transform:translateY(0);visibility:visible;pointer-events:auto} + .paneTopButton.visible:hover{opacity:1;background:rgba(31,41,51,.62);transform:translateY(-1px)} + .row{display:flex;flex-wrap:wrap;gap:6px;align-items:center} + .primary,.ghost{min-height:34px;padding:0 12px;font-weight:700} + .mini,.tab{min-height:28px;padding:0 10px;font-weight:600} + .primary{background:var(--accent);color:#fff7f2} + .ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)} + .tab{background:transparent;color:var(--muted)} + .tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)} + .primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)} + .chip{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:0 10px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.88rem} + .chip input{margin:0;accent-color:var(--accent)} + .chip select{border:none;background:transparent;outline:none;color:var(--ink)} + .search{flex:1 1 260px;min-height:36px;padding:0 12px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none} + .searchCompact{flex:1 1 220px} + .status{display:inline-flex;align-items:center;min-height:32px;padding:0 11px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.86rem;font-weight:600} + .status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)} + .status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)} + .status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)} + .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:0} + .stat{border-radius:16px;padding:10px 12px} + .stat span{display:block;margin-bottom:4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} + .stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em} + .stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em} + .stat small{display:block;margin-top:4px;color:var(--muted);font-size:.82rem} + .banner,.empty{margin:0;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)} + .banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)} + .list{display:grid;gap:12px;align-content:start} + .logList{display:grid;gap:10px;align-content:start;width:100%} + .card{overflow:hidden;border-radius:16px} + .card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)} + .summary{list-style:none;display:grid;gap:7px;padding:12px 14px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))} + .summary::-webkit-details-marker{display:none} + .top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center} + .top{justify-content:space-between} + .head{min-width:0;flex:1 1 320px} + .head h2{margin:0;font-size:clamp(1rem,1.6vw,1.22rem);line-height:1.18;letter-spacing:-.03em;word-break:break-word} + .meta{margin:4px 0 0;color:var(--muted);font-size:.88rem;word-break:break-word} + .time,.tag{display:inline-flex;align-items:center;min-height:24px;padding:0 10px;border-radius:999px;font-size:.76rem;font-weight:700} + .time{background:rgba(31,41,51,.06)} + .tag{background:var(--accent-soft);color:#8d5632} + .tag.new{background:rgba(31,143,101,.1);color:var(--ok)} + .tag.bad{background:rgba(179,58,58,.1);color:var(--bad)} + .preview{margin:0;color:#324150;font-size:.9rem} + .body{display:grid;gap:10px;padding:10px 14px 14px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)} + .toolbar{justify-content:space-between;align-items:center} + .tabs{display:inline-flex;gap:4px;padding:3px;border-radius:999px;background:rgba(207,109,60,.08)} + .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px} + .metaCard{padding:9px 11px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)} + .metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} + .metaCard dd{margin:0;word-break:break-word} + .attachments{gap:6px} + .attachment{display:inline-flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;background:rgba(255,248,240,.96);border:1px solid rgba(207,109,60,.12);font-size:.84rem} + .attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} + .attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)} + .panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff} + .logEvent{width:100%;overflow:hidden;border-radius:16px;border:1px solid var(--log-line);background:var(--log-shell);box-shadow:var(--log-shadow)} + .secretCard{background:var(--secret-shell);border:1px solid var(--secret-line);box-shadow:var(--secret-shadow)} + .s3Card{background:var(--bucket-shell);border:1px solid var(--bucket-line);box-shadow:var(--bucket-shadow)} + .logSummary{list-style:none;display:grid;gap:7px;padding:10px 12px;cursor:pointer} + .logSummary::-webkit-details-marker{display:none} + .secretSummary{background:linear-gradient(180deg,rgba(244,253,248,.9),rgba(236,249,242,.96))} + .s3Summary{background:linear-gradient(180deg,rgba(255,251,238,.92),rgba(255,246,223,.98))} + .logSummaryTop{display:flex;flex-wrap:wrap;gap:8px;justify-content:space-between;align-items:center} + .logMeta{display:flex;flex-wrap:wrap;gap:8px;align-items:center} + .logSummaryActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} + .logTag{background:var(--info-soft);color:var(--info);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .secretTag{background:var(--secret-soft);color:var(--secret);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bucketTag{background:var(--bucket-soft);color:var(--bucket);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .logPreview{margin:0;color:#324150;font:600 .88rem/1.45 "Cascadia Code","Consolas",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .logBody{padding:8px 12px 12px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)} + .secretBody{border-top-color:rgba(31,143,101,.14);background:var(--secret-body)} + .s3Body{border-top-color:rgba(181,137,37,.18);background:var(--bucket-body)} + .logCopyButton{box-shadow:none} + .logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff} + .secretValuePanel{display:grid;gap:10px} + .secretValuePanel pre{border-radius:12px;border:1px solid rgba(31,143,101,.14);padding:12px;background:linear-gradient(180deg,rgba(31,143,101,.04),transparent 140px),#fff} + .s3PreviewPanel{display:grid;gap:10px} + .s3PreviewImage{max-width:min(100%,640px);border-radius:12px;border:1px solid rgba(181,137,37,.16);background:#fff} + .logBody.wrapOff pre{white-space:pre;word-break:normal} + .tag.levelError{background:rgba(179,58,58,.12);color:var(--bad)} + .tag.levelWarn{background:rgba(157,95,0,.12);color:var(--warn)} + .tag.levelInfo{background:rgba(48,113,169,.12);color:var(--info)} + .tag.levelDebug{background:rgba(96,112,128,.12);color:var(--muted)} + .jsonSyntax .jsonKey{color:#b55f2d} + .jsonSyntax .jsonString{color:#1f8f65} + .jsonSyntax .jsonNumber{color:#2f6ea9} + .jsonSyntax .jsonBoolean{color:#9d5f00} + .jsonSyntax .jsonNull{color:#b33a3a} + iframe{width:100%;min-height:560px;border:none;background:#fff} + pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff} + .placeholder,.inlineError{padding:12px} + .inlineError{color:var(--bad)} + body[data-theme="dark"]{color-scheme:dark;background:radial-gradient(circle at top left,rgba(207,109,60,.12),transparent 28%),radial-gradient(circle at top right,rgba(48,113,169,.12),transparent 26%),linear-gradient(180deg,#10161d,#17202a)} + body[data-theme="dark"] .heroShell, + body[data-theme="dark"] .toolControls, + body[data-theme="dark"] .stat{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.16);box-shadow:0 14px 30px rgba(0,0,0,.32)} + body[data-theme="dark"] .card{background:linear-gradient(180deg,rgba(50,35,28,.96),rgba(31,25,22,.98));border-color:rgba(207,109,60,.24);box-shadow:0 18px 34px rgba(0,0,0,.34)} + body[data-theme="dark"] .logEvent{background:linear-gradient(180deg,rgba(18,31,45,.96),rgba(14,24,36,.98));border-color:rgba(73,144,204,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)} + body[data-theme="dark"] .secretCard{background:linear-gradient(180deg,rgba(19,39,31,.96),rgba(14,30,24,.98));border-color:rgba(64,170,126,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)} + body[data-theme="dark"] .s3Card{background:linear-gradient(180deg,rgba(52,42,17,.96),rgba(37,30,13,.98));border-color:rgba(181,137,37,.24);box-shadow:0 16px 32px rgba(0,0,0,.34)} + body[data-theme="dark"] .healthBadge{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.18);box-shadow:0 10px 22px rgba(0,0,0,.28)} + body[data-theme="dark"] .healthBadge.active{border-color:rgba(207,109,60,.32);background:rgba(207,109,60,.18);box-shadow:0 12px 26px rgba(0,0,0,.3)} + body[data-theme="dark"] .healthBadge.active .healthBadgeName, + body[data-theme="dark"] .healthBadge.active .healthBadgeSummary{color:#f8ede6} + body[data-theme="dark"] .tab{color:#aab8c8} + body[data-theme="dark"] .tab.active, + body[data-theme="dark"] .ghost, + body[data-theme="dark"] .mini, + body[data-theme="dark"] .chip, + body[data-theme="dark"] .status, + body[data-theme="dark"] .search{background:rgba(18,25,35,.88);border-color:rgba(148,163,184,.18);color:#edf2f7} + body[data-theme="dark"] .chip select, + body[data-theme="dark"] .search::placeholder{color:#9fb0c2} + body[data-theme="dark"] .ghost, + body[data-theme="dark"] .mini, + body[data-theme="dark"] .tab.active{border-color:rgba(148,163,184,.18)} + body[data-theme="dark"] .summary{background:linear-gradient(180deg,rgba(58,40,31,.88),rgba(45,33,28,.96))} + body[data-theme="dark"] .body{background:#211a17;border-top-color:rgba(207,109,60,.18)} + body[data-theme="dark"] .logSummary{background:linear-gradient(180deg,rgba(21,34,47,.94),rgba(16,27,39,.98))} + body[data-theme="dark"] .logBody{background:#13212d;border-top-color:rgba(73,144,204,.18)} + body[data-theme="dark"] .secretSummary{background:linear-gradient(180deg,rgba(21,43,34,.94),rgba(16,34,27,.98))} + body[data-theme="dark"] .secretBody{background:#12241c;border-top-color:rgba(64,170,126,.18)} + body[data-theme="dark"] .s3Summary{background:linear-gradient(180deg,rgba(53,41,19,.94),rgba(39,31,15,.98))} + body[data-theme="dark"] .s3Body{background:#241d10;border-top-color:rgba(181,137,37,.18)} + body[data-theme="dark"] .metaCard{background:rgba(17,25,35,.64);border-color:rgba(148,163,184,.14)} + body[data-theme="dark"] .attachment{background:rgba(50,35,28,.9);border-color:rgba(207,109,60,.18)} + body[data-theme="dark"] .attachmentLink{color:#f6c4a9} + body[data-theme="dark"] .attachmentLink:hover{background:rgba(75,52,39,.96);border-color:rgba(246,196,169,.26)} + body[data-theme="dark"] .panel, + body[data-theme="dark"] pre, + body[data-theme="dark"] .logBody pre{background:linear-gradient(180deg,rgba(73,144,204,.06),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} + body[data-theme="dark"] .secretValuePanel pre{background:linear-gradient(180deg,rgba(64,170,126,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} + body[data-theme="dark"] .s3PreviewPanel pre{background:linear-gradient(180deg,rgba(181,137,37,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} + body[data-theme="dark"] .panel{border-color:rgba(148,163,184,.14)} + body[data-theme="dark"] .banner, + body[data-theme="dark"] .empty{background:rgba(15,21,30,.82);border-color:rgba(148,163,184,.16)} + body[data-theme="dark"] .time{background:rgba(148,163,184,.12);color:#e8edf3} + body[data-theme="dark"] .tag{background:rgba(207,109,60,.14);color:#f0c2aa} + body[data-theme="dark"] .logTag{background:rgba(73,144,204,.16);color:#93cfff} + body[data-theme="dark"] .secretTag{background:rgba(64,170,126,.16);color:#9fe0be} + body[data-theme="dark"] .bucketTag{background:rgba(181,137,37,.16);color:#f1d38c} + body[data-theme="dark"] .preview, + body[data-theme="dark"] .logPreview, + body[data-theme="dark"] .metaCard dd, + body[data-theme="dark"] .head h2, + body[data-theme="dark"] .stat strong, + body[data-theme="dark"] h1{color:#edf2f7} + body[data-theme="dark"] .jsonSyntax .jsonKey{color:#f0b08a} + body[data-theme="dark"] .jsonSyntax .jsonString{color:#80d5b0} + body[data-theme="dark"] .jsonSyntax .jsonNumber{color:#94c9ff} + body[data-theme="dark"] .jsonSyntax .jsonBoolean{color:#f0c274} + body[data-theme="dark"] .jsonSyntax .jsonNull{color:#ff9c9c} + body[data-theme="dark"] .meta, + body[data-theme="dark"] .helper, + body[data-theme="dark"] .lede, + body[data-theme="dark"] .stat small, + body[data-theme="dark"] .stat span, + body[data-theme="dark"] .chip, + body[data-theme="dark"] .tab{color:#aab8c8} + body[data-theme="dark"] .paneTopButton{border-color:rgba(255,255,255,.18);background:rgba(8,12,18,.58);color:#edf2f7} + body[data-theme="dark"] .paneTopButton.visible:hover{background:rgba(8,12,18,.8)} + @media (max-width:1080px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}} + @media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row,.heroActions{align-items:stretch}.heroTopRow{justify-content:stretch;flex-basis:100%}.heroStatusRow{align-items:flex-start}.heroStatusLabel,.healthStrip{flex-basis:100%}.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.healthBadge{justify-content:flex-start}.logSummaryTop,.logSummaryActions{align-items:flex-start}.contentPane{min-height:300px}iframe{min-height:420px}} + `; +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export { getClientConfig, renderHtml }; diff --git a/_reference/testPlans/commission-based-cut-manual-test-plan.md b/_reference/testPlans/commission-based-cut-manual-test-plan.md new file mode 100644 index 000000000..df94cc3f4 --- /dev/null +++ b/_reference/testPlans/commission-based-cut-manual-test-plan.md @@ -0,0 +1,424 @@ +# Commission-Based Cut Feature Manual Test Plan + +## Purpose +Use this guide to manually test the commission-based cut feature from an end-user point of view. + +This plan is written for a non-technical tester. Follow the steps exactly as written and mark each scenario as Pass or Fail. + +## What You Need Before You Start +- A login that can open `Manage my Shop`, `Jobs`, and `Time Tickets`. +- At least 2 active employees in the shop. +- At least 1 converted repair order that already has labor lines on it. +- If possible, use a simple test job where the labor sale rates are easy to calculate. +- A notebook, spreadsheet, or screenshot folder to record what happened. + +## Recommended Easy-Math Test Data +If you can choose your own test job, use something simple like this: + +- Body sale rate: `$100.00` +- Refinish sale rate: `$120.00` +- Mechanical sale rate: `$80.00` +- 1 Body labor line with `10.0` hours +- 1 Refinish labor line with `4.0` hours + +This makes the expected payout easy to check: + +- `40%` of `$100.00` = `$40.00` +- `30%` of `$120.00` = `$36.00` + +## Important Navigation Notes +- Team setup is under `Manage my Shop` > `Employee Teams`. +- Team assignment happens on the job line grid in the `Team` column. +- Automatic payout happens from the job's `Labor Allocations` card using the `Pay All` button. +- If your shop uses task presets, the `Flag Hours` button can preview the payout method before committing tickets. + +--- + +## Scenario 1: Create a Simple Commission Team +### Goal +Confirm a team member can be set to commission and saved successfully. + +### Steps +1. Sign in. +2. Click `Manage my Shop`. +3. Click the `Employee Teams` tab. +4. Click `New Team`. +5. In `Team Name`, type `Commission Team Test`. +6. Make sure `Active` is turned on. +7. In `Max Load`, enter `10`. +8. Click `New Team Member`. +9. In `Employee`, choose an active employee. +10. In `Allocation %`, enter `100`. +11. In `Payout Method`, choose `Commission %`. +12. In each commission field that appears, enter a value. +13. For the main labor types you plan to test, use these values: +14. Enter `40` for Body. +15. Enter `30` for Refinish. +16. Enter `25` for Mechanical. +17. Enter `20` for Frame. +18. Enter `15` for Glass. +19. Fill in the remaining commission boxes with any valid number from `0` to `100`. +20. Click `Save`. + +### Expected Result +- The team saves successfully. +- The team stays visible in the Employee Teams list. +- The team member card shows a `Commission` tag. +- The `Allocation Total` shows `100%`. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 2: Allocation Total Must Equal 100% +### Goal +Confirm the system blocks a team that does not total exactly 100%. + +### Steps +1. Stay on the same team. +2. Change `Allocation %` from `100` to `90`. +3. Click `Save`. +4. Change `Allocation %` from `90` to `110`. +5. Click `Save`. +6. Change `Allocation %` back to `100`. +7. Click `Save` again. + +### Expected Result +- When the total is `90%`, the system should not save. +- When the total is `110%`, the system should not save. +- The page should show that the allocation total is not correct. +- When the total is set back to `100%`, the save should succeed. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 3: The Same Employee Cannot Be Added Twice +### Goal +Confirm the same employee cannot appear twice on one team. + +### Steps +1. Open the same team again. +2. Click `New Team Member`. +3. Choose the same employee already used on the team. +4. Enter any valid allocation amount. +5. Choose `Commission %`. +6. Fill in all required commission fields. +7. Click `Save`. + +### Expected Result +- The system should block the save. +- The team should not save with the same employee listed twice. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 4: Switching Between Hourly and Commission Changes the Input Style +### Goal +Confirm the rate section changes correctly when the payout method changes. + +### Steps +1. Open the same team again. +2. On the team member row, change `Payout Method` from `Commission %` to `Hourly`. +3. Look at the rate fields that appear. +4. Change `Payout Method` back to `Commission %`. +5. Look at the rate fields again. + +### Expected Result +- In `Hourly` mode, the rate boxes should behave like money/rate fields. +- In `Commission %` mode, the rate boxes should behave like percentage fields. +- The screen should clearly show you are editing the correct type of value. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 5: Boundary Values for Commission % +### Goal +Confirm the feature accepts valid boundary values and blocks invalid ones. + +### Steps +1. Open the team again. +2. In one commission box, enter `0`. +3. In another commission box, enter `100`. +4. Click `Save`. +5. Try to type a value above `100` in one of the commission boxes. +6. Try to type a negative value in one of the commission boxes. + +### Expected Result +- `0` should be accepted. +- `100` should be accepted. +- Values above `100` should not be allowed or should fail validation. +- Negative values should not be allowed or should fail validation. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 6: Inactive Teams Should Not Be Offered for New Assignment +### Goal +Confirm inactive teams do not appear as normal team choices. + +### Steps +1. Open the team again. +2. Turn `Active` off. +3. Click `Save`. +4. Open a converted repair order. +5. Go to the job lines area where the `Team` column is visible. +6. Click inside the `Team` field on any labor line. +7. Open the team drop-down list. +8. Look for `Commission Team Test`. +9. Go back to `Manage my Shop` > `Employee Teams`. +10. Turn `Active` back on. +11. Click `Save`. +12. Return to the same job line and open the `Team` drop-down again. + +### Expected Result +- When the team is inactive, it should not appear as a normal assignment choice. +- After turning it back on, it should appear again. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 7: Assign the Commission Team to a Labor Line +### Goal +Confirm the team can be assigned to a job line from the job screen. + +### Steps +1. Open a converted repair order that has labor lines. +2. Find a labor line in the job line grid. +3. In the `Team` column, click the blank area or the current team name. +4. From the drop-down list, choose `Commission Team Test`. +5. Click outside the field so it saves. +6. Repeat for at least 1 Body line and 1 Refinish line if both exist. + +### Expected Result +- The selected team name should appear in the `Team` column. +- The assignment should stay in place after the screen refreshes. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 8: Pay All Creates Commission-Based Tickets +### Goal +Confirm `Pay All` creates time tickets using the commission rate, not a flat hourly rate. + +### Steps +1. Use a converted repair order that has: +2. At least 1 labor line assigned to `Commission Team Test`. +3. Known labor sale rates on the job. +4. No existing time tickets for the same employee and labor type. +5. Open that repair order. +6. Go to the labor/payroll area where the `Labor Allocations` card is visible. +7. Write down the following before you click anything: +8. The labor type on the line. +9. The sold labor rate for that labor type. +10. The hours on that line. +11. The commission % you entered for that labor type on the team. +12. Click `Pay All`. +13. Wait for the success message. +14. Look at the `Time Tickets` list on the same screen. +15. Find the new ticket created for that employee. + +### Expected Result +- The system should show `All hours paid out successfully.` +- A new time ticket should appear. +- The ticket rate should equal: +- `sale rate x commission %` +- Example: if Body sale rate is `$100.00` and commission is `40%`, the ticket rate should be `$40.00`. +- The productive hours should match the assigned labor hours for that employee. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 9: Different Labor Types Use Different Commission Rates +### Goal +Confirm the feature uses the correct commission % for each labor type. + +### Steps +1. Use a job that has at least: +2. One Body labor line. +3. One Refinish labor line. +4. Make sure both lines are assigned to `Commission Team Test`. +5. Confirm your team is set up like this: +6. Body = `40%` +7. Refinish = `30%` +8. Open the job's `Labor Allocations` area. +9. Click `Pay All`. +10. Review the new time tickets that are created. + +### Expected Result +- The Body ticket should use the Body commission %. +- The Refinish ticket should use the Refinish commission %. +- Example: +- If Body sale rate is `$100.00`, Body payout rate should be `$40.00`. +- If Refinish sale rate is `$120.00`, Refinish payout rate should be `$36.00`. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 10: Mixed Team With Commission and Hourly Members +### Goal +Confirm one team can contain both commission and hourly members, and each person is paid correctly. + +### Steps +1. Open `Manage my Shop` > `Employee Teams`. +2. Open `Commission Team Test`. +3. Edit the first team member: +4. Keep Employee 1 as `Commission %`. +5. Change `Allocation %` to `60`. +6. Make sure Body commission is still `40`. +7. Add a second team member. +8. Choose a different active employee. +9. Set `Allocation %` to `40`. +10. Set `Payout Method` to `Hourly`. +11. Enter an hourly rate for each required labor type. +12. For Body, use `$25.00`. +13. Fill in the other required hourly boxes with valid values. +14. Make sure the total allocation shows `100%`. +15. Click `Save`. +16. Assign this team to a Body line with `10.0` hours. +17. Click `Pay All`. +18. Review the new time tickets. + +### Expected Result +- Employee 1 should receive `60%` of the hours at the commission-derived rate. +- Employee 2 should receive `40%` of the hours at the hourly rate you entered. +- Example with a 10-hour Body line and `$100.00` sale rate: +- Employee 1 should get `6.0` hours at `$40.00`. +- Employee 2 should get `4.0` hours at `$25.00`. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 11: Pay All Only Adds the Remaining Hours +### Goal +Confirm `Pay All` does not duplicate hours that were already paid. + +### Steps +1. Use a job with one Body line assigned to `Commission Team Test`. +2. Make sure the line has `10.0` hours. +3. In the `Time Tickets` card, click `Enter New Time Ticket`. +4. Create a manual time ticket for the same employee and the same labor type. +5. Enter `4.0` productive hours. +6. Save the manual time ticket. +7. Go back to the `Labor Allocations` card. +8. Click `Pay All`. +9. Review the new ticket that is created. + +### Expected Result +- The system should only create the remaining unpaid hours. +- In this example, it should add `6.0` hours, not `10.0`. +- The payout rate should still use the current commission-based rate. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 12: Unassigned Labor Lines Should Block Automatic Payout +### Goal +Confirm `Pay All` does not silently pay lines that do not have a team assigned. + +### Steps +1. Open a converted repair order with at least 2 labor lines. +2. Assign `Commission Team Test` to one line. +3. Leave the second labor line with no team assigned. +4. Go to the `Labor Allocations` card. +5. Click `Pay All`. + +### Expected Result +- The system should not quietly pay everything. +- You should see an error telling you that not all hours have been assigned. +- The unassigned line should still need manual attention. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Scenario 13: Flag Hours Preview Shows the Correct Payout Method +### Goal +If your shop uses task presets, confirm the preview shows `Commission` for commission-based tickets. + +### Steps +1. Open a converted repair order. +2. Go to the `Time Tickets` card. +3. Click `Flag Hours`. +4. Choose a task preset. +5. Wait for the preview table to load. +6. Review the `Payout Method` column in the preview. +7. If the preview includes more than one employee, review each row. + +### Expected Result +- The preview table should load without error. +- Rows for commission-based employees should show `Commission`. +- Rows for hourly employees should show `Hourly`. +- If there are unassigned hours, a warning should appear. + +### Record +- [ ] Pass +- [ ] Fail +- Notes: + +--- + +## Quick Regression Checklist +- [ ] I can create a commission-based team. +- [ ] Allocation must total exactly 100%. +- [ ] The same employee cannot be added twice to one team. +- [ ] Inactive teams do not appear for normal assignment. +- [ ] A team can be assigned to job lines from the `Team` column. +- [ ] `Pay All` creates commission-based tickets correctly. +- [ ] Different labor types use different commission percentages. +- [ ] Mixed commission and hourly teams calculate correctly. +- [ ] `Pay All` only creates the remaining unpaid hours. +- [ ] Unassigned labor lines stop automatic payout. +- [ ] `Flag Hours` preview shows the correct payout method. + +## Tester Sign-Off +- Tester name: +- Test date: +- Environment: +- Overall result: +- Follow-up issues found: diff --git a/_reference/testPlans/select-component-test-plan.md b/_reference/testPlans/select-component-test-plan.md new file mode 100644 index 000000000..ae5eff2c2 --- /dev/null +++ b/_reference/testPlans/select-component-test-plan.md @@ -0,0 +1,647 @@ +# Ant Design Select.Option Deprecation - Manual Testing Plan +**Branch:** `feature/IO-3544-Ant-Select-Deprecation` +**Base Branch:** `master-AIO` +**Jira:** IO-3544 + +## Overview +This branch migrates all Ant Design `` components to the new `options` prop pattern (required for Ant Design v5+). The deprecated `Select.Option` child component pattern has been replaced with the `options` array prop. + +## What Changed +- **Old Pattern:** `` +- **New Pattern:** `; -} +/** + * Formats a phone number for display purposes. If the input value is a valid phone number, it will be formatted in a + * national format (e.g., (123) 456-7890 for US/CA). If the input is not a valid phone number, it will be returned as-is. + * @param value + * @returns {*} + */ +const formatPhoneDisplayValue = (value) => { + if (!value) return value; + + try { + const parsedPhone = parsePhoneNumber(value, "CA"); + return parsedPhone?.isValid() ? parsedPhone.formatNational() : value; + } catch { + return value; + } +}; + +/** + * Generates a "tel:" URL for a phone number if it's valid. If the input value is a valid phone number, it will return a + * URL in the format "tel:+1234567890". If the input is not a valid phone number, it will attempt to trim whitespace and + * return a "tel:" URL with the raw value, or null if the trimmed value is empty. + * @param value + * @returns {string|null} + */ +const getPhoneActionHref = (value) => { + if (!value) return null; + + try { + const parsedPhone = parsePhoneNumber(value, "CA"); + if (parsedPhone?.isValid()) return `tel:${parsedPhone.number}`; + } catch { + // Fall back to the raw value below. + } + + const trimmedValue = String(value).trim(); + return trimmedValue ? `tel:${trimmedValue}` : null; +}; + +const FormItemPhone = forwardRef(function FormItemPhone( + { formatDisplayOnly = false, showPhoneAction = false, value, onBlur, onFocus, ...props }, + ref +) { + const [isFocused, setIsFocused] = useState(false); + const displayValue = useMemo(() => { + if (!formatDisplayOnly || isFocused) return value; + return formatPhoneDisplayValue(value); + }, [formatDisplayOnly, isFocused, value]); + const phoneActionHref = useMemo(() => (showPhoneAction ? getPhoneActionHref(value) : null), [showPhoneAction, value]); + + const input = ( + { + setIsFocused(true); + onFocus?.(event); + }} + onBlur={(event) => { + setIsFocused(false); + onBlur?.(event); + }} + /> + ); + + if (!showPhoneAction) return input; + + return ( + + {input} + {phoneActionHref ? ( + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Edit Adjustment" })); + + fireEvent.change(screen.getByRole("spinbutton"), { + target: { value: "3.7" } + }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(updateAdjustmentsMock).toHaveBeenCalledWith({ + variables: { + jobId: "job-1", + job: { + lbr_adjustments: { + LAA: 3.7, + LAB: 0.5 + } + } + }, + refetchQueries: ["QUERY_JOB"] + }); + }); + + expect(jobmodifylbradjMock).toHaveBeenCalledWith({ + mod_lbr_ty: "LAA", + hours: 2.5 + }); + expect(insertAuditTrailMock).toHaveBeenCalledWith({ + jobid: "job-1", + operation: "audit-entry", + type: "jobmodifylbradj" + }); + expect(notification.success).toHaveBeenCalledWith({ + title: "Saved" + }); + }); +}); diff --git a/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx index 3bcc32f14..02a9366f6 100644 --- a/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx +++ b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx @@ -21,6 +21,8 @@ const mapStateToProps = createStructuredSelector({ technician: selectTechnician }); +const getRequestErrorMessage = (error) => error?.response?.data?.error || error?.message || ""; + export function PayrollLaborAllocationsTable({ jobId, joblines, @@ -43,16 +45,23 @@ export function PayrollLaborAllocationsTable({ }); const notification = useNotification(); - useEffect(() => { - async function CalculateTotals() { + const loadTotals = async () => { + try { const { data } = await axios.post("/payroll/calculatelabor", { jobid: jobId }); setTotals(data); + } catch (error) { + setTotals([]); + notification.error({ + title: getRequestErrorMessage(error) + }); } + }; + useEffect(() => { if (!!joblines && !!timetickets && !!bodyshop) { - CalculateTotals(); + loadTotals(); } if (!jobId) setTotals([]); }, [joblines, timetickets, bodyshop, adjustments, jobId]); @@ -210,28 +219,36 @@ export function PayrollLaborAllocationsTable({ +); + +export const renderConfigListOrEmpty = ({ fields, actionLabel, renderItems }) => + fields.length === 0 ? : renderItems(); + +export const buildSectionActionButton = (key, label, onClick, id) => + buildConfigListActionButton({ key, label, onClick, id }); + +export const renderListOrEmpty = (fields, actionLabel, renderItems) => + renderConfigListOrEmpty({ fields, actionLabel, renderItems }); diff --git a/client/src/components/layout-form-row/config-list-empty-state.component.jsx b/client/src/components/layout-form-row/config-list-empty-state.component.jsx new file mode 100644 index 000000000..fc0f41570 --- /dev/null +++ b/client/src/components/layout-form-row/config-list-empty-state.component.jsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; + +export default function ConfigListEmptyState({ actionLabel, minHeight = 96 }) { + const { t } = useTranslation(); + + return ( +
+ {t("general.labels.click_to_begin", { action: actionLabel })} +
+ ); +} diff --git a/client/src/components/layout-form-row/inline-form-row-title.utils.js b/client/src/components/layout-form-row/inline-form-row-title.utils.js new file mode 100644 index 000000000..70c0e8dd2 --- /dev/null +++ b/client/src/components/layout-form-row/inline-form-row-title.utils.js @@ -0,0 +1,89 @@ +import { UnorderedListOutlined } from "@ant-design/icons"; + +export const inlineFormRowTitleStyles = Object.freeze({ + input: Object.freeze({ + background: "transparent", + border: "none", + borderRadius: 0, + boxShadow: "none", + paddingInline: 0, + paddingBlock: 0, + lineHeight: 1.35, + flex: "1 1 auto", + minWidth: 0, + width: "100%" + }), + row: Object.freeze({ + display: "flex", + gap: 6, + flexWrap: "wrap", + alignItems: "center", + width: "100%", + paddingInline: 4 + }), + group: Object.freeze({ + display: "flex", + alignItems: "center", + gap: 8, + paddingInline: 8, + paddingBlock: 4, + borderRadius: 10, + border: "1px solid var(--imex-form-title-group-border)", + background: "var(--imex-form-title-group-bg)", + minWidth: 0, + flex: "1 1 0" + }), + label: Object.freeze({ + color: "var(--ant-color-text-secondary)", + fontSize: 12, + fontWeight: 600, + lineHeight: 1, + whiteSpace: "nowrap", + paddingInline: 6, + paddingBlock: 3, + borderRadius: 999, + border: "1px solid var(--imex-form-title-label-border)", + background: "var(--imex-form-title-label-bg)" + }), + handle: Object.freeze({ + color: "var(--ant-color-text-tertiary)", + fontSize: 14, + flex: "0 0 auto", + marginRight: 2 + }), + separator: Object.freeze({ + width: 1, + height: 16, + background: "color-mix(in srgb, var(--imex-form-surface-border) 58%, transparent)", + borderRadius: 999, + flex: "0 0 auto", + marginInline: 2 + }), + text: Object.freeze({ + whiteSpace: "nowrap", + fontWeight: 500, + fontSize: "var(--ant-font-size-lg)", + lineHeight: 1.2 + }) +}); + +export const INLINE_TITLE_INPUT_STYLE = inlineFormRowTitleStyles.input; +export const INLINE_TITLE_ROW_STYLE = inlineFormRowTitleStyles.row; +export const INLINE_TITLE_GROUP_STYLE = inlineFormRowTitleStyles.group; +export const InlineTitleListIcon = UnorderedListOutlined; +export const INLINE_TITLE_SWITCH_GROUP_STYLE = Object.freeze({ + ...inlineFormRowTitleStyles.group, + flex: "0 0 auto" +}); +export const INLINE_TITLE_LABEL_STYLE = inlineFormRowTitleStyles.label; +export const INLINE_TITLE_HANDLE_STYLE = inlineFormRowTitleStyles.handle; +export const INLINE_TITLE_SEPARATOR_STYLE = inlineFormRowTitleStyles.separator; +export const INLINE_TITLE_TEXT_STYLE = inlineFormRowTitleStyles.text; + +export const INLINE_FORM_ROW_WRAP_TITLE_STYLES = Object.freeze({ + title: Object.freeze({ + whiteSpace: "normal", + overflow: "visible", + textOverflow: "unset" + }) +}); diff --git a/client/src/components/layout-form-row/inline-validated-form-row.component.jsx b/client/src/components/layout-form-row/inline-validated-form-row.component.jsx new file mode 100644 index 000000000..70910856b --- /dev/null +++ b/client/src/components/layout-form-row/inline-validated-form-row.component.jsx @@ -0,0 +1,47 @@ +import { Form } from "antd"; +import LayoutFormRow from "./layout-form-row.component"; + +export default function InlineValidatedFormRow({ actions, errorNames = [], extraErrors = [], form, ...layoutFormRowProps }) { + const normalizedErrorNames = Array.isArray(errorNames) ? errorNames : [errorNames]; + const normalizedExtraErrors = Array.isArray(extraErrors) ? extraErrors.filter(Boolean) : [extraErrors].filter(Boolean); + + return ( + + {() => { + const fieldErrors = normalizedErrorNames.flatMap((name) => form?.getFieldError?.(name) || []); + const errors = [...new Set([...fieldErrors, ...normalizedExtraErrors])]; + const resolvedClassName = [ + layoutFormRowProps.className, + errors.length > 0 ? "imex-form-row--error" : null + ] + .filter(Boolean) + .join(" "); + + const normalizedActions = Array.isArray(actions) ? actions.filter(Boolean) : [actions].filter(Boolean); + const resolvedActions = + errors.length > 0 + ? [ +
0 ? 8 : 0, + width: "100%", + textAlign: "left" + }} + > + + {normalizedActions.length > 0 ?
{normalizedActions}
: null} +
+ ] + : normalizedActions.length > 0 + ? normalizedActions + : undefined; + + return ; + }} +
+ ); +} diff --git a/client/src/components/layout-form-row/layout-form-row.component.jsx b/client/src/components/layout-form-row/layout-form-row.component.jsx index 22343a790..ade2cbaab 100644 --- a/client/src/components/layout-form-row/layout-form-row.component.jsx +++ b/client/src/components/layout-form-row/layout-form-row.component.jsx @@ -1,5 +1,6 @@ import { Card, Col, Row } from "antd"; import { Children, isValidElement } from "react"; +import { INLINE_FORM_ROW_WRAP_TITLE_STYLES } from "./inline-form-row-title.utils.js"; import "./layout-form-row.styles.scss"; export default function LayoutFormRow({ @@ -7,32 +8,45 @@ export default function LayoutFormRow({ children, grow = false, noDivider = false, - gutter = [16, 16], // Responsive gutter: horizontal, vertical + titleOnly = false, + wrapTitle = false, + gutter, rowProps, // Optional overrides if you ever need per-section customization surface = true, surfaceBg, surfaceHeaderBg, + surfaceBorderColor, ...cardProps }) { const items = Children.toArray(children).filter(Boolean); - if (items.length === 0) return null; + const isCompactRow = noDivider; const title = !noDivider && header ? header : undefined; + const resolvedTitle = cardProps.title ?? title; + const isHeaderOnly = titleOnly || items.length === 0; + const hideBody = isHeaderOnly; + + if (items.length === 0 && !resolvedTitle) return null; + const resolvedGutter = gutter ?? [16, isCompactRow ? 8 : 16]; const bg = surfaceBg ?? (surface ? "var(--imex-form-surface)" : undefined); const headBg = surfaceHeaderBg ?? (surface ? "var(--imex-form-surface-head)" : undefined); + const borderColor = surfaceBorderColor ?? (surface ? "var(--imex-form-surface-border)" : undefined); const mergedStyles = mergeSemanticStyles( { + ...(wrapTitle ? INLINE_FORM_ROW_WRAP_TITLE_STYLES : null), header: { - paddingInline: 16, - background: headBg + paddingInline: isHeaderOnly ? 8 : isCompactRow ? 12 : 16, + background: headBg, + borderBottomColor: borderColor }, body: { - padding: 16, + padding: hideBody ? 0 : isCompactRow ? 12 : 16, + display: hideBody ? "none" : undefined, background: bg } }, @@ -40,28 +54,12 @@ export default function LayoutFormRow({ ); const baseCardStyle = { - marginBottom: ".8rem", + marginBottom: isHeaderOnly ? "0" : isCompactRow ? "8px" : ".8rem", ...(bg ? { background: bg } : null), // ensures the “circled area” is tinted + ...(borderColor ? { borderColor } : null), ...cardProps.style }; - // single child => just render it - if (items.length === 1) { - return ( - - {items[0]} - - ); - } - const count = items.length; // Modern responsive strategy leveraging Ant Design 6: @@ -125,20 +123,32 @@ export default function LayoutFormRow({ return ( - - {items.map((child, idx) => ( - - {child} - + {!isHeaderOnly && + (items.length === 1 ? ( + items[0] + ) : ( + + {items.map((child, idx) => ( + + {child} + + ))} + ))} - ); } @@ -152,6 +162,7 @@ function mergeSemanticStyles(defaults, userStyles) { return { ...defaults, ...computed, + title: { ...(defaults.title || {}), ...(computed.title || {}) }, header: { ...defaults.header, ...(computed.header || {}) }, body: { ...defaults.body, ...(computed.body || {}) } }; @@ -161,6 +172,7 @@ function mergeSemanticStyles(defaults, userStyles) { return { ...defaults, ...userStyles, + title: { ...(defaults.title || {}), ...(userStyles.title || {}) }, header: { ...defaults.header, ...(userStyles.header || {}) }, body: { ...defaults.body, ...(userStyles.body || {}) } }; diff --git a/client/src/components/layout-form-row/layout-form-row.styles.scss b/client/src/components/layout-form-row/layout-form-row.styles.scss index 264ae9760..2c95c7973 100644 --- a/client/src/components/layout-form-row/layout-form-row.styles.scss +++ b/client/src/components/layout-form-row/layout-form-row.styles.scss @@ -13,6 +13,12 @@ --imex-form-surface: #fafafa; /* subtle contrast vs white page */ --imex-form-surface-head: #f5f5f5; /* header strip */ --imex-form-surface-border: #d9d9d9; /* matches AntD-ish border */ + --imex-form-title-input-bg: rgba(255, 255, 255, 0.96); + --imex-form-title-input-border: rgba(0, 0, 0, 0.08); + --imex-form-title-group-bg: rgba(255, 255, 255, 0.72); + --imex-form-title-group-border: rgba(0, 0, 0, 0.08); + --imex-form-title-label-bg: rgba(0, 0, 0, 0.04); + --imex-form-title-label-border: rgba(0, 0, 0, 0.06); } /* Pick the selector that matches your app and remove the rest */ @@ -20,6 +26,12 @@ html[data-theme="dark"] { --imex-form-surface: rgba(255, 255, 255, 0.01); /* subtle lift off page bg */ --imex-form-surface-head: rgba(255, 255, 255, 0.06); /* slightly stronger for header strip */ --imex-form-surface-border: rgba(5, 5, 5, 0.12); + --imex-form-title-input-bg: rgba(255, 255, 255, 0.12); + --imex-form-title-input-border: rgba(255, 255, 255, 0.2); + --imex-form-title-group-bg: rgba(255, 255, 255, 0.08); + --imex-form-title-group-border: rgba(255, 255, 255, 0.16); + --imex-form-title-label-bg: rgba(255, 255, 255, 0.06); + --imex-form-title-label-border: rgba(255, 255, 255, 0.12); } .imex-form-row { @@ -38,18 +50,111 @@ html[data-theme="dark"] { border-color: var(--imex-form-surface-border); } + &.imex-form-row--error.ant-card { + border-color: var(--ant-color-error); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ant-color-error) 24%, transparent); + } + .ant-card-head { background: var(--imex-form-surface-head); border-bottom-color: var(--imex-form-surface-border); } + &.imex-form-row--error { + .ant-card-head, + .ant-card-actions { + border-color: color-mix(in srgb, var(--ant-color-error) 34%, var(--imex-form-surface-border)); + } + } + + &.imex-form-row--compact { + .ant-card-head { + min-height: 40px; + } + + .ant-card-head-title, + .ant-card-extra { + padding-block: 2px; + } + + .ant-form-item { + margin-bottom: 12px; + } + } + + &.imex-form-row--title-only { + .ant-card-head { + min-height: auto; + padding-inline: 6px; + padding-block: 0; + border-radius: inherit; + } + + .ant-card-head-wrapper { + gap: 2px; + align-items: center; + } + + .ant-card-head-title, + .ant-card-extra { + padding-block: 0; + display: flex; + align-items: center; + } + + .ant-card-head-title { + white-space: normal; + overflow: visible; + text-overflow: unset; + font-size: var(--ant-font-size); + line-height: 1.1; + padding-inline: 4px; + } + + .ant-card-body { + display: none; + padding: 0; + } + + .ant-input, + .ant-input-number, + .ant-input-affix-wrapper, + .ant-select-selector, + .ant-picker { + background: var(--imex-form-title-input-bg); + border-color: var(--imex-form-title-input-border); + } + + .ant-input-number-input { + background: transparent; + } + } + .ant-card-body { background: var(--imex-form-surface); } + .ant-card-actions { + background: var(--imex-form-surface-head); + border-top-color: var(--imex-form-surface-border); + } + + .ant-card-actions > li { + margin: 10px 0; + padding-inline: 12px; + } + + .ant-card-actions .ant-btn { + width: 100%; + } + + .ant-form-item:last-child { + margin-bottom: 4px; + } + /* Optional: tighter spacing on phones for better space usage */ @media (max-width: 575px) { - .ant-card-head { + &:not(.imex-form-row--title-only) .ant-card-head { padding-inline: 12px; padding-block: 12px; } @@ -70,6 +175,14 @@ html[data-theme="dark"] { width: 100%; } + .ant-form-item:has(.imex-form-row--compact) { + margin-bottom: 8px; + } + + .ant-form-item:has(.imex-form-row--title-only) { + margin-bottom: 4px; + } + /* Better form item spacing on mobile */ @media (max-width: 575px) { .ant-form-item { @@ -77,3 +190,24 @@ html[data-theme="dark"] { } } } + +.imex-form-row-empty-state { + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + text-align: center; + color: var(--ant-color-text-description); + font-size: var(--ant-font-size); + line-height: 1.5; +} + +.imex-inline-form-row-errors { + color: var(--ant-color-error); + + .ant-form-item-explain, + .ant-form-item-explain-error, + .ant-form-item-additional { + color: var(--ant-color-error); + } +} diff --git a/client/src/components/parts-order-modal/parts-order-modal.component.jsx b/client/src/components/parts-order-modal/parts-order-modal.component.jsx index bfb0cb1df..3766b2e7b 100644 --- a/client/src/components/parts-order-modal/parts-order-modal.component.jsx +++ b/client/src/components/parts-order-modal/parts-order-modal.component.jsx @@ -1,12 +1,13 @@ import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; -import { Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd"; +import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; +import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component"; import PartsOrderModalPriceChange from "./parts-order-modal-price-change.component"; @@ -50,6 +51,7 @@ export function PartsOrderModalComponent({ }); const { t } = useTranslation(); + const partsOrderLines = Form.useWatch(["parts_order_lines", "data"], form) || []; const handleClick = ({ item }) => { form.setFieldsValue({ comments: item.props.value }); }; @@ -128,10 +130,38 @@ export function PartsOrderModalComponent({ {(fields, { remove, move }) => { return (
- {fields.map((field, index) => ( - -
- + {fields.map((field, index) => { + const partsOrderLine = partsOrderLines[field.name] || {}; + + return ( + + +
-
- ))} + + ); + })}
); }} diff --git a/client/src/components/parts-receive-modal/parts-receive-modal.component.jsx b/client/src/components/parts-receive-modal/parts-receive-modal.component.jsx index 723ccb9db..c4fdd1bd0 100644 --- a/client/src/components/parts-receive-modal/parts-receive-modal.component.jsx +++ b/client/src/components/parts-receive-modal/parts-receive-modal.component.jsx @@ -1,10 +1,11 @@ import { DeleteFilled } from "@ant-design/icons"; -import { Form, Input, InputNumber, Select, Typography } from "antd"; +import { Button, Form, Input, InputNumber, Select, Space, Typography } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; +import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; const mapStateToProps = createStructuredSelector({ @@ -15,6 +16,7 @@ export default connect(mapStateToProps, null)(PartsReceiveModalComponent); export function PartsReceiveModalComponent({ bodyshop, form }) { const { t } = useTranslation(); + const partsOrderLines = Form.useWatch(["partsorderlines"], form) || []; return (
@@ -42,16 +44,43 @@ export function PartsReceiveModalComponent({ bodyshop, form }) { {(fields, { remove, move }) => { return (
- {fields.map((field, index) => ( - -
+ {fields.map((field, index) => { + const partsOrderLine = partsOrderLines[field.name] || {}; + + return ( + - + + diff --git a/client/src/components/shop-employees/shop-employees-form.component.jsx b/client/src/components/shop-employees/shop-employees-form.component.jsx index 59a5b7e9f..5aed82b8e 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.jsx @@ -1,11 +1,10 @@ import { DeleteFilled } from "@ant-design/icons"; import { useApolloClient, useMutation, useQuery } from "@apollo/client/react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; -import { Button, Card, Form, Input, InputNumber, Select, Switch } from "antd"; +import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd"; import ResponsiveTable from "../responsive-table/responsive-table.component"; -import { useForm } from "antd/es/form/Form"; import queryString from "query-string"; -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; @@ -26,9 +25,24 @@ import { DateFormatter } from "../../utils/DateFormatter"; import dayjs from "../../utils/day"; import AlertComponent from "../alert/alert.component"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx"; +import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; +import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx"; +import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import { + INLINE_TITLE_GROUP_STYLE, + INLINE_TITLE_HANDLE_STYLE, + INLINE_TITLE_INPUT_STYLE, + INLINE_TITLE_LABEL_STYLE, + INLINE_TITLE_ROW_STYLE, + INLINE_TITLE_SEPARATOR_STYLE, + INLINE_TITLE_SWITCH_GROUP_STYLE, + INLINE_TITLE_TEXT_STYLE, + InlineTitleListIcon +} from "../layout-form-row/inline-form-row-title.utils.js"; import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component"; +import FormItemEmail from "../form-items-formatted/email-form-item.component.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -37,19 +51,37 @@ const mapDispatchToProps = () => ({ //setUserLanguage: language => dispatch(setUserLanguage(language)) }); -export function ShopEmployeesFormComponent({ bodyshop }) { +export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) { const { t } = useTranslation(); - const [form] = useForm(); + const [internalIsDirty, setInternalIsDirty] = useState(false); + const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty; + const employeeNumber = Form.useWatch("employee_number", form); + const firstName = Form.useWatch("first_name", form); + const lastName = Form.useWatch("last_name", form); + const employeeOptionsColProps = { + xs: 24, + sm: 12, + md: 12, + lg: 8, + xl: 8, + xxl: 8 + }; const history = useNavigate(); const search = queryString.parse(useLocation().search); const [deleteVacation] = useMutation(DELETE_VACATION); - const { error, data } = useQuery(QUERY_EMPLOYEE_BY_ID, { + const { error, data, refetch } = useQuery(QUERY_EMPLOYEE_BY_ID, { variables: { id: search.employeeId }, skip: !search.employeeId || search.employeeId === "new", fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); const notification = useNotification(); + const isNewEmployee = search.employeeId === "new"; + const currentEmployeeData = data?.employees_by_pk?.id === search.employeeId ? data.employees_by_pk : null; + const employeeTitleName = [firstName, lastName].filter(Boolean).join(" ").trim(); + const employeeCardTitle = + [employeeNumber, employeeTitleName].filter(Boolean).join(" - ") || + (isNewEmployee ? t("employees.actions.new") : t("bodyshop.labels.employees")); const { treatments: { Enhanced_Payroll } @@ -59,13 +91,46 @@ export function ShopEmployeesFormComponent({ bodyshop }) { splitKey: bodyshop.imexshopid }); + const updateDirtyState = useCallback( + (nextDirtyState) => { + setInternalIsDirty(nextDirtyState); + onDirtyChange?.(nextDirtyState); + }, + [onDirtyChange] + ); + const client = useApolloClient(); - useEffect(() => { - if (data && data.employees_by_pk) form.setFieldsValue(data.employees_by_pk); - else { - form.resetFields(); + const clearEmployeeFormMeta = useCallback(() => { + const fieldMeta = form.getFieldsError().map(({ name }) => ({ + name, + touched: false, + validating: false, + errors: [], + warnings: [] + })); + + if (fieldMeta.length > 0) { + form.setFields(fieldMeta); } - }, [form, data, search.employeeId]); + + updateDirtyState(false); + }, [form, updateDirtyState]); + + const resetEmployeeFormToCurrentData = useCallback(() => { + form.resetFields(); + + if (currentEmployeeData) { + form.setFieldsValue(currentEmployeeData); + } + + window.requestAnimationFrame(() => { + clearEmployeeFormMeta(); + }); + }, [clearEmployeeFormMeta, currentEmployeeData, form]); + + useEffect(() => { + resetEmployeeFormToCurrentData(); + }, [resetEmployeeFormToCurrentData, search.employeeId]); const [updateEmployee] = useMutation(UPDATE_EMPLOYEE); const [insertEmployees] = useMutation(INSERT_EMPLOYEES); @@ -85,6 +150,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) { } }) .then(() => { + updateDirtyState(false); + void refetch(); notification.success({ title: t("employees.successes.save") }); @@ -104,6 +171,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) { variables: { employees: [{ ...values, shopid: bodyshop.id }] }, refetchQueries: ["QUERY_EMPLOYEES"] }).then((r) => { + updateDirtyState(false); search.employeeId = r.data.insert_employees.returning[0].id; history({ search: queryString.stringify(search) }); notification.success({ @@ -141,6 +209,8 @@ export function ShopEmployeesFormComponent({ bodyshop }) { key: "actions", render: (text, record) => ( } > -
- - - - - - - - ({ - async validator(rule, value) { - if (value) { - const response = await client.query({ - query: CHECK_EMPLOYEE_NUMBER, - variables: { - employeenumber: value - } - }); - - if (response.data.employees_aggregate.aggregate.count === 0) { - return Promise.resolve(); - } else if ( - response.data.employees_aggregate.nodes.length === 1 && - response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id") - ) { - return Promise.resolve(); - } - return Promise.reject(t("employees.validation.unique_employee_number")); - } else { - return Promise.resolve(); + { + updateDirtyState(form.isFieldsTouched()); + }} + > + + +
+ {t("bodyshop.labels.employee_options")} +
+
+
+
+
{t("employees.labels.active")}
+ + + +
+
+
+
{t("employees.fields.flat_rate")}
+ + + +
+
+
+ } + wrapTitle + > + + + - - - - - - - - - - - - - - - - - - - - ({ - async validator(rule, value) { - const user_email = getFieldValue("user_email"); - - if (user_email && value) { - const response = await client.query({ - query: QUERY_USERS_BY_EMAIL, - variables: { - email: user_email - } - }); - - if (response.data.users.length === 1) { - return Promise.resolve(); - } - return Promise.reject(t("bodyshop.validation.useremailmustexist")); - } else { - return Promise.resolve(); + ]} + > + + + + + - - - - - + ]} + > + + + + + ({ + async validator(rule, value) { + if (value) { + const response = await client.query({ + query: CHECK_EMPLOYEE_NUMBER, + variables: { + employeenumber: value + } + }); + + if (response.data.employees_aggregate.aggregate.count === 0) { + return Promise.resolve(); + } else if ( + response.data.employees_aggregate.nodes.length === 1 && + response.data.employees_aggregate.nodes[0].id === form.getFieldValue("id") + ) { + return Promise.resolve(); + } + return Promise.reject(t("employees.validation.unique_employee_number")); + } else { + return Promise.resolve(); + } + } + }) + ]} + > + + + + + + + + + + + + + + + + + + + + ({ + async validator(rule, value) { + const user_email = getFieldValue("user_email"); + + if (user_email && value) { + const response = await client.query({ + query: QUERY_USERS_BY_EMAIL, + variables: { + email: user_email + } + }); + + if (response.data.users.length === 1) { + return Promise.resolve(); + } + return Promise.reject(t("bodyshop.validation.useremailmustexist")); + } else { + return Promise.resolve(); + } + } + }) + ]} + > + + + + + + + + + {(fields, { add, remove, move }) => { return ( -
- {fields.map((field, index) => ( - - - - ({ + value: c.name, + label: c.name + }))) + ]} + style={{ width: "100%" }} + styles={{ + selector: INLINE_TITLE_INPUT_STYLE + }} + /> + +
+
+ } + wrapTitle + extra={ + +
+ ); }} - } - columns={columns} - mobileColumnKeys={["start", "length", "actions"]} - rowKey={"id"} - dataSource={data?.employees_by_pk?.employee_vacations ?? []} - /> + + ]} + > + {(data?.employees_by_pk?.employee_vacations ?? []).length === 0 ? ( + + ) : ( +
+ +
+ )} +
); } diff --git a/client/src/components/shop-employees/shop-employees-list.component.jsx b/client/src/components/shop-employees/shop-employees-list.component.jsx index e9082ac05..45e434b5a 100644 --- a/client/src/components/shop-employees/shop-employees-list.component.jsx +++ b/client/src/components/shop-employees/shop-employees-list.component.jsx @@ -4,9 +4,16 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { alphaSort } from "../../utils/sorters"; +import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx"; +import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import ResponsiveTable from "../responsive-table/responsive-table.component"; -export default function ShopEmployeesListComponent({ loading, employees }) { +export default function ShopEmployeesListComponent({ + loading, + employees, + onRequestEmployeeChange, + selectedEmployeeId +}) { const { t } = useTranslation(); const history = useNavigate(); const search = queryString.parse(useLocation().search); @@ -16,13 +23,33 @@ export default function ShopEmployeesListComponent({ loading, employees }) { filteredInfo: { text: "" } }); + const navigateToEmployee = (employeeId) => { + if (onRequestEmployeeChange) { + onRequestEmployeeChange(employeeId); + return; + } + + history({ + search: queryString.stringify({ + ...search, + employeeId + }) + }); + }; + + const clearEmployeeSelection = () => { + const { employeeId, ...nextSearch } = search; + void employeeId; + history({ + search: queryString.stringify(nextSearch) + }); + }; + const handleOnRowClick = (record) => { if (record) { - search.employeeId = record.id; - history({ search: queryString.stringify(search) }); + navigateToEmployee(record.id); } else { - delete search.employeeId; - history({ search: queryString.stringify(search) }); + clearEmployeeSelection(); } }; const handleTableChange = (pagination, filters, sorter) => { @@ -30,7 +57,7 @@ export default function ShopEmployeesListComponent({ loading, employees }) { }; const columns = [ { - title: t("employees.fields.employee_number"), + title: t("employees.labels.employee_number_short"), dataIndex: "employee_number", key: "employee_number", sorter: (a, b) => alphaSort(a.employee_number, b.employee_number), @@ -89,44 +116,39 @@ export default function ShopEmployeesListComponent({ loading, employees }) { } ]; return ( -
- { - return ( - - ); - }} - loading={loading} - pagination={{ placement: "top" }} - columns={columns} - mobileColumnKeys={["employee_number", "employee_name", "active"]} - rowKey="id" - dataSource={employees} - rowSelection={{ - onSelect: (props) => { - search.employeeId = props.id; - history({ search: queryString.stringify(search) }); - }, - type: "radio", - selectedRowKeys: [search.employeeId] - }} - onChange={handleTableChange} - onRow={(record) => { - return { - onClick: () => { - handleOnRowClick(record); - } - }; - }} - /> -
+ navigateToEmployee("new")}> + {t("employees.actions.new")} + + ]} + > + {employees.length === 0 ? ( + + ) : ( + navigateToEmployee(props.id), + type: "radio", + selectedRowKeys: [selectedEmployeeId || search.employeeId] + }} + onChange={handleTableChange} + onRow={(record) => { + return { + onClick: () => { + handleOnRowClick(record); + } + }; + }} + /> + )} + ); } diff --git a/client/src/components/shop-employees/shop-employees.container.jsx b/client/src/components/shop-employees/shop-employees.container.jsx index 8b22b87a4..3582ee40c 100644 --- a/client/src/components/shop-employees/shop-employees.container.jsx +++ b/client/src/components/shop-employees/shop-employees.container.jsx @@ -1,29 +1,101 @@ +import { Drawer, Form, Grid } from "antd"; import { useQuery } from "@apollo/client/react"; +import queryString from "query-string"; import { connect } from "react-redux"; +import { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import { QUERY_EMPLOYEES } from "../../graphql/employees.queries"; +import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx"; import AlertComponent from "../alert/alert.component"; import ShopEmployeesFormComponent from "./shop-employees-form.component"; import ShopEmployeesListComponent from "./shop-employees-list.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; +import "./shop-employees.styles.scss"; const mapStateToProps = createStructuredSelector({}); function ShopEmployeesContainer() { + const [form] = Form.useForm(); + const [isEmployeeFormDirty, setIsEmployeeFormDirty] = useState(false); + const location = useLocation(); + const navigate = useNavigate(); + const search = queryString.parse(location.search); const { loading, error, data } = useQuery(QUERY_EMPLOYEES, { fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); + const screens = Grid.useBreakpoint(); + const hasSelectedEmployee = Boolean(search.employeeId); + + const bpoints = { + xs: "100%", + sm: "100%", + md: "92%", + lg: "80%", + xl: "80%", + xxl: "80%" + }; + + let drawerPercentage = "100%"; + if (screens.xxl) drawerPercentage = bpoints.xxl; + else if (screens.xl) drawerPercentage = bpoints.xl; + else if (screens.lg) drawerPercentage = bpoints.lg; + else if (screens.md) drawerPercentage = bpoints.md; + else if (screens.sm) drawerPercentage = bpoints.sm; + else if (screens.xs) drawerPercentage = bpoints.xs; + + const hasDirtyEmployeeForm = Boolean(search.employeeId) && (isEmployeeFormDirty || form.isFieldsTouched()); + const confirmCloseDirtyEmployee = useConfirmDirtyFormNavigation(hasDirtyEmployeeForm); + + const navigateToEmployee = (employeeId) => { + if (employeeId === search.employeeId) return; + if (!confirmCloseDirtyEmployee()) return; + + const nextSearch = { ...search, employeeId }; + setIsEmployeeFormDirty(false); + navigate({ + search: queryString.stringify(nextSearch) + }); + }; + + const handleDrawerClose = () => { + if (!confirmCloseDirtyEmployee()) return; + + const nextSearch = { ...search }; + delete nextSearch.employeeId; + setIsEmployeeFormDirty(false); + navigate({ + search: queryString.stringify(nextSearch) + }); + }; if (error) return ; return ( -
- - - - -
+ +
+
+ +
+
+ + {hasSelectedEmployee ? ( + + ) : null} + +
); } diff --git a/client/src/components/shop-employees/shop-employees.styles.scss b/client/src/components/shop-employees/shop-employees.styles.scss new file mode 100644 index 000000000..69ee5c1d0 --- /dev/null +++ b/client/src/components/shop-employees/shop-employees.styles.scss @@ -0,0 +1,7 @@ +.shop-employees-layout { + min-width: 0; +} + +.shop-employees-layout__list { + min-width: 0; +} diff --git a/client/src/components/shop-info/shop-info.color.utils.js b/client/src/components/shop-info/shop-info.color.utils.js new file mode 100644 index 000000000..d577aee25 --- /dev/null +++ b/client/src/components/shop-info/shop-info.color.utils.js @@ -0,0 +1,304 @@ +/** + * Default translucent card color used for tinting card surfaces when no specific color is provided. + * @type {{r: number, g: number, b: number, a: number}} + */ +export const DEFAULT_TRANSLUCENT_CARD_COLOR = { + r: 22, + g: 119, + b: 255, + a: 0.5 +}; + +/** + * Rounds a color channel value to two decimal places. + * @param value + * @returns {number} + */ +const roundColorChannel = (value) => Math.round(value * 100) / 100; + +/** + * Rounds a tint percentage value to two decimal places. + * @param value + * @returns {number} + */ +const roundTintPercentage = (value) => Math.round(value * 100) / 100; + +/** + * Clamps an alpha value to the range [0, 1] and rounds it to two decimal places. + * @param value + * @returns {number} + */ +const clampAlpha = (value) => { + const numericValue = Number(value); + + if (!Number.isFinite(numericValue)) return 1; + if (numericValue <= 0) return 0; + if (numericValue >= 1) return 1; + + return numericValue; +}; + +/** + * Converts an RGB color object to a hexadecimal color string. + * @param param0 + * @param param0.r + * @param param0.g + * @param param0.b + * @returns {`#${string}`} + */ +const rgbToHex = ({ r, g, b }) => + `#${[r, g, b].map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`; + +/** + * Converts an RGB color object to an HSL color object. + * @param param0 + * @param param0.r + * @param param0.g + * @param param0.b + * @param param0.a + * @returns {{h: number, s: number, l: number, a: number}|{h: number, s: number, l: number, a: number}} + */ +const rgbToHsl = ({ r, g, b, a = 1 }) => { + const red = r / 255; + const green = g / 255; + const blue = b / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + const lightness = (max + min) / 2; + + if (delta === 0) { + return { h: 0, s: 0, l: roundColorChannel(lightness), a }; + } + + const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min); + let hue; + + switch (max) { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0); + break; + case green: + hue = (blue - red) / delta + 2; + break; + default: + hue = (red - green) / delta + 4; + break; + } + + return { + h: roundColorChannel(hue * 60), + s: roundColorChannel(saturation), + l: roundColorChannel(lightness), + a + }; +}; + +/** + * Converts an RGB color object to an HSV color object. + * @param param0 + * @param param0.r + * @param param0.g + * @param param0.b + * @param param0.a + * @returns {{h: number, s: number, v: number, a: number}} + */ +const rgbToHsv = ({ r, g, b, a = 1 }) => { + const red = r / 255; + const green = g / 255; + const blue = b / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + const saturation = max === 0 ? 0 : delta / max; + let hue = 0; + + if (delta !== 0) { + switch (max) { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0); + break; + case green: + hue = (blue - red) / delta + 2; + break; + default: + hue = (red - green) / delta + 4; + break; + } + } + + return { + h: roundColorChannel(hue * 60), + s: roundColorChannel(saturation), + v: roundColorChannel(max), + a + }; +}; + +/** + * Builds a comprehensive color value object for a color picker component based on an input RGB color object. + * @param rgb + * @returns {{hex: `#${string}`, rgb: *, hsl: {h: number, s: number, l: number, a: number}, hsv: {h: number, s: number, v: number, a: number}, oldHue: number, source: string}} + */ +const buildPickerColorValue = (rgb) => { + const hsl = rgbToHsl(rgb); + + return { + hex: rgbToHex(rgb), + rgb: { ...rgb }, + hsl, + hsv: rgbToHsv(rgb), + oldHue: hsl.h, + source: "rgb" + }; +}; + +/** + * Default color value object for the color picker component, derived from the default translucent card color. + * @type {{hex: `#${string}`, rgb: *, hsl: {h: number, s: number, l: number, a: number}, hsv: {h: number, s: number, v: number, a: number}, oldHue: number, source: string}} + */ +export const DEFAULT_TRANSLUCENT_PICKER_COLOR = buildPickerColorValue(DEFAULT_TRANSLUCENT_CARD_COLOR); + +/** + * Parses a color string that may be a JSON representation of a color object. If the string is valid JSON and represents + * a color, it returns the parsed object; otherwise, it returns the original string. + * @param color + * @returns {*|string} + */ +const parseJsonColorString = (color) => { + if (typeof color !== "string") return color; + + const trimmedColor = color.trim(); + if (!trimmedColor.startsWith("{") && !trimmedColor.startsWith("[")) return color; + + try { + return JSON.parse(trimmedColor); + } catch { + return color; + } +}; + +/** + * Parses a hexadecimal color string (e.g., "#RRGGBB" or "#RRGGBBAA") and returns an object containing the corresponding + * RGB color value and alpha transparency. Supports both 3/4-digit and 6/8-digit hex formats. + * @param color + * @returns {{colorCssValue: string, alpha: number}|null} + */ +const parseHexColor = (color) => { + if (typeof color !== "string") return null; + + const normalizedHex = color.trim().replace(/^#/, ""); + + if (![3, 4, 6, 8].includes(normalizedHex.length) || /[^0-9a-f]/i.test(normalizedHex)) { + return null; + } + + const expandedHex = + normalizedHex.length <= 4 + ? normalizedHex + .split("") + .map((character) => `${character}${character}`) + .join("") + : normalizedHex; + + const hasAlpha = expandedHex.length === 8; + const red = Number.parseInt(expandedHex.slice(0, 2), 16); + const green = Number.parseInt(expandedHex.slice(2, 4), 16); + const blue = Number.parseInt(expandedHex.slice(4, 6), 16); + const alpha = hasAlpha ? Number.parseInt(expandedHex.slice(6, 8), 16) / 255 : 1; + + return { + colorCssValue: `rgb(${red}, ${green}, ${blue})`, + alpha: clampAlpha(alpha) + }; +}; + +/** + * Parses an RGB or RGBA color string (e.g., "rgb(255, 0, 0)" or "rgba(255, 0, 0, 0.5)") and returns an object + * containing the corresponding RGB color value and alpha transparency. Supports both integer and percentage formats for + * color channels and alpha. + * @param color + * @returns {{colorCssValue: string, alpha: number}|null} + */ +const parseRgbColor = (color) => { + if (typeof color !== "string") return null; + + const rgbMatch = color.trim().match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i); + + if (!rgbMatch) return null; + + const [, red, green, blue, alpha = 1] = rgbMatch; + + return { + colorCssValue: `rgb(${red}, ${green}, ${blue})`, + alpha: clampAlpha(alpha) + }; +}; + +/** + * Normalizes a color input into a consistent descriptor object containing a CSS color value and an alpha transparency + * level. + * @param color + * @returns {{colorCssValue: string, alpha: number}|{colorCssValue: string, alpha: number}|*|{colorCssValue: string, alpha: number}|null} + */ +const getNormalizedColorDescriptor = (color) => { + if (!color) return null; + + const normalizedColor = parseJsonColorString(color); + + if (typeof normalizedColor === "string") { + return ( + parseHexColor(normalizedColor) || + parseRgbColor(normalizedColor) || { + colorCssValue: normalizedColor, + alpha: 1 + } + ); + } + + if (typeof normalizedColor === "object" && normalizedColor.rgb) { + return getNormalizedColorDescriptor(normalizedColor.rgb); + } + + if (typeof normalizedColor === "object" && typeof normalizedColor.hex === "string") { + return getNormalizedColorDescriptor(normalizedColor.hex); + } + + if ( + typeof normalizedColor === "object" && + normalizedColor.r !== undefined && + normalizedColor.g !== undefined && + normalizedColor.b !== undefined + ) { + return { + colorCssValue: `rgb(${normalizedColor.r}, ${normalizedColor.g}, ${normalizedColor.b})`, + alpha: clampAlpha(normalizedColor.a) + }; + } + + return null; +}; + +/** + * Generates CSS styles for tinting card surfaces based on a provided color input. The function normalizes the input + * color, + * @param color + * @returns {{surfaceBg: string, surfaceHeaderBg: string, surfaceBorderColor: string}|{}} + */ +export const getTintedCardSurfaceStyles = (color) => { + const normalizedColor = getNormalizedColorDescriptor(color); + if (!normalizedColor?.colorCssValue) return {}; + + const tintStrength = clampAlpha(normalizedColor.alpha); + if (tintStrength === 0) return {}; + + const backgroundTint = roundTintPercentage(10 * tintStrength); + const headerTint = roundTintPercentage(18 * tintStrength); + const borderTint = roundTintPercentage(30 * tintStrength); + + return { + surfaceBg: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${backgroundTint}%, var(--imex-form-surface))`, + surfaceHeaderBg: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${headerTint}%, var(--imex-form-surface-head))`, + surfaceBorderColor: `color-mix(in srgb, ${normalizedColor.colorCssValue} ${borderTint}%, var(--imex-form-surface-border))` + }; +}; diff --git a/client/src/components/shop-info/shop-info.color.utils.test.js b/client/src/components/shop-info/shop-info.color.utils.test.js new file mode 100644 index 000000000..d39fb810f --- /dev/null +++ b/client/src/components/shop-info/shop-info.color.utils.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { getTintedCardSurfaceStyles } from "./shop-info.color.utils"; + +describe("shop info color utilities", () => { + it("scales card tint intensity with alpha for plain rgba values", () => { + expect( + getTintedCardSurfaceStyles({ + r: 22, + g: 119, + b: 255, + a: 0.5 + }) + ).toEqual({ + surfaceBg: "color-mix(in srgb, rgb(22, 119, 255) 5%, var(--imex-form-surface))", + surfaceHeaderBg: "color-mix(in srgb, rgb(22, 119, 255) 9%, var(--imex-form-surface-head))", + surfaceBorderColor: "color-mix(in srgb, rgb(22, 119, 255) 15%, var(--imex-form-surface-border))" + }); + }); + + it("returns no tint when the selected color alpha is zero", () => { + expect( + getTintedCardSurfaceStyles({ + hex: "#1677ff", + rgb: { + r: 22, + g: 119, + b: 255, + a: 0 + } + }) + ).toEqual({}); + }); + + it("supports legacy JSON-stringified picker values", () => { + expect( + getTintedCardSurfaceStyles( + JSON.stringify({ + rgb: { + r: 255, + g: 0, + b: 0, + a: 0.25 + } + }) + ) + ).toEqual({ + surfaceBg: "color-mix(in srgb, rgb(255, 0, 0) 2.5%, var(--imex-form-surface))", + surfaceHeaderBg: "color-mix(in srgb, rgb(255, 0, 0) 4.5%, var(--imex-form-surface-head))", + surfaceBorderColor: "color-mix(in srgb, rgb(255, 0, 0) 7.5%, var(--imex-form-surface-border))" + }); + }); +}); diff --git a/client/src/components/shop-info/shop-info.component.jsx b/client/src/components/shop-info/shop-info.component.jsx index 8dd04843e..901a14afe 100644 --- a/client/src/components/shop-info/shop-info.component.jsx +++ b/client/src/components/shop-info/shop-info.component.jsx @@ -1,6 +1,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Card, Tabs } from "antd"; import queryString from "query-string"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; @@ -21,6 +22,7 @@ import ShopInfoResponsibilityCenterComponent from "./shop-info.responsibilitycen import ShopInfoRoGuard from "./shop-info.roguard.component"; import ShopInfoROStatusComponent from "./shop-info.rostatus.component"; import ShopInfoSchedulingComponent from "./shop-info.scheduling.component"; +import ShopInfoSectionNavigator from "./shop-info.section-navigator.component.jsx"; import ShopInfoSpeedPrint from "./shop-info.speedprint.component"; import ShopInfoTaskPresets from "./shop-info.task-presets.component"; import ShopInfoIntellipay from "./shop-intellipay-config.component"; @@ -33,7 +35,7 @@ const mapDispatchToProps = () => ({ }); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoComponent); -export function ShopInfoComponent({ bodyshop, form, saveLoading }) { +export function ShopInfoComponent({ bodyshop, form, saveLoading, isDirty }) { const { treatments: { CriticalPartsScanning, Enhanced_Payroll } } = useTreatmentsWithConfig({ @@ -47,6 +49,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) { const history = useNavigate(); const location = useLocation(); const search = queryString.parse(location.search); + const tabsRef = useRef(null); const tabItems = [ { @@ -154,23 +157,35 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) { ] : []) ]; + const activeTabKey = search.subtab || tabItems[0]?.key; + return ( } extra={ - } > - - history({ - search: `?tab=${search.tab}&subtab=${key}` - }) - } - items={tabItems} - /> +
+ + history({ + search: `?tab=${search.tab}&subtab=${key}` + }) + } + items={tabItems} + /> +
); } diff --git a/client/src/components/shop-info/shop-info.consent.component.jsx b/client/src/components/shop-info/shop-info.consent.component.jsx index 28992e594..621095c14 100644 --- a/client/src/components/shop-info/shop-info.consent.component.jsx +++ b/client/src/components/shop-info/shop-info.consent.component.jsx @@ -1,4 +1,4 @@ -import { Card, Typography } from "antd"; +import { Card } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -15,9 +15,8 @@ function ShopInfoConsentComponent({ bodyshop }) { const { t } = useTranslation(); return ( - - {t("settings.title")} - {} + + ); } diff --git a/client/src/components/shop-info/shop-info.container.jsx b/client/src/components/shop-info/shop-info.container.jsx index a43403776..823091b12 100644 --- a/client/src/components/shop-info/shop-info.container.jsx +++ b/client/src/components/shop-info/shop-info.container.jsx @@ -1,6 +1,6 @@ import { useMutation, useQuery } from "@apollo/client/react"; import { Form } from "antd"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { logImEXEvent } from "../../firebase/firebase.utils"; @@ -15,6 +15,7 @@ import { FEATURE_CONFIGS, useFormDataPreservation } from "./useFormDataPreservat export default function ShopInfoContainer() { const [form] = Form.useForm(); const { t } = useTranslation(); + const [isShopInfoDirty, setIsShopInfoDirty] = useState(false); const [saveLoading, setSaveLoading] = useState(false); const [updateBodyshop] = useMutation(UPDATE_SHOP); const { loading, error, data, refetch } = useQuery(QUERY_BODYSHOP, { @@ -33,7 +34,10 @@ export default function ShopInfoContainer() { return acc; }, {}); - const combinedFeatureConfig = combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters); + const combinedFeatureConfig = useMemo( + () => combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters), + [] + ); // Use form data preservation for all shop-info features const { createSubmissionHandler, preserveHiddenFormData } = useFormDataPreservation( @@ -51,7 +55,10 @@ export default function ShopInfoContainer() { }) .then(() => { notification.success({ title: t("bodyshop.successes.save") }); - refetch().then(() => form.resetFields()); + refetch().then(() => { + form.resetFields(); + setIsShopInfoDirty(false); + }); }) .catch((error) => { notification.error({ @@ -66,6 +73,7 @@ export default function ShopInfoContainer() { form.resetFields(); // After reset, re-apply hidden field preservation so values aren't wiped preserveHiddenFormData(); + setIsShopInfoDirty(false); }, [data, form, preserveHiddenFormData]); if (error) return ; @@ -76,6 +84,9 @@ export default function ShopInfoContainer() { layout="vertical" autoComplete="new-password" onFinish={handleFinish} + onValuesChange={() => { + setIsShopInfoDirty(form.isFieldsTouched()); + }} initialValues={ data ? data?.bodyshops?.[0]?.accountingconfig?.ClosingPeriod @@ -99,8 +110,8 @@ export default function ShopInfoContainer() { : null } > - - + + ); } diff --git a/client/src/components/shop-info/shop-info.general.component.jsx b/client/src/components/shop-info/shop-info.general.component.jsx index 00410f0f9..fa3a2c3af 100644 --- a/client/src/components/shop-info/shop-info.general.component.jsx +++ b/client/src/components/shop-info/shop-info.general.component.jsx @@ -1,5 +1,5 @@ import { DeleteFilled } from "@ant-design/icons"; -import { Button, Form, Input, InputNumber, Select, Space, Switch } from "antd"; +import { Button, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -7,10 +7,24 @@ import FeatureWrapper from "../feature-wrapper/feature-wrapper.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import FormItemEmail from "../form-items-formatted/email-form-item.component"; import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component"; +import FormItemUrl from "../form-items-formatted/url-form-item.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; +import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx"; +import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import { + INLINE_TITLE_GROUP_STYLE, + INLINE_TITLE_HANDLE_STYLE, + INLINE_TITLE_INPUT_STYLE, + INLINE_TITLE_LABEL_STYLE, + INLINE_TITLE_ROW_STYLE, + INLINE_TITLE_SEPARATOR_STYLE, + INLINE_TITLE_SWITCH_GROUP_STYLE, + InlineTitleListIcon +} from "../layout-form-row/inline-form-row-title.utils.js"; const timeZonesList = Intl.supportedValuesOf("timeZone"); + const mapStateToProps = createStructuredSelector({}); const mapDispatchToProps = () => ({ //setUserLanguage: language => dispatch(setUserLanguage(language)) @@ -19,6 +33,8 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoGeneral); export function ShopInfoGeneral({ form }) { const { t } = useTranslation(); + const insuranceCompanies = Form.useWatch(["md_ins_cos"], form) || []; + const duplicateInsuranceCompanyIndexes = getDuplicateIndexSetByNormalizedName(insuranceCompanies, "name"); return (
@@ -81,17 +97,17 @@ export function ShopInfoGeneral({ form }) { - + PhoneItemFormatterValidation(getFieldValue, "phone")]} > - + - + - + - + - + - + - + null}> - + +
+ {t("bodyshop.labels.scoreboardsetup")} +
+
+
+
+
{t("bodyshop.fields.scoreboard_setup.ignore_blocked_days")}
+ + + +
+
+
+ } + wrapTitle + > - + - - - - +
- {[ + <> - , - - - , - - - , - - - , - - - , - - - , - ({ - validator(rule, value) { - if (!value && !getFieldValue(["md_hour_split", "paint"])) { - return Promise.resolve(); - } - if (value + getFieldValue(["md_hour_split", "paint"]) === 1) { - return Promise.resolve(); - } - return Promise.reject(t("bodyshop.validation.larsplit")); - } - }) - ]} - > - - , - ({ - validator(rule, value) { - if (!value && !getFieldValue(["md_hour_split", "paint"])) { - return Promise.resolve(); - } - if (value + getFieldValue(["md_hour_split", "prep"]) === 1) { - return Promise.resolve(); - } - return Promise.reject(t("bodyshop.validation.larsplit")); - } - }) - ]} - > - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - , - - - ]} - - - - - - - - - {(fields, { add, remove, move }) => { - return ( -
- {fields.map((field, index) => ( - - - - - - - - - - { - remove(field.name); - }} - /> - - - - - ))} - - - -
- ); - }} -
-
- - - {(fields, { add, remove, move }) => { - return ( -
- {fields.map((field, index) => ( - - - - - - - - - - { - remove(field.name); - }} - /> - - - - - ))} - -
+
- {t("general.actions.add")} - - -
- ); - }} -
-
- - - {(fields, { add, remove, move }) => { - return ( -
- {fields.map((field, index) => ( - - - - +
+
+
+ {t("bodyshop.fields.system_settings.local_media_server.enabled")} +
+ + - - { - remove(field.name); - }} - /> - - - - - ))} - -
+
+
+ } + wrapTitle + > + + + + + + + + + +
+ + + + + + + + + + + +
+
+ } + extra={ + +
+ + ); + }} + + + {(fields, { add, remove, move }) => { + return ( + { + add(); + }) + ]} + > +
+ {renderListOrEmpty(fields, t("bodyshop.actions.add_note_preset"), () => + fields.map((field, index) => { + return ( + + + +
+
{t("bodyshop.fields.noteslabel_short")}
+ + + +
+
+ } + extra={ + + - + ); + }) + )} - ); - }} -
- + + ); + }} + {/*End Insurance Provider Row */} - - - {(fields, { add, remove, move }) => { - return ( + + {(fields, { add, remove, move }) => { + return ( + { + add(); + }) + ]} + >
- {fields.map((field, index) => ( - - - - - - - - - - - - PhoneItemFormatterValidation(getFieldValue, [field.name, "est_ph"]) - ]} - > - - - - - - - { - remove(field.name); - }} - /> - - - - - ))} - - - -
- ); - }} -
-
- - - {(fields, { add, remove, move }) => { - return ( -
- {fields.map((field, index) => ( - - - - - - - - - PhoneItemFormatterValidation(getFieldValue, [field.name, "ins_ph"]) - ]} - > - - - - - - - { - remove(field.name); - }} - /> - - - - - ))} - - - -
- ); - }} -
-
- null}> - - - {(fields, { add, remove, move }) => { - return ( -
- {fields.map((field, index) => ( - - - + fields.map((field, index) => { + return ( + + + +
+
{t("jobs.fields.est_ct_fn_short")}
+ + + +
+
+
+
{t("jobs.fields.est_ct_ln_short")}
+ + + +
+
+
+
{t("jobs.fields.est_co_nm_short")}
+ + + +
+
+ } + wrapTitle + extra={ + +
+
+ ); + }} + + + {(fields, { add, remove, move }) => { + return ( + { + add(); + }) + ]} + > +
+ {renderListOrEmpty(fields, t("bodyshop.actions.add_adjuster"), () => + fields.map((field, index) => { + return ( + + + +
+
{t("jobs.fields.ins_ct_fn_short")}
+ + + +
+
+
+
{t("jobs.fields.ins_ct_ln_short")}
+ + + +
+
+ } + wrapTitle + extra={ + +
+
+ ); + }} +
+ null}> + + {(fields, { add, remove, move }) => { + return ( + { + add(); + } + ) + ]} + > +
+ {renderListOrEmpty(fields, t("bodyshop.actions.add_courtesy_car_rate_preset"), () => + fields.map((field, index) => { + return ( + + + +
+
{t("general.labels.label")}
+ + + +
+
+ } + wrapTitle + extra={ + + -
+ ); + }) + )}
- ); - }} -
-
+ + ); + }} +
- - - {(fields, { add, remove, move }) => { - return ( + + {(fields, { add, remove, move }) => { + return ( + { + add(); + }) + ]} + >
- {fields.map((field, index) => ( - - - - - - - - - - + +
+
+
+
{t("joblines.fields.line_desc")}
+ + + +
+
+ } + wrapTitle + extra={ + + - + ); + }) + )} - ); - }} -
-
- - - {(fields, { add, remove, move }) => { - return ( + + ); + }} + + + {(fields, { add, remove, move }) => { + return ( + { + add(); + } + ) + ]} + >
- {fields.map((field, index) => ( - - - + fields.map((field, index) => { + return ( + + + +
+
{t("general.labels.label")}
+ + + +
+
} - ]} - > - - - + - + ); + }) + )} - ); - }} -
- - - - {(fields, { add, remove, move }) => { - return ( + + ); + }} + + + {(fields, { add, remove, move }) => { + return ( + { + add(); + }) + ]} + >
- {fields.map((field, index) => ( - - - + fields.map((field, index) => { + return ( + + + +
+
{t("general.labels.label")}
+ + + +
+
} - ]} - > - - - + - + ); + }) + )} - ); - }} -
- + + ); + }} + ); } + +function getDuplicateIndexSetByNormalizedName(list, key) { + const indexes = new Set(); + const firstIndexByValue = new Map(); + + (Array.isArray(list) ? list : []).forEach((item, index) => { + const normalizedValue = (item?.[key] ?? "").toString().trim().toLowerCase(); + if (!normalizedValue) return; + + if (firstIndexByValue.has(normalizedValue)) { + indexes.add(firstIndexByValue.get(normalizedValue)); + indexes.add(index); + return; + } + + firstIndexByValue.set(normalizedValue, index); + }); + + return indexes; +} diff --git a/client/src/components/shop-info/shop-info.intake.component.jsx b/client/src/components/shop-info/shop-info.intake.component.jsx index 174a6ddf0..9a8b6bc12 100644 --- a/client/src/components/shop-info/shop-info.intake.component.jsx +++ b/client/src/components/shop-info/shop-info.intake.component.jsx @@ -5,7 +5,19 @@ import styled from "styled-components"; import { TemplateList } from "../../utils/TemplateConstants"; import ConfigFormTypes from "../config-form-components/config-form-types"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; +import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx"; +import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import { + INLINE_TITLE_GROUP_STYLE, + INLINE_TITLE_HANDLE_STYLE, + INLINE_TITLE_INPUT_STYLE, + INLINE_TITLE_LABEL_STYLE, + INLINE_TITLE_ROW_STYLE, + INLINE_TITLE_SEPARATOR_STYLE, + INLINE_TITLE_SWITCH_GROUP_STYLE, + InlineTitleListIcon +} from "../layout-form-row/inline-form-row-title.utils.js"; const SelectorDiv = styled.div` .ant-form-item .ant-select { @@ -19,306 +31,386 @@ export default function ShopInfoIntakeChecklistComponent({ form }) { const TemplateListGenerated = TemplateList(); return (
- - - {(fields, { add, remove, move }) => { - return ( -
- {fields.map((field, index) => ( - - - - - - - - - - - {() => { - if (form.getFieldValue(["intakechecklist", "form", index, "type"]) !== "slider") return null; - return ( - <> - - - - - - - - ); - }} - - - - - - { - remove(field.name); - }} - /> - - - - - ))} - - - -
- ); - }} -
-
- - ({ + value: TemplateListGenerated[i].key, + label: TemplateListGenerated[i].title + }))} + /> + + + + +
+
+
+
{t("jobs.fields.intake.required")}
+ + + +
+
} - ]} - > - - - - + - + ); + }) + )} - ); - }} - - - - - + + +
+
+
{t("jobs.fields.intake.required")}
+ + + +
+
+ } + wrapTitle + extra={ + + + ]} + >
- {fields.map((field, index) => ( - - - + ) : ( + fields.map((field, index) => { + return ( + + + +
+
{t("jobs.fields.labor_rate_desc")}
+ + + +
+
} - ]} - > - - - + - + ); + }) + )} - ); - }} - - + + ); + }} + ); } diff --git a/client/src/components/shop-info/shop-info.notifications-autoadd.component.jsx b/client/src/components/shop-info/shop-info.notifications-autoadd.component.jsx index 45a8ef838..3647cc95c 100644 --- a/client/src/components/shop-info/shop-info.notifications-autoadd.component.jsx +++ b/client/src/components/shop-info/shop-info.notifications-autoadd.component.jsx @@ -1,6 +1,7 @@ import { Form, Typography } from "antd"; import { useTranslation } from "react-i18next"; import EmployeeSearchSelectComponent from "../employee-search-select/employee-search-select.component.jsx"; +import LayoutFormRow from "../layout-form-row/layout-form-row.component"; const { Text, Paragraph } = Typography; @@ -11,43 +12,45 @@ export default function ShopInfoNotificationsAutoadd({ bodyshop }) { const employeeOptions = bodyshop?.employees?.filter((e) => e.active && e.user_email && e.id) || []; return ( -
- {t("bodyshop.fields.notifications.description")} - {t("bodyshop.labels.notifications.followers")} - {employeeOptions.length > 0 ? ( - (value || []).filter((id) => typeof id === "string" && id.trim() !== "")} - name="notification_followers" - rules={[ - { - type: "array", - message: t("general.validation.array") - }, - { - validator: async (_, value) => { - if (!value || value.length === 0) { - return Promise.resolve(); // Allow empty array + +
+ {t("bodyshop.fields.notifications.description")} + {t("bodyshop.labels.notifications.followers")} + {employeeOptions.length > 0 ? ( + (value || []).filter((id) => typeof id === "string" && id.trim() !== "")} + name="notification_followers" + rules={[ + { + type: "array", + message: t("general.validation.array") + }, + { + validator: async (_, value) => { + if (!value || value.length === 0) { + return Promise.resolve(); // Allow empty array + } + const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === ""); + if (hasInvalid) { + return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers"))); + } + return Promise.resolve(); } - const hasInvalid = value.some((id) => id == null || typeof id !== "string" || id.trim() === ""); - if (hasInvalid) { - return Promise.reject(new Error(t("bodyshop.fields.notifications.invalid_followers"))); - } - return Promise.resolve(); } - } - ]} - > - - - ) : ( - {t("bodyshop.fields.no_employees_available")} - )} -
+ ]} + > + +
+ ) : ( + {t("bodyshop.fields.no_employees_available")} + )} +
+ ); } diff --git a/client/src/components/shop-info/shop-info.parts-scan.jsx b/client/src/components/shop-info/shop-info.parts-scan.jsx index 51fdbbfe4..03c55b937 100644 --- a/client/src/components/shop-info/shop-info.parts-scan.jsx +++ b/client/src/components/shop-info/shop-info.parts-scan.jsx @@ -3,7 +3,19 @@ import { Button, Col, Form, Input, Row, Select, Space, Switch } from "antd"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; +import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx"; +import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import { + INLINE_TITLE_GROUP_STYLE, + INLINE_TITLE_HANDLE_STYLE, + INLINE_TITLE_INPUT_STYLE, + INLINE_TITLE_LABEL_STYLE, + INLINE_TITLE_ROW_STYLE, + INLINE_TITLE_SEPARATOR_STYLE, + INLINE_TITLE_SWITCH_GROUP_STYLE, + InlineTitleListIcon +} from "../layout-form-row/inline-form-row-title.utils.js"; import i18n from "i18next"; const predefinedPartTypes = ["PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"]; @@ -68,195 +80,223 @@ export default function ShopInfoPartsScan({ form }) { return (
- - - {(fields, { add, remove, move }) => ( + + {(fields, { add, remove, move }) => ( + + add({ + field: "line_desc", + operation: "contains", + mark_critical: true, + caseInsensitive: true + }) + } + > + {t("bodyshop.actions.addpartsrule")} + + ]} + >
- {fields.map((field, index) => { - const selectedField = watchedFields?.[index]?.field || "line_desc"; - const fieldType = getFieldType(selectedField); + {fields.length === 0 ? ( + + ) : ( + fields.map((field, index) => { + const selectedField = watchedFields?.[index]?.field || "line_desc"; + const fieldType = getFieldType(selectedField); - return ( - - - {/* Select Field */} - - - { + form.setFields([ + { name: ["md_parts_scan", index, "operation"], value: "contains" }, + { name: ["md_parts_scan", index, "value"], value: undefined } + ]); + }} + style={{ + width: "100%" + }} + styles={{ + selector: INLINE_TITLE_INPUT_STYLE + }} + size="small" + /> + +
+ {fieldType === "string" && ( + <> +
+
+
+ {t("bodyshop.fields.md_parts_scan.caseInsensitive")} +
+ + + +
+ + )} +
+
+
+ {t("bodyshop.fields.md_parts_scan.mark_critical")} +
+ + + +
+
+ } + wrapTitle + extra={ + + - + + + + + + ); + }) + )}
- )} -
-
+ + )} +
); } diff --git a/client/src/components/shop-info/shop-info.rbac.component.jsx b/client/src/components/shop-info/shop-info.rbac.component.jsx index c0377cc17..1e24355ec 100644 --- a/client/src/components/shop-info/shop-info.rbac.component.jsx +++ b/client/src/components/shop-info/shop-info.rbac.component.jsx @@ -27,7 +27,7 @@ export function ShopInfoRbacComponent({ bodyshop }) { }); return ( - + {[ ...(HasFeatureAccess({ featureName: "export", bodyshop }) ? [ diff --git a/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx b/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx index 87013bdd2..c88650e13 100644 --- a/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx +++ b/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx @@ -1,6 +1,6 @@ import { DeleteFilled } from "@ant-design/icons"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; -import { Button, DatePicker, Form, Input, InputNumber, Radio, Select, Space, Switch } from "antd"; +import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; @@ -11,7 +11,20 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr"; import DataLabel from "../data-label/data-label.component"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; +import { getFormListItemTitle } from "../form-list-move-arrows/form-list-item-title.utils"; +import { buildSectionActionButton, renderListOrEmpty } from "../layout-form-row/config-list-actions.utils.jsx"; +import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; +import { + INLINE_TITLE_GROUP_STYLE, + INLINE_TITLE_HANDLE_STYLE, + INLINE_TITLE_INPUT_STYLE, + INLINE_TITLE_LABEL_STYLE, + INLINE_TITLE_ROW_STYLE, + INLINE_TITLE_SEPARATOR_STYLE, + INLINE_TITLE_SWITCH_GROUP_STYLE, + InlineTitleListIcon +} from "../layout-form-row/inline-form-row-title.utils.js"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import ShopInfoResponsibilitycentersTaxesComponent from "./shop-info.responsibilitycenters.taxes.component"; import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; @@ -32,6 +45,9 @@ export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoResponsibili export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { const { t } = useTranslation(); + const taxAccountRowCol = { xs: 24, sm: 12, md: 12, lg: 6, xl: 6, xxl: 6 }; + const taxAccountFullRowCol = { xs: 24 }; + const dmsPayers = Form.useWatch(["cdk_configuration", "payers"], form) || []; const hasDMSKey = bodyshopHasDmsKey(bodyshop); @@ -66,240 +82,21 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { }; const ReceivableCustomFieldSelect = ( - ); return (
- {[ - ...(HasFeatureAccess({ featureName: "export", bodyshop }) - ? !hasDMSKey - ? [ - - - , - InstanceRenderManager({ - imex: ( - - {() => ( - - - - )} - - ) - }), - - - , - - - 2 - 3 - - , - - {() => { - return ( - - - {t("bodyshop.labels.2tiername")} - {t("bodyshop.labels.2tiersource")} - - - ); - }} - , - - - , - - - , - - {ReceivableCustomFieldSelect} - , - - {ReceivableCustomFieldSelect} - , - - {ReceivableCustomFieldSelect} - , - { - return { - required: getFieldValue("enforce_class"), - //message: t("general.validation.required"), - type: "array" - }; - } - ]} - > - - , - - - , - InstanceRenderManager({ - imex: ( - - - - ) - }), - - - , - ...(HasFeatureAccess({ featureName: "bills", bodyshop }) - ? [ - InstanceRenderManager({ - imex: ( - - - - ) - }), - - - , - - - - ] - : []), + <> - ] - : []), - ...(HasFeatureAccess({ featureName: "bills", bodyshop }) - ? [ + + - + - ] - : []), - ...(HasFeatureAccess({ featureName: "export", bodyshop }) - ? [ - ...(ClosingPeriod.treatment === "on" - ? [ - - + + {InstanceRenderManager({ + imex: ( + + + + + + ) + })} + + + + + + {HasFeatureAccess({ featureName: "export", bodyshop }) && + ClosingPeriod.treatment === "on" && ( + + + + + + )} + {HasFeatureAccess({ featureName: "export", bodyshop }) && + ADPPayroll.treatment === "on" && ( + + + + + + )} + {HasFeatureAccess({ featureName: "export", bodyshop }) && + ADPPayroll.treatment === "on" && ( + + + + + + )} + {HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && ( + <> + + + + 2 + 3 + + + + + + {() => { + return ( + + + {t("bodyshop.labels.2tiername")} + {t("bodyshop.labels.2tiersource")} + + + ); + }} + + + + { + return { + required: getFieldValue("enforce_class"), + //message: t("general.validation.required"), + type: "array" + }; + } + ]} + > + - - ] - : []), - ...(ADPPayroll.treatment === "on" - ? [ - - - - ] - : []) - ] - : []) - ]} +
+ {InstanceRenderManager({ + imex: ( +
+ {t("bodyshop.labels.qbo_usa")} + + {() => ( + + + + )} + +
+ ) + })} +
+ } + > + + + + + {ReceivableCustomFieldSelect} + + + {ReceivableCustomFieldSelect} + + + {ReceivableCustomFieldSelect} + + + )} + + {HasFeatureAccess({ featureName: "bills", bodyshop }) && ( + + {InstanceRenderManager({ + imex: ( + + + + ) + })} + + + + + + + + )} + + {hasDMSKey && ( - <> - - {bodyshop.rr_dealerid && ( - {form.getFieldValue("rr_dealerid")} - )} - {bodyshop.cdk_dealerid && ( - - {form.getFieldValue("cdk_dealerid")} - - )} - {bodyshop.pbs_serialnumber && ( - - {form.getFieldValue("pbs_serialnumber")} - - )} - - - - - - - - - - - - - - - - - - {bodyshop.pbs_serialnumber && ( + +
+ + {bodyshop.rr_dealerid && ( + + {form.getFieldValue("rr_dealerid")} + + )} + {bodyshop.cdk_dealerid && ( + + {form.getFieldValue("cdk_dealerid")} + + )} + {bodyshop.pbs_serialnumber && ( + + {form.getFieldValue("pbs_serialnumber")} + + )} + + - + - )} - {bodyshop.pbs_serialnumber && ( - + - )} - {bodyshop.pbs_serialnumber && ( - + - )} - {bodyshop.pbs_serialnumber && ( - - )} - {bodyshop.pbs_serialnumber && ( - - + + )} + {bodyshop.pbs_serialnumber && ( + + - - - - - - + + + + + + - - - - - { - remove(field.name); - }} - /> - - - ))} - - - -
+ } + ) + ]} + > +
+ {renderListOrEmpty(fields, t("bodyshop.actions.add_control_number"), () => + fields.map((field, index) => ( + + + + + + + + +
+
); }} -
- - )} - + + )} + + )} {HasFeatureAccess({ featureName: "export", bodyshop }) && ( <> - - - {(fields, { add, remove, move }) => { - return ( + + {(fields, { add, remove, move }) => { + return ( + { + add(); + }) + ]} + >
- {fields.map((field, index) => ( - - - - - - - - - - - - - {hasDMSKey && !bodyshop.rr_dealerid && ( - <> + {renderListOrEmpty(fields, t("bodyshop.actions.add_cost_center"), () => + fields.map((field, index) => { + return ( + + + +
+
+ {t("bodyshop.fields.responsibilitycenter")} +
+ + + +
+
+
+
+ {t("bodyshop.fields.responsibilitycenter_accountdesc")} +
+ + + +
+
+ } + wrapTitle + extra={ + + -
-
- ); - }} -
-
- - - {(fields, { add, remove, move }) => { - return ( -
- {fields.map((field, index) => ( - - - - - - - - - {!hasDMSKey && ( - - - - )} - {hasDMSKey && !bodyshop.rr_dealerid && ( - - - - )} - {bodyshop.cdk_dealerid && ( - - - - )} - {bodyshop.rr_dealerid && ( - <> - - - - - - - - - )} - - { - remove(field.name); - }} - /> - - - - - ))} - - - -
- ); - }} -
-
- - {hasDMSKey && ( - - - {(fields, { add, remove }) => { - return ( -
- {fields.map((field, index) => ( - -
- 0 ? false : true}> + {bodyshop.cdk_dealerid && ( + + + )} + + + ); + }) + )} +
+ + ); + }} + + + {(fields, { add, remove, move }) => { + return ( + { + add(); + }) + ]} + > +
+ {renderListOrEmpty(fields, t("bodyshop.actions.add_profit_center"), () => + fields.map((field, index) => { + return ( + + + +
+
+ {t("bodyshop.fields.responsibilitycenter")} +
+ + + +
+
+
+
+ {t("bodyshop.fields.responsibilitycenter_accountdesc")} +
+ + + +
+
+ } + wrapTitle + extra={ + +
+
+
+ ); + }) + )}
- ); - }} -
-
+ + ); + }} + )} @@ -2913,43 +3322,110 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { )} - - - - - - - - - - {hasDMSKey && ( - + - - - )} - - - + + + + + + + + + + + + + {hasDMSKey && ( + + + + )} + + + {InstanceRenderManager({ + imex: ( + + + + + + + + + + + + + + {hasDMSKey && ( + + + + )} + + ), + rome: null + })} + {DmsAp.treatment === "on" && ( @@ -2989,75 +3465,34 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { rules={[{ required: true }]} name={["md_responsibility_centers", "taxes", "federal_itc", "rate"]} > - + )} {InstanceRenderManager({ - imex: ( - - - - - - - - - - - - {hasDMSKey && ( - - - - )} - - - - - ), + imex: null, rome: })} {HasFeatureAccess({ featureName: "export", bodyshop }) && ( <> {InstanceRenderManager({ rome: ( - <> + - + ) })} AR} id="AR"> @@ -3155,78 +3590,119 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { )} - - - {(fields, { add, remove }) => { - return ( + + {(fields, { add, remove }) => { + return ( + { + add(); + }) + ]} + >
- {fields.map((field, index) => ( - - 0 ? false : true}> - - + {renderListOrEmpty(fields, t("bodyshop.actions.newsalestaxcode"), () => + fields.map((field, index) => { + return ( + + + +
+
+ {t("bodyshop.fields.responsibilitycenters.sales_tax_codes.description")} +
+ + + +
+
+
+
+ {t("bodyshop.fields.responsibilitycenters.sales_tax_codes.code")} +
+ + + +
+
+ } + wrapTitle + extra={ + -
+ ); + }) + )}
- ); - }} -
-
+ + ); + }} + )} diff --git a/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.component.jsx b/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.component.jsx index a947f7d1c..d742628eb 100644 --- a/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.component.jsx +++ b/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.component.jsx @@ -1,4 +1,4 @@ -import { Collapse, Divider, Form, Input, InputNumber, Space, Switch } from "antd"; +import { Col, Collapse, Form, Input, InputNumber, Row, Switch } from "antd"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -6,6 +6,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; +import "./shop-info.responsibilitycenters.taxes.styles.scss"; const mapStateToProps = createStructuredSelector({ //currentUser: selectCurrentUser @@ -16,53 +17,102 @@ const mapDispatchToProps = () => ({ }); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoResponsibilityCenters); +const taxRootColProps = { + xs: 24, + sm: 12, + md: 8, + lg: { flex: "0 0 280px" }, + xl: { flex: "0 0 240px" }, + xxl: { flex: "0 0 300px" } +}; + +const taxTierFieldColProps = { + xs: 24, + sm: 12, + lg: 6 +}; + export function ShopInfoResponsibilityCenters({ bodyshop, form }) { const { t } = useTranslation(); - //Iteratively build the form items. - const formItems = []; - for (let tyCounter = 1; tyCounter <= 5; tyCounter++) { - const section = []; + const profileTaxCards = []; + for (let typeNum = 1; typeNum <= 5; typeNum++) { + const rootTaxItems = getRootTaxFormItems({ typeNum, bodyshop, t }); - section.push( - TaxFormItems({ - typeNum: tyCounter, - rootElements: true, - bodyshop - }) + profileTaxCards.push( + +
+ + {rootTaxItems.map((item, index) => ( + + {item} + + ))} + + + {Array.from({ length: 5 }, (_, index) => { + const typeNumIterator = index + 1; + const tierTaxItems = getTierTaxFormItems({ + typeNum, + typeNumIterator, + t + }); + + return ( + + + + {tierTaxItems.map((item, tierIndex) => ( + + {item} + + ))} + + + + ); + })} + +
+
); - for (let iterator = 1; iterator <= 5; iterator++) { - section.push( - TaxFormItems({ - typeNum: tyCounter, - typeNumIterator: iterator, - rootElements: false - }) - ); - } - formItems.push({section}); - formItems.push(); } + return ( <> - - {t("jobs.labels.cieca_pft")} - - {formItems} + +
{profileTaxCards}
+
- + + - + - + ); }} @@ -135,7 +185,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.cieca_pfl.lbr_adjp")} name={["md_responsibility_centers", "cieca_pfl", "LAD", "lbr_adjp"]} > - + - + ); }} @@ -208,7 +258,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.cieca_pfl.lbr_adjp")} name={["md_responsibility_centers", "cieca_pfl", "LAE", "lbr_adjp"]} > - + - + ); }} @@ -281,7 +331,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.cieca_pfl.lbr_adjp")} name={["md_responsibility_centers", "cieca_pfl", "LAF", "lbr_adjp"]} > - + - + ); }} @@ -354,7 +404,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.cieca_pfl.lbr_adjp")} name={["md_responsibility_centers", "cieca_pfl", "LAG", "lbr_adjp"]} > - + - + ); }} @@ -427,7 +477,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.cieca_pfl.lbr_adjp")} name={["md_responsibility_centers", "cieca_pfl", "LAM", "lbr_adjp"]} > - + - + ); }} @@ -500,7 +550,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.cieca_pfl.lbr_adjp")} name={["md_responsibility_centers", "cieca_pfl", "LAR", "lbr_adjp"]} > - + - + ); }} @@ -573,7 +623,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.cieca_pfl.lbr_adjp")} name={["md_responsibility_centers", "cieca_pfl", "LAS", "lbr_adjp"]} > - + - + ); }} @@ -740,7 +790,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.materials.mat_adjp")} name={["md_responsibility_centers", "cieca_pfm", "MAPA", "mat_adjp"]} > - + - + ); }} @@ -825,7 +875,7 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { label={t("jobs.fields.materials.mat_adjp")} name={["md_responsibility_centers", "cieca_pfm", "MASH", "mat_adjp"]} > - + - + ); }} @@ -893,15 +943,15 @@ export function ShopInfoResponsibilityCenters({ bodyshop, form }) { - - ) - }, - { - key: "cieca_pfo", - label: t("jobs.labels.cieca_pfo"), - forceRender: true, - children: ( - <> + + ) + }, + { + key: "cieca_pfo", + label: t("jobs.labels.cieca_pfo"), + forceRender: true, + children: ( + <> - - ) - } - ]} - /> + + ) + } + ]} + /> + ); } -function TaxFormItems({ typeNum, typeNumIterator, rootElements, bodyshop }) { - const { t } = useTranslation(); - - if (rootElements) - return ( - <> - - - - - - - - - - - - - - - {bodyshopHasDmsKey(bodyshop) && ( +function getRootTaxFormItems({ typeNum, bodyshop, t }) { + return [ + + + , + + + , + + + , + + + , + ...(bodyshopHasDmsKey(bodyshop) + ? [ - )} - - ); - return ( - <> - - - - - - - - - - - - - - ); + ] + : []) + ]; +} + +function getTierTaxFormItems({ typeNum, typeNumIterator, t }) { + return [ + + + , + + + , + + + , + + + + ]; } diff --git a/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.styles.scss b/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.styles.scss new file mode 100644 index 000000000..5cbfe7f95 --- /dev/null +++ b/client/src/components/shop-info/shop-info.responsibilitycenters.taxes.styles.scss @@ -0,0 +1,25 @@ +.responsibility-centers-tax-tier-grid__col.ant-col { + flex: 0 0 100%; + max-width: 100%; +} + +@media (min-width: 992px) { + .responsibility-centers-tax-tier-grid__col.ant-col { + flex: 0 0 50%; + max-width: 50%; + } +} + +@media (min-width: 1600px) { + .responsibility-centers-tax-tier-grid__col.ant-col { + flex: 0 0 25%; + max-width: 25%; + } +} + +@media (min-width: 2400px) { + .responsibility-centers-tax-tier-grid__col.ant-col { + flex: 0 0 20%; + max-width: 20%; + } +} diff --git a/client/src/components/shop-info/shop-info.roguard.component.jsx b/client/src/components/shop-info/shop-info.roguard.component.jsx index a8b6e983e..66638a9f5 100644 --- a/client/src/components/shop-info/shop-info.roguard.component.jsx +++ b/client/src/components/shop-info/shop-info.roguard.component.jsx @@ -21,7 +21,7 @@ export default function ShopInfoRoGuard({ form }) { {() => { const disabled = !form.getFieldValue(["md_ro_guard", "enabled"]); return ( - + - + [...new Set((statuses || []).map((item) => item?.trim()).filter(Boolean))]; + +const getTranslatedDragRect = (active, delta) => { + const rect = active?.rect?.current?.initial || active?.rect?.current?.translated; + + if (!rect) return null; + + const x = delta?.x || 0; + const y = delta?.y || 0; + + return { + left: rect.left + x, + right: rect.right + x, + top: rect.top + y, + bottom: rect.bottom + y, + width: rect.width, + height: rect.height + }; +}; + +const isPointWithinRect = (point, rect) => { + if (!point || !rect) return false; + + return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom; +}; + +const DraggableStatusTag = ({ label, value, closable, onClose }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: value + }); + const labelText = String(label ?? value); + + return ( + { + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + {...attributes} + {...listeners} + > + { + if (event.target.closest(".ant-select-selection-item-remove")) { + event.stopPropagation(); + return; + } + + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + if (event.target.closest(".ant-select-selection-item-remove")) { + event.stopPropagation(); + return; + } + + event.stopPropagation(); + }} + title={labelText} + > + + + + {labelText} + {closable ? ( + { + event.stopPropagation(); + onClose?.(event); + }} + onMouseDown={(event) => { + event.stopPropagation(); + }} + > + + + ) : null} + + + ); +}; + +const SortableStatusesSelect = ({ value, onChange, mode = "tags", options = [] }) => { + const statuses = normalizeStatuses(value); + const isTagsMode = mode === "tags"; + const [knownStatuses, setKnownStatuses] = useState(statuses); + const selectWrapperRef = useRef(null); + const dragRectRef = useRef(null); + const tagSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 6 + } + }) + ); + + const handleStatusesChange = (nextValues) => { + const normalizedNextValues = normalizeStatuses(nextValues); + if (isTagsMode) { + setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...normalizedNextValues])); + } + onChange?.(normalizedNextValues); + }; + + useEffect(() => { + if (isTagsMode) { + setKnownStatuses((currentKnownStatuses) => normalizeStatuses([...currentKnownStatuses, ...statuses])); + } + }, [isTagsMode, statuses]); + + const shouldMoveStatusToEnd = (activeId, dragRect) => { + const selectRect = + selectWrapperRef.current?.querySelector?.(".ant-select-selector")?.getBoundingClientRect?.() || + selectWrapperRef.current?.getBoundingClientRect?.(); + if (!dragRect || !selectRect) return false; + + const dragLeadingPoint = { + x: dragRect.left, + y: dragRect.top + }; + const dragTrailingPoint = { + x: dragRect.right, + y: dragRect.bottom + }; + + if (!isPointWithinRect(dragLeadingPoint, selectRect) && !isPointWithinRect(dragTrailingPoint, selectRect)) { + return false; + } + + const trailingStatus = statuses.filter((status) => status !== activeId).at(-1); + if (!trailingStatus) return false; + + const trailingTagNode = selectWrapperRef.current?.querySelector?.( + `.job-statuses-source-tag-wrapper[data-status-tag-value="${CSS.escape(String(trailingStatus))}"]` + ); + const trailingTagRect = trailingTagNode?.getBoundingClientRect?.(); + + if (!trailingTagRect) return false; + + const isOnTrailingRow = dragRect.bottom >= trailingTagRect.top && dragRect.top <= trailingTagRect.bottom; + if (isOnTrailingRow) { + return dragRect.left >= trailingTagRect.right - 4; + } + + return dragRect.top >= trailingTagRect.bottom - 4; + }; + + const handleStatusSortEnd = ({ active, over, delta }) => { + const oldIndex = statuses.indexOf(active.id); + const dragRect = dragRectRef.current || getTranslatedDragRect(active, delta); + dragRectRef.current = null; + + if (oldIndex < 0) return; + + if (!over) { + if (oldIndex !== statuses.length - 1 && shouldMoveStatusToEnd(active.id, dragRect)) { + onChange?.(arrayMove(statuses, oldIndex, statuses.length - 1)); + } + return; + } + + if (active.id === over.id) return; + + const newIndex = statuses.indexOf(over.id); + + if (newIndex < 0) return; + + onChange?.(arrayMove(statuses, oldIndex, newIndex)); + }; + + const renderStatusTag = ({ label, value: tagValue, closable, onClose }) => { + return ; + }; + + const statusSelectOptions = isTagsMode + ? knownStatuses.map((status) => ({ + value: status, + label: status + })) + : options; + + if (statuses.length === 0) { + return ( + + + + + ); +}; + export function ShopInfoROStatusComponent({ bodyshop, form }) { const { t } = useTranslation(); + const allStatuses = normalizeStatuses(Form.useWatch(["md_ro_statuses", "statuses"], form)); + const productionStatuses = Form.useWatch(["md_ro_statuses", "production_statuses"], form) || []; + const additionalBoardStatuses = Form.useWatch(["md_ro_statuses", "additional_board_statuses"], form) || []; + const productionColors = Form.useWatch(["md_ro_statuses", "production_colors"], form) || []; + const statusOptions = allStatuses; + const statusSelectOptions = statusOptions.map((item) => ({ value: item, label: item })); + const availableProductionStatuses = [...new Set([...productionStatuses, ...additionalBoardStatuses].filter(Boolean))]; const { treatments: { Production_List_Status_Colors } @@ -37,117 +375,119 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) { splitKey: bodyshop.imexshopid }); - const [options, setOptions] = useState(form.getFieldValue(["md_ro_statuses", "statuses"]) || []); - - const [productionStatus, setProductionStatus] = useState( - (form.getFieldValue(["md_ro_statuses", "production_statuses"]) || []).concat( - form.getFieldValue(["md_ro_statuses", "additional_board_statuses"]) || [] - ) || [] - ); - - const handleBlur = () => { - setOptions(form.getFieldValue(["md_ro_statuses", "statuses"])); - setProductionStatus( - form - .getFieldValue(["md_ro_statuses", "production_statuses"]) - .concat(form.getFieldValue(["md_ro_statuses", "additional_board_statuses"])) - ); - }; - return ( - - ({ value: item, label: item }))} /> - - - ({ value: item, label: item }))} /> - - - ({ value: item, label: item }))} /> - - - ({ value: item, label: item }))} /> + ({ value: item, label: item }))} /> + ({ value: item, label: item }))} /> + ({ value: item, label: item }))} /> + ({ value: item, label: item }))} /> + ({ value: item, label: item }))} /> + ({ value: item, label: item }))} /> + ({ value: item, label: item }))} /> + ({ value: item, label: item }))} /> - - { - remove(field.name); - }} - /> - - + ) : ( + + {fields.map((field, index) => { + const productionColor = productionColors[field.name] || {}; + const productionColorSurfaceStyles = getTintedCardSurfaceStyles(productionColor.color); + const selectedProductionColorStatuses = productionColors + .map((item) => item?.status) + .filter(Boolean); + const productionColorStatusOptions = [ + ...new Set([productionColor.status, ...availableProductionStatuses]) + ] + .filter(Boolean) + .filter( + (status) => + status === productionColor.status || !selectedProductionColorStatuses.includes(status) + ); + + return ( + + - - - - - - - - - + + + + + + } + extra={ + + - - + ]} + > +
+ {fields.length === 0 ? ( + + ) : ( + fields.map((field, index) => { + const schedulingBucket = + schedulingBuckets[field.name] || form.getFieldValue(["ssbuckets", field.name]) || {}; + const schedulingBucketSurfaceStyles = getTintedCardSurfaceStyles(schedulingBucket.color); + + return ( + + +
+
{t("bodyshop.fields.ssbuckets.id")}
+ + + +
+
+
+ {t("bodyshop.fields.ssbuckets.label")} +
+ + + +
+
+ } + extra={ + + - - } - key={`${index}color`} - name={[field.name, "color"]} - > - - - - { - remove(field.name); - }} - /> - - - -
- - ))} - - - - - ); - }} - -
)} ); diff --git a/client/src/components/shop-info/shop-info.scheduling.styles.scss b/client/src/components/shop-info/shop-info.scheduling.styles.scss new file mode 100644 index 000000000..b04a92e15 --- /dev/null +++ b/client/src/components/shop-info/shop-info.scheduling.styles.scss @@ -0,0 +1,58 @@ +.shop-info-scheduling__bucket-card-body { + display: flex; + gap: 12px; + align-items: stretch; +} + +.shop-info-scheduling__bucket-card-fields { + flex: 1 1 0; + min-width: 0; + display: grid; + grid-template-columns: repeat(3, minmax(92px, 1fr)); + gap: 0 12px; +} + +.shop-info-scheduling__bucket-card-fields .ant-form-item { + margin-bottom: 10px; +} + +.shop-info-scheduling__bucket-card-color { + flex: 0 0 360px; + min-width: 360px; + max-width: 360px; + display: flex; + align-items: stretch; +} + +.shop-info-scheduling__bucket-card-color .ant-form-item { + margin-bottom: 0; + width: 100%; +} + +.shop-info-scheduling__bucket-card-color .ant-form-item-control, +.shop-info-scheduling__bucket-card-color .ant-form-item-control-input, +.shop-info-scheduling__bucket-card-color .ant-form-item-control-input-content { + height: 100%; +} + +@media (max-width: 1199px) { + .shop-info-scheduling__bucket-card-body { + flex-direction: column; + } + + .shop-info-scheduling__bucket-card-fields { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + .shop-info-scheduling__bucket-card-color { + flex-basis: auto; + min-width: 0; + max-width: none; + } +} + +@media (max-width: 575px) { + .shop-info-scheduling__bucket-card-fields { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/client/src/components/shop-info/shop-info.section-navigator.component.jsx b/client/src/components/shop-info/shop-info.section-navigator.component.jsx new file mode 100644 index 000000000..3d120c0e5 --- /dev/null +++ b/client/src/components/shop-info/shop-info.section-navigator.component.jsx @@ -0,0 +1,213 @@ +import { Select } from "antd"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import "./shop-info.section-navigator.styles.scss"; + +const HIGHLIGHT_CLASS = "shop-info-section-navigator__target--active"; + +export default function ShopInfoSectionNavigator({ tabsRef, activeTabKey }) { + const { t } = useTranslation(); + const targetMapRef = useRef(new Map()); + const highlightedTargetRef = useRef(null); + const [options, setOptions] = useState([]); + const [selectedSection, setSelectedSection] = useState(undefined); + + useEffect(() => { + const tabsContainer = tabsRef.current; + if (!tabsContainer) return undefined; + + let animationFrameId = 0; + + const refreshOptions = () => { + const activePane = tabsContainer.querySelector(".ant-tabs-tabpane-active"); + if (!activePane) { + targetMapRef.current = new Map(); + setOptions([]); + return; + } + + const nextTargetMap = new Map(); + const nextOptions = Array.from(activePane.querySelectorAll(".imex-form-row")) + .filter((card) => { + return shouldIncludeCardInNavigator(card, activePane); + }) + .map((card, index) => { + const { title, depth, searchLabel } = getCardNavigatorInfo(card, activePane); + const value = `${activeTabKey}-shop-info-section-${index}`; + + nextTargetMap.set(value, card); + + return { + label: renderNavigatorOptionLabel(title, depth), + labelText: title, + searchLabel, + depth, + value + }; + }); + + targetMapRef.current = nextTargetMap; + setOptions((currentOptions) => (areOptionsEqual(currentOptions, nextOptions) ? currentOptions : nextOptions)); + }; + + const scheduleRefresh = () => { + cancelAnimationFrame(animationFrameId); + animationFrameId = requestAnimationFrame(refreshOptions); + }; + + scheduleRefresh(); + + const observer = new MutationObserver(scheduleRefresh); + observer.observe(tabsContainer, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ["class"] + }); + + return () => { + cancelAnimationFrame(animationFrameId); + observer.disconnect(); + }; + }, [activeTabKey, tabsRef]); + + useEffect(() => { + clearHighlightedTarget(highlightedTargetRef); + setSelectedSection(undefined); + }, [activeTabKey]); + + const handleSectionChange = (value) => { + setSelectedSection(value); + + clearHighlightedTarget(highlightedTargetRef); + if (!value) return; + + const target = targetMapRef.current.get(value); + if (target) { + target.classList.add(HIGHLIGHT_CLASS); + highlightedTargetRef.current = target; + target.scrollIntoView({ + behavior: "smooth", + block: "start" + }); + } + + window.setTimeout(() => { + setSelectedSection(undefined); + }, 0); + }; + + return ( +
+ - - - - - - - + +
+
+
+
{t("bodyshop.fields.speedprint.label")}
+ + + +
+
+ } + wrapTitle + extra={ + + - - - ); + {isTeamHydrating ? ( + + ) : ( +
{ + updateDirtyState(teamForm.isFieldsTouched()); }} - -
+ > + + +
+ {t("employee_teams.labels.team_options")} +
+
+
+
{t("employee_teams.fields.active")}
+ + + +
+
+ } + wrapTitle + > + + + + + + +
+ + {(fields, { add, remove, move }) => { + return ( + { + add({ + percentage: 0, + payout_method: "hourly", + labor_rates: {}, + commission_rates: {} + }); + }} + > + {t("employee_teams.actions.newmember")} + + ]} + > +
+ {fields.length === 0 ? ( + + ) : ( + fields.map((field, index) => { + return ( + + + + +
+
{t("employee_teams.fields.employeeid")}
+ + + +
+
+
+
{t("employee_teams.fields.allocation")}
+ + + +
+
+
+
{t("employee_teams.fields.payout_method")}
+ + onChange?.(event.target.value || undefined)} + > + + {options.map((option) => ( + + ))} + + ) +})); + +vi.mock("../form-items-formatted/currency-form-item.component", () => ({ + default: ({ id, value, onChange }) => ( + onChange?.(event.target.value === "" ? null : Number(event.target.value))} + /> + ) +})); + +vi.mock("../layout-form-row/layout-form-row.component", () => ({ + default: ({ title, extra, actions, children }) => ( +
+ {title} + {extra} + {children} + {actions} +
+ ) +})); + +vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({ + default: () => null +})); + +const bodyshop = { + id: "shop-1", + employees: [ + { + id: "emp-1", + first_name: "Avery", + last_name: "Johnson" + }, + { + id: "emp-2", + first_name: "Morgan", + last_name: "Lee" + } + ] +}; + +const fillHourlyRates = (value) => { + LABOR_TYPES.forEach((laborType) => { + fireEvent.change(screen.getByLabelText(laborType), { + target: { value: String(value) } + }); + }); +}; + +const addBaseTeamMember = ({ employeeId = "emp-1", percentage = 100, rate = 25 } = {}) => { + fireEvent.click(screen.getByRole("button", { name: "New Team Member" })); + + fireEvent.change(screen.getByLabelText("Employee"), { + target: { value: employeeId } + }); + fireEvent.change(screen.getByRole("spinbutton", { name: "Allocation" }), { + target: { value: String(percentage) } + }); + fillHourlyRates(rate); +}; + +describe("ShopEmployeeTeamsFormComponent", () => { + beforeEach(() => { + vi.clearAllMocks(); + + useQueryMock.mockReturnValue({ + error: null, + data: null, + loading: false + }); + + useMutationMock.mockImplementation((mutation) => { + if (mutation === UPDATE_EMPLOYEE_TEAM) { + return [updateEmployeeTeamMock]; + } + + if (mutation === INSERT_EMPLOYEE_TEAM) { + return [insertEmployeeTeamMock]; + } + + return [vi.fn()]; + }); + + insertEmployeeTeamMock.mockResolvedValue({ + data: { + insert_employee_teams_one: { + id: "team-1" + } + } + }); + }); + + it("switches a new team member from hourly rates to commission percentages", async () => { + render(); + + addBaseTeamMember(); + expect(screen.getAllByTestId("currency-input")).toHaveLength(LABOR_TYPES.length); + + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Payout Method" })); + fireEvent.click(screen.getByText("Commission")); + + await waitFor(() => { + expect(screen.queryAllByTestId("currency-input")).toHaveLength(0); + }); + }); + + it("submits a valid new hourly team with normalized member data", async () => { + render(); + + fireEvent.change(screen.getByRole("textbox", { name: "Team Name" }), { + target: { value: "Commission Crew" } + }); + fireEvent.change(screen.getByRole("spinbutton", { name: "Max Load" }), { + target: { value: "8" } + }); + + addBaseTeamMember({ + employeeId: "emp-1", + percentage: 100, + rate: 27.5 + }); + + fireEvent.click(screen.getByRole("button", { name: "Save Employee Team" })); + + await waitFor(() => { + expect(insertEmployeeTeamMock).toHaveBeenCalledWith({ + variables: { + employeeTeam: { + name: "Commission Crew", + max_load: 8, + employee_team_members: { + data: [ + { + employeeid: "emp-1", + percentage: 100, + payout_method: "hourly", + labor_rates: Object.fromEntries(LABOR_TYPES.map((laborType) => [laborType, 27.5])), + commission_rates: {} + } + ] + }, + bodyshopid: "shop-1" + } + }, + refetchQueries: ["QUERY_TEAMS"] + }); + }); + + expect(notification.success).toHaveBeenCalledWith({ + title: "Saved" + }); + expect(navigateMock).toHaveBeenCalledWith({ + search: "employeeTeamId=team-1" + }); + }); +}); diff --git a/client/src/components/shop-teams/shop-employee-teams.form.utils.js b/client/src/components/shop-teams/shop-employee-teams.form.utils.js new file mode 100644 index 000000000..b56352377 --- /dev/null +++ b/client/src/components/shop-teams/shop-employee-teams.form.utils.js @@ -0,0 +1,70 @@ +export const LABOR_TYPES = [ + "LAA", + "LAB", + "LAD", + "LAE", + "LAF", + "LAG", + "LAM", + "LAR", + "LAS", + "LAU", + "LA1", + "LA2", + "LA3", + "LA4" +]; + +export const normalizeTeamMember = (teamMember = {}) => ({ + ...teamMember, + payout_method: teamMember.payout_method || "hourly", + labor_rates: teamMember.labor_rates || {}, + commission_rates: teamMember.commission_rates || {} +}); + +export const normalizeEmployeeTeam = (employeeTeam = {}) => ({ + ...employeeTeam, + employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember) +}); + +export const getSplitTotal = (teamMembers = []) => + teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0); + +export const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001; + +export const validateEmployeeTeamMembers = (employeeTeamMembers = []) => { + const normalizedTeamMembers = employeeTeamMembers.map((teamMember) => { + const nextTeamMember = normalizeTeamMember({ ...teamMember }); + delete nextTeamMember.__typename; + return nextTeamMember; + }); + + if (normalizedTeamMembers.length === 0) { + return { + normalizedTeamMembers, + errorKey: "employee_teams.errors.minimum_one_member" + }; + } + + const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean); + const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index); + + if (duplicateEmployeeIds.length > 0) { + return { + normalizedTeamMembers, + errorKey: "employee_teams.errors.duplicate_member" + }; + } + + if (!hasExactSplitTotal(normalizedTeamMembers)) { + return { + normalizedTeamMembers, + errorKey: "employee_teams.errors.allocation_total_exact" + }; + } + + return { + normalizedTeamMembers, + errorKey: null + }; +}; diff --git a/client/src/components/shop-teams/shop-employee-teams.form.utils.test.js b/client/src/components/shop-teams/shop-employee-teams.form.utils.test.js new file mode 100644 index 000000000..e9a03d2f0 --- /dev/null +++ b/client/src/components/shop-teams/shop-employee-teams.form.utils.test.js @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + getSplitTotal, + hasExactSplitTotal, + normalizeTeamMember, + validateEmployeeTeamMembers +} from "./shop-employee-teams.form.utils.js"; + +describe("shop employee team form utilities", () => { + it("normalizes missing payout defaults for a team member", () => { + expect( + normalizeTeamMember({ + employeeid: "emp-1", + percentage: 100 + }) + ).toEqual({ + employeeid: "emp-1", + percentage: 100, + payout_method: "hourly", + labor_rates: {}, + commission_rates: {} + }); + }); + + it("returns a minimum-member validation error when no team members are provided", () => { + expect(validateEmployeeTeamMembers([])).toEqual({ + normalizedTeamMembers: [], + errorKey: "employee_teams.errors.minimum_one_member" + }); + }); + + it("rejects duplicate employees in the same team", () => { + const result = validateEmployeeTeamMembers([ + { employeeid: "emp-1", percentage: 50, labor_rates: { LAA: 25 } }, + { employeeid: "emp-1", percentage: 50, labor_rates: { LAA: 30 } } + ]); + + expect(result.errorKey).toBe("employee_teams.errors.duplicate_member"); + }); + + it("rejects team allocations that do not add up to exactly one hundred percent", () => { + const result = validateEmployeeTeamMembers([ + { employeeid: "emp-1", percentage: 60, labor_rates: { LAA: 25 } }, + { employeeid: "emp-2", percentage: 30, labor_rates: { LAA: 30 } } + ]); + + expect(getSplitTotal(result.normalizedTeamMembers)).toBe(90); + expect(hasExactSplitTotal(result.normalizedTeamMembers)).toBe(false); + expect(result.errorKey).toBe("employee_teams.errors.allocation_total_exact"); + }); + + it("accepts a valid mixed hourly and commission team and strips graph metadata", () => { + const result = validateEmployeeTeamMembers([ + { + __typename: "employee_team_members", + employeeid: "emp-1", + percentage: 40, + labor_rates: { LAA: 28.5 } + }, + { + employeeid: "emp-2", + percentage: 60, + payout_method: "commission", + commission_rates: { LAA: 35 } + } + ]); + + expect(result.errorKey).toBeNull(); + expect(result.normalizedTeamMembers).toEqual([ + { + employeeid: "emp-1", + percentage: 40, + payout_method: "hourly", + labor_rates: { LAA: 28.5 }, + commission_rates: {} + }, + { + employeeid: "emp-2", + percentage: 60, + payout_method: "commission", + labor_rates: {}, + commission_rates: { LAA: 35 } + } + ]); + }); +}); diff --git a/client/src/components/shop-teams/shop-employee-teams.list.jsx b/client/src/components/shop-teams/shop-employee-teams.list.jsx index 4c0ac837b..6acb89f45 100644 --- a/client/src/components/shop-teams/shop-employee-teams.list.jsx +++ b/client/src/components/shop-teams/shop-employee-teams.list.jsx @@ -2,20 +2,47 @@ import { Button } from "antd"; import queryString from "query-string"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; +import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx"; +import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import ResponsiveTable from "../responsive-table/responsive-table.component"; -export default function ShopEmployeeTeamsListComponent({ loading, employee_teams }) { +export default function ShopEmployeeTeamsListComponent({ + loading, + employee_teams, + onRequestTeamChange, + selectedTeamId +}) { const { t } = useTranslation(); const history = useNavigate(); const search = queryString.parse(useLocation().search); + const navigateToTeam = (employeeTeamId) => { + if (onRequestTeamChange) { + onRequestTeamChange(employeeTeamId); + return; + } + + history({ + search: queryString.stringify({ + ...search, + employeeTeamId + }) + }); + }; + + const clearTeamSelection = () => { + const { employeeTeamId, ...nextSearch } = search; + void employeeTeamId; + history({ + search: queryString.stringify(nextSearch) + }); + }; + const handleOnRowClick = (record) => { if (record) { - search.employeeTeamId = record.id; - history({ search: queryString.stringify(search) }); + navigateToTeam(record.id); } else { - delete search.employeeTeamId; - history({ search: queryString.stringify(search) }); + clearTeamSelection(); } }; const columns = [ @@ -27,43 +54,38 @@ export default function ShopEmployeeTeamsListComponent({ loading, employee_teams ]; return ( -
- { - return ( - - ); - }} - loading={loading} - pagination={{ placement: "top" }} - columns={columns} - mobileColumnKeys={["name"]} - rowKey="id" - dataSource={employee_teams} - rowSelection={{ - onSelect: (props) => { - search.employeeTeamId = props.id; - history({ search: queryString.stringify(search) }); - }, - type: "radio", - selectedRowKeys: [search.employeeTeamId] - }} - onRow={(record) => { - return { - onClick: () => { - handleOnRowClick(record); - } - }; - }} - /> -
+ navigateToTeam("new")}> + {t("employee_teams.actions.new")} + + ]} + > + {employee_teams.length === 0 ? ( + + ) : ( + navigateToTeam(props.id), + type: "radio", + selectedRowKeys: [selectedTeamId || search.employeeTeamId] + }} + onRow={(record) => { + return { + onClick: () => { + handleOnRowClick(record); + } + }; + }} + /> + )} + ); } diff --git a/client/src/components/shop-teams/shop-teams.container.jsx b/client/src/components/shop-teams/shop-teams.container.jsx index 9f5211f59..b55534b2d 100644 --- a/client/src/components/shop-teams/shop-teams.container.jsx +++ b/client/src/components/shop-teams/shop-teams.container.jsx @@ -1,36 +1,70 @@ +import { Form } from "antd"; import { useQuery } from "@apollo/client/react"; +import queryString from "query-string"; import { connect } from "react-redux"; +import { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import { QUERY_TEAMS } from "../../graphql/employee_teams.queries"; +import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx"; import AlertComponent from "../alert/alert.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list"; import ShopEmployeeTeamsFormComponent from "./shop-employee-teams.form.component"; -import { Col, Row } from "antd"; +import "./shop-teams.styles.scss"; const mapStateToProps = createStructuredSelector({}); function ShopTeamsContainer() { + const [form] = Form.useForm(); + const [isTeamFormDirty, setIsTeamFormDirty] = useState(false); + const navigate = useNavigate(); + const search = queryString.parse(useLocation().search); const { loading, error, data } = useQuery(QUERY_TEAMS, { fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); + const hasSelectedTeam = Boolean(search.employeeTeamId); + const hasDirtyTeamForm = Boolean(search.employeeTeamId) && isTeamFormDirty; + const confirmCloseDirtyTeam = useConfirmDirtyFormNavigation(hasDirtyTeamForm); + + const navigateToTeam = (employeeTeamId) => { + if (employeeTeamId === search.employeeTeamId) return; + if (!confirmCloseDirtyTeam()) return; + + setIsTeamFormDirty(false); + navigate({ + search: queryString.stringify({ + ...search, + employeeTeamId + }) + }); + }; if (error) return ; return ( -
- - - - - - - - - - -
+ +
+
+ +
+ {hasSelectedTeam ? ( +
+ +
+ ) : null} +
+
); } diff --git a/client/src/components/shop-teams/shop-teams.styles.scss b/client/src/components/shop-teams/shop-teams.styles.scss new file mode 100644 index 000000000..2582f7232 --- /dev/null +++ b/client/src/components/shop-teams/shop-teams.styles.scss @@ -0,0 +1,16 @@ +.shop-teams-layout { + display: grid; + gap: 16px; + align-items: start; +} + +.shop-teams-layout__list, +.shop-teams-layout__details { + min-width: 0; +} + +@media (min-width: 1700px) { + .shop-teams-layout--with-detail { + grid-template-columns: minmax(420px, 500px) minmax(0, 1fr); + } +} diff --git a/client/src/components/shop-users/shop-users.component.jsx b/client/src/components/shop-users/shop-users.component.jsx index d3fd16b45..d4e972b11 100644 --- a/client/src/components/shop-users/shop-users.component.jsx +++ b/client/src/components/shop-users/shop-users.component.jsx @@ -6,6 +6,7 @@ import { createStructuredSelector } from "reselect"; import { QUERY_SHOP_ASSOCIATIONS } from "../../graphql/user.queries"; import { selectBodyshop } from "../../redux/user/user.selectors"; import AlertComponent from "../alert/alert.component"; +import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import ResponsiveTable from "../responsive-table/responsive-table.component"; import ShopUsersAuthEdit from "../shop-users-auth-edit/shop-users-auth-edit.component"; @@ -66,7 +67,7 @@ export function ShopInfoUsersComponent({ bodyshop }) { return ; } return ( -
+ -
+ ); } diff --git a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx index ba3de7760..4f664a11c 100644 --- a/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx +++ b/client/src/components/time-ticket-modal/time-ticket-modal.container.jsx @@ -2,7 +2,7 @@ import { PageHeader } from "@ant-design/pro-layout"; import { useMutation, useQuery } from "@apollo/client/react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Form, Modal, Space } from "antd"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -15,21 +15,27 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import dayjs from "../../utils/day"; import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component"; import TimeTicketModalComponent from "./time-ticket-modal.component"; +import { insertAuditTrail } from "../../redux/application/application.actions.js"; +import AuditTrailMapping from "../../utils/AuditTrailMappings"; +import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js"; const mapStateToProps = createStructuredSelector({ timeTicketModal: selectTimeTicket, bodyshop: selectBodyshop }); const mapDispatchToProps = (dispatch) => ({ - toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")) + toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")), + insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) }); -export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop }) { +export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop, insertAuditTrail }) { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const { t } = useTranslation(); const [enterAgain, setEnterAgain] = useState(false); + const lastSubmittedRef = useRef(null); + const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0); const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET); @@ -48,47 +54,77 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); + const employees = EmployeeAutoCompleteData?.employees ?? []; const handleFinish = (values) => { + lastSubmittedRef.current = values; setLoading(true); - const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid); - if (timeTicketModal.context.id) { - updateTicket({ - variables: { - timeticketId: timeTicketModal.context.id, - timeticket: { - ...values, - rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null - } - } - }) - .then(handleMutationSuccess) - .catch(handleMutationError); - } else { - //Get selected employee rate. - insertTicket({ - variables: { - timeTicketInput: [ - { + const isEdit = Boolean(timeTicketModal.context.id); + const emps = employees.filter((employee) => employee.id === values.employeeid); + const mutation = isEdit + ? updateTicket({ + variables: { + timeticketId: timeTicketModal.context.id, + timeticket: { ...values, rate: - emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null, - bodyshopid: bodyshop.id, - created_by: timeTicketModal.context.created_by + emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null } - ] - } - }) - .then(handleMutationSuccess) - .catch(handleMutationError); - } + } + }) + : insertTicket({ + variables: { + timeTicketInput: [ + { + ...values, + rate: + emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null, + bodyshopid: bodyshop.id, + created_by: timeTicketModal.context.created_by + } + ] + } + }); + + mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError); }; - const handleMutationSuccess = () => { + const handleMutationSuccess = (result, isEdit) => { notification.success({ title: t("timetickets.successes.created") }); + const savedTicket = + result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {}; + const originalTicket = timeTicketModal.context?.timeticket ?? {}; + const submittedValues = { + ...(lastSubmittedRef.current ?? {}), + date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null, + employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null, + jobid: + lastSubmittedRef.current?.jobid ?? + savedTicket.jobid ?? + timeTicketModal.context.jobId ?? + originalTicket.job?.id ?? + originalTicket.jobid ?? + null + }; + const auditSummary = buildTimeTicketAuditSummary({ + originalTicket, + submittedValues, + employees + }); + + if (auditSummary.jobid) { + insertAuditTrail({ + jobid: auditSummary.jobid, + operation: isEdit + ? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details) + : AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details), + type: isEdit ? "timeticketupdated" : "timeticketcreated" + }); + } + // Refresh parent screens (Job Labor tab, etc.) if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch(); diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx index 276b852fe..ab95ce9d1 100644 --- a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx +++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx @@ -15,6 +15,18 @@ const mapDispatchToProps = () => ({ }); export default connect(mapStateToProps, mapDispatchToProps)(TimeTicketTaskModalComponent); +const getPayoutMethodLabel = (payoutMethod, t) => { + if (!payoutMethod) { + return ""; + } + + if (payoutMethod === "hourly" || payoutMethod === "commission") { + return t(`timetickets.labels.payout_methods.${payoutMethod}`); + } + + return payoutMethod; +}; + export function TimeTicketTaskModalComponent({ bodyshop, form, loading, completedTasks, unassignedHours }) { const { t } = useTranslation(); @@ -35,7 +47,15 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
- + {loading ? ( ) : ( @@ -93,33 +113,51 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete {t("timetickets.fields.cost_center")} {t("timetickets.fields.ciecacode")} {t("timetickets.fields.productivehrs")} + {t("timetickets.fields.payout_method")} + {t("timetickets.fields.rate")} + {t("timetickets.fields.amount")} - {fields.map((field, index) => ( - - - - - - - - - - - - - - - - - - - - - - - ))} + {fields.map((field, index) => { + const payoutMethod = form.getFieldValue(["timetickets", field.name, "payout_context", "payout_method"]); + + return ( + + + + + + + + + + + + + + + + + + + + + + {getPayoutMethodLabel(payoutMethod, t)} + + + + + + + + + + + + ); + })} diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.test.jsx b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.test.jsx new file mode 100644 index 000000000..0b3a36c34 --- /dev/null +++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.test.jsx @@ -0,0 +1,117 @@ +import { render, screen } from "@testing-library/react"; +import { Form } from "antd"; +import { describe, expect, it, vi } from "vitest"; +import { TimeTicketTaskModalComponent } from "./time-ticket-task-modal.component.jsx"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, values = {}) => { + const translations = { + "timetickets.fields.ro_number": "RO Number", + "timetickets.labels.task": "Task", + "bodyshop.fields.md_tasks_presets.percent": "Percent", + "bodyshop.fields.md_tasks_presets.hourstype": "Labor Types", + "bodyshop.fields.md_tasks_presets.nextstatus": "Next Status", + "timetickets.labels.claimtaskpreview": "Claim Task Preview", + "timetickets.fields.employee": "Employee", + "timetickets.fields.cost_center": "Cost Center", + "timetickets.fields.ciecacode": "Labor Type", + "timetickets.fields.productivehrs": "Hours", + "timetickets.fields.payout_method": "Payout Method", + "timetickets.fields.rate": "Rate", + "timetickets.fields.amount": "Amount", + "timetickets.labels.payout_methods.commission": "Commission", + "timetickets.labels.payout_methods.hourly": "Hourly", + "timetickets.labels.payrollclaimedtasks": "Payroll claimed tasks are ready.", + "tt_approvals.labels.approval_queue_in_use": "Approval queue is enabled." + }; + + if (key === "timetickets.validation.unassignedlines") { + return `${values.unassignedHours} hours remain unassigned.`; + } + + return translations[key] || key; + } + }) +})); + +vi.mock("../form-items-formatted/read-only-form-item.component", () => ({ + default: ({ value }) => {value} +})); + +vi.mock("../job-search-select/job-search-select.component", () => ({ + default: () =>
Job Search
+})); + +function TestHarness({ unassignedHours = 0 }) { + const [form] = Form.useForm(); + + return ( +
+ + + ); +} + +describe("TimeTicketTaskModalComponent", () => { + it("shows preview payout methods for both commission and hourly tickets", () => { + render(); + + expect(screen.getByText("Claim Task Preview")).toBeInTheDocument(); + expect(screen.getByText("Commission")).toBeInTheDocument(); + expect(screen.getByText("Hourly")).toBeInTheDocument(); + expect(screen.getByText("Payroll claimed tasks are ready.")).toBeInTheDocument(); + }); + + it("shows the unassigned-hours alert when payroll assignments are incomplete", () => { + render(); + + expect(screen.getByText("1.25 hours remain unassigned.")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx index 22bf9ea94..3e4b62ace 100644 --- a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx +++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx @@ -25,6 +25,22 @@ const mapDispatchToProps = (dispatch) => ({ }); export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer); +const toFiniteNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const getPreviewPayoutAmount = (ticket) => { + const productiveHours = toFiniteNumber(ticket?.productivehrs); + const rate = toFiniteNumber(ticket?.rate); + + if (productiveHours === null || rate === null) { + return null; + } + + return productiveHours * rate; +}; + export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) { const [form] = Form.useForm(); const { context, open, actions } = timeTicketTasksModal; @@ -90,7 +106,12 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke if (actions?.refetch) actions.refetch(); toggleModalVisible(); } else if (handleFinish === false) { - form.setFieldsValue({ timetickets: data.ticketsToInsert }); + form.setFieldsValue({ + timetickets: (data.ticketsToInsert || []).map((ticket) => ({ + ...ticket, + payoutamount: getPreviewPayoutAmount(ticket) + })) + }); setUnassignedHours(data.unassignedHours); } else { notification.error({ @@ -101,7 +122,9 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke } } catch (error) { notification.error({ - title: t("timetickets.errors.creating", { message: error.message }) + title: t("timetickets.errors.creating", { + message: error.response?.data?.error || error.message + }) }); } finally { setLoading(false); diff --git a/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx b/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx index e1b591414..63090257f 100644 --- a/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx +++ b/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx @@ -130,7 +130,15 @@ export function TtApprovalsListComponent({ key: "memo", sorter: (a, b) => alphaSort(a.memo, b.memo), sortOrder: state.sortedInfo.columnKey === "memo" && state.sortedInfo.order, - render: (text, record) => (record.clockon || record.clockoff ? t(record.memo) : record.memo) + render: (text, record) => (record.memo?.startsWith("timetickets.labels") ? t(record.memo) : record.memo) + }, + { + title: t("timetickets.fields.task_name"), + dataIndex: "task_name", + key: "task_name", + sorter: (a, b) => alphaSort(a.task_name, b.task_name), + sortOrder: state.sortedInfo.columnKey === "task_name" && state.sortedInfo.order, + render: (text, record) => record.task_name || "" }, { title: t("timetickets.fields.clockon"), @@ -140,12 +148,12 @@ export function TtApprovalsListComponent({ render: (text, record) => {record.clockon} }, { - title: "Pay", + title: t("timetickets.fields.pay"), dataIndex: "pay", key: "pay", render: (text, record) => - Dinero({ amount: Math.round(record.rate * 100) }) - .multiply(record.flat_rate ? record.productivehrs : record.actualhrs) + Dinero({ amount: Math.round((record.rate || 0) * 100) }) + .multiply(record.flat_rate ? record.productivehrs || 0 : record.actualhrs || 0) .toFormat("$0.00") } ]; @@ -184,7 +192,7 @@ export function TtApprovalsListComponent({ - + diff --git a/client/src/graphql/bodyshop.queries.js b/client/src/graphql/bodyshop.queries.js index 28c81b05b..38dd5bde4 100644 --- a/client/src/graphql/bodyshop.queries.js +++ b/client/src/graphql/bodyshop.queries.js @@ -152,6 +152,8 @@ export const QUERY_BODYSHOP = gql` id employeeid labor_rates + payout_method + commission_rates percentage } } @@ -285,6 +287,8 @@ export const UPDATE_SHOP = gql` id employeeid labor_rates + payout_method + commission_rates percentage } } diff --git a/client/src/graphql/employee_teams.queries.js b/client/src/graphql/employee_teams.queries.js index bfb5104de..20ce91406 100644 --- a/client/src/graphql/employee_teams.queries.js +++ b/client/src/graphql/employee_teams.queries.js @@ -10,6 +10,8 @@ export const QUERY_TEAMS = gql` id employeeid labor_rates + payout_method + commission_rates percentage } } @@ -29,6 +31,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql` employeeid id labor_rates + payout_method + commission_rates percentage } } @@ -40,6 +44,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql` employeeid id labor_rates + payout_method + commission_rates percentage } } @@ -52,6 +58,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql` employeeid id labor_rates + payout_method + commission_rates percentage } } @@ -69,6 +77,8 @@ export const INSERT_EMPLOYEE_TEAM = gql` employeeid id labor_rates + payout_method + commission_rates percentage } } @@ -86,6 +96,8 @@ export const QUERY_EMPLOYEE_TEAM_BY_ID = gql` employeeid id labor_rates + payout_method + commission_rates percentage } } diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 35a3a1f26..e12d23da3 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -197,6 +197,7 @@ export const QUERY_EXACT_JOB_IN_PRODUCTION = gql` employee_prep employee_csr date_repairstarted + dms_id joblines_status { part_type status @@ -269,6 +270,7 @@ export const QUERY_EXACT_JOBS_IN_PRODUCTION = gql` employee_prep employee_csr date_repairstarted + dms_id joblines_status { part_type status @@ -2671,6 +2673,7 @@ export const QUERY_JOBS_IN_PRODUCTION = gql` suspended job_totals date_repairstarted + dms_id joblines_status { part_type status diff --git a/client/src/graphql/timetickets.queries.js b/client/src/graphql/timetickets.queries.js index b69a9552c..6536d04cc 100644 --- a/client/src/graphql/timetickets.queries.js +++ b/client/src/graphql/timetickets.queries.js @@ -260,6 +260,7 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql` id clockon clockoff + created_by employeeid productivehrs actualhrs @@ -267,6 +268,9 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql` date memo flat_rate + task_name + payout_context + ttapprovalqueueid commited_by committed_at } diff --git a/client/src/graphql/tt-approvals.queries.js b/client/src/graphql/tt-approvals.queries.js index 4301debbb..5aea2eb21 100644 --- a/client/src/graphql/tt-approvals.queries.js +++ b/client/src/graphql/tt-approvals.queries.js @@ -23,7 +23,14 @@ export const QUERY_ALL_TT_APPROVALS_PAGINATED = gql` ciecacode cost_center date + memo + flat_rate + clockon + clockoff rate + created_by + task_name + payout_context } tt_approval_queue_aggregate { aggregate { @@ -42,9 +49,16 @@ export const INSERT_NEW_TT_APPROVALS = gql` productivehrs actualhrs ciecacode + cost_center date memo flat_rate + rate + clockon + clockoff + created_by + task_name + payout_context } } } @@ -65,6 +79,11 @@ export const QUERY_TT_APPROVALS_BY_IDS = gql` ciecacode bodyshopid cost_center + clockon + clockoff + created_by + task_name + payout_context } } `; diff --git a/client/src/hooks/useConfirmDirtyFormNavigation.jsx b/client/src/hooks/useConfirmDirtyFormNavigation.jsx new file mode 100644 index 000000000..5b77bbf88 --- /dev/null +++ b/client/src/hooks/useConfirmDirtyFormNavigation.jsx @@ -0,0 +1,11 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +export default function useConfirmDirtyFormNavigation(isDirty) { + const { t } = useTranslation(); + + return useCallback(() => { + if (!isDirty) return true; + return window.confirm(t("general.messages.unsavedchangespopup")); + }, [isDirty, t]); +} diff --git a/client/src/pages/jobs-create/jobs-create.container.jsx b/client/src/pages/jobs-create/jobs-create.container.jsx index 5b5627ef1..b3cc808b2 100644 --- a/client/src/pages/jobs-create/jobs-create.container.jsx +++ b/client/src/pages/jobs-create/jobs-create.container.jsx @@ -8,13 +8,14 @@ import { createStructuredSelector } from "reselect"; import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; import { INSERT_NEW_JOB } from "../../graphql/jobs.queries"; import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries"; -import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; +import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; import JobsCreateComponent from "./jobs-create.component"; import JobCreateContext from "./jobs-create.context"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { logImEXEvent } from "../../firebase/firebase.utils"; +import AuditTrailMapping from "../../utils/AuditTrailMappings.js"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -22,10 +23,11 @@ const mapStateToProps = createStructuredSelector({ }); const mapDispatchToProps = (dispatch) => ({ setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), - setSelectedHeader: (key) => dispatch(setSelectedHeader(key)) + setSelectedHeader: (key) => dispatch(setSelectedHeader(key)), + insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })) }); -function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) { +function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser, insertAuditTrail }) { const { t } = useTranslation(); const notification = useNotification(); @@ -84,6 +86,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr newJobId: resp.data.insert_jobs.returning[0].id }); logImEXEvent("manual_job_create_completed", {}); + insertAuditTrail({ + jobid: resp.data.insert_jobs.returning[0].id, + operation: AuditTrailMapping.jobmanualcreate(), + type: "jobmanualcreate" + }); setIsSubmitting(false); }) .catch((error) => { diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index e89fb9a1a..cdb5c413c 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -120,8 +120,9 @@ "appointmentinsert": "Appointment created. Appointment Date: {{start}}.", "assignedlinehours": "Assigned job lines totaling {{hours}} units to {{team}}.", "billdeleted": "Bill with invoice number {{invoice_number}} deleted.", + "billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.", "billposted": "Bill with invoice number {{invoice_number}} posted.", - "billupdated": "Bill with invoice number {{invoice_number}} updated.", + "billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{details}}.", "failedpayment": "Failed payment attempt.", "jobassignmentchange": "Employee {{name}} assigned to {{operation}}", "jobassignmentremoved": "Employee assignment removed for {{operation}}", @@ -136,6 +137,9 @@ "jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.", "jobinvoiced": "Job has been invoiced.", "jobioucreated": "IOU Created.", + "joblineupdate": "Job line {{lineDescription}} updated with the following details: {{details}}.", + "jobmanualcreate": "Job manually created.", + "jobmanuallineinsert": "Job line manually added with the following details: {{details}}.", "jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.", "jobnoteadded": "Note added to Job.", "jobnotedeleted": "Note deleted from Job.", @@ -151,7 +155,9 @@ "tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}", "tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}", "tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}", - "tasks_updated": "Task '{{title}}' updated by {{updatedBy}}" + "tasks_updated": "Task '{{title}}' updated by {{updatedBy}}", + "timeticketcreated": "Time Ticket for {{employee}} on {{date}} created with the following details: {{details}}.", + "timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}" } }, "billlines": { @@ -292,7 +298,23 @@ }, "bodyshop": { "actions": { + "add_adjuster": "Add Adjuster", + "add_control_number": "Add Control Number", + "add_cost_center": "Add Cost Center", + "add_courtesy_car_rate_preset": "Add Courtesy Car Contract Rate Preset", + "add_delivery_checklist_item": "Add Delivery Checklist Item", + "add_dms_allocation": "Add DMS Allocation", + "add_estimator": "Add Estimator", + "add_insurance_company": "Add Insurance Company", + "add_intake_checklist_item": "Add Intake Checklist Item", + "add_jobline_preset": "Add Jobline Preset", + "add_messaging_preset": "Add Messaging Preset", + "add_note_preset": "Add Note Preset", + "add_parts_order_comment": "Add Parts Order Comment", + "add_production_status_color": "Add Production Status Color", + "add_profit_center": "Add Profit Center", "add_task_preset": "Add Task Preset", + "add_to_email_preset": "Add To Email Preset", "addapptcolor": "Add Appointment Color", "addbucket": "Add Definition", "addpartslocation": "Add Parts Location", @@ -301,14 +323,17 @@ "addtemplate": "Add Template", "newlaborrate": "New Labor Rate", "newsalestaxcode": "New Sales Tax Code", + "save_shop_information": "Save Shop Information", "newstatus": "Add Status", "testrender": "Test Render" }, "errors": { "creatingdefaultview": "Error creating default view.", + "duplicate_job_status": "Duplicate job status. Each job status must be unique.", "duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique", "loading": "Unable to load shop details. Please call technical support.", - "saving": "Error encountered while saving. {{message}}" + "saving": "Error encountered while saving. {{message}}", + "task_preset_allocation_exceeded": "{{laborType}} task preset total is {{total}}% and cannot exceed 100%." }, "fields": { "ReceivableCustomField": "QBO Receivable Custom Field {{number}}", @@ -338,6 +363,7 @@ "require_actual_delivery_date": "Require Actual Delivery", "templates": "Delivery Templates" }, + "disableBillCostCalculation": "Disable Automatic Bill Cost Calculation", "dms": { "apcontrol": "AP Control Number", "appostingaccount": "AP Posting Account", @@ -401,6 +427,35 @@ "logo_img_path": "Shop Logo", "logo_img_path_height": "Logo Image Height", "logo_img_path_width": "Logo Image Width", + "scoreboard_setup": { + "daily_body_target": "Daily Body Target", + "daily_paint_target": "Daily Paint Target", + "ignore_blocked_days": "Ignore Blocked Days", + "last_number_working_days": "Last Number of Working Days", + "production_target_hours": "Production Target Hours" + }, + "system_settings": { + "auto_email": { + "attach_pdf_to_email": "Attach PDF to Sent Emails?", + "from_emails": "Additional From Emails", + "parts_order_cc": "Parts Orders CC", + "parts_return_slip_cc": "Parts Returns CC" + }, + "job_costing": { + "paint_hour_split": "Paint Hour Split", + "paint_materials_hourly_cost_rate": "Paint Materials Hourly Cost Rate", + "prep_hour_split": "Prep Hour Split", + "shop_materials_hourly_cost_rate": "Shop Materials Hourly Cost Rate", + "target_touch_time": "Target Touch Time", + "use_paint_scale_data": "Use Paint Scale Data" + }, + "local_media_server": { + "enabled": "Enabled", + "http_path": "HTTP Path", + "network_path": "Network Path", + "token": "Token" + } + }, "md_categories": "Categories", "md_ccc_rates": "Courtesy Car Contract Rate Presets", "md_classes": "Classes", @@ -461,9 +516,13 @@ "use_approvals": "Use Time Ticket Approval Queue" }, "messaginglabel": "Messaging Preset Label", + "messaginglabel_short": "Label", "messagingtext": "Messaging Preset Text", + "messagingtext_short": "Text", "noteslabel": "Note Label", + "noteslabel_short": "Label", "notestext": "Note Text", + "notestext_short": "Text", "notifications": { "description": "Select employees to automatically follow new jobs and receive notifications for job updates.", "invalid_followers": "Invalid selection. Please select valid employees.", @@ -597,12 +656,17 @@ "federal_tax_itc": "Federal Tax Credit", "gogcode": "GOG Code (BreakOut)", "gst_override": "GST Override Account #", + "invoice_federal_tax_rate_short": "Federal Tax Rate", + "invoice_local_tax_rate_short": "Local Tax Rate", + "invoice_state_tax_rate_short": "State Tax Rate", "invoiceexemptcode": "QuickBooks US - Invoice Tax Exempt Code", + "invoiceexemptcode_short": "Invoice Tax Exempt Code", "item_type": "Item Type", "item_type_freight": "Freight", "item_type_gog": "GOG", "item_type_paint": "Paint Materials", "itemexemptcode": "QuickBooks US - Line Item Tax Exempt Code", + "itemexemptcode_short": "Line Item Tax Exempt Code", "la1": "LA1", "la2": "LA2", "la3": "LA3", @@ -719,6 +783,7 @@ "customtemplates": "Custom Templates", "defaultcostsmapping": "Default Costs Mapping", "defaultprofitsmapping": "Default Profits Mapping", + "dms_setup": "DMS Setup", "deliverchecklist": "Delivery Checklist", "dms": { "cdk": { @@ -735,24 +800,33 @@ }, "emaillater": "Email Later", "employee_teams": "Employee Teams", + "employee_options": "Employee Options", + "employee_rates": "Employee Rates", + "employee_vacation": "Employee Vacation", "employees": "Employees", "estimators": "Estimators", "filehandlers": "Adjusters", "imexpay": "ImEX Pay", "insurancecos": "Insurance Companies", + "intake_delivery": "Intake / Delivery Options", "intakechecklist": "Intake Checklist", "intellipay_cash_discount": "Please ensure that cash discounting has been enabled on your merchant account. Reach out to IntelliPay Support if you need assistance. ", + "job_status_options": "Job Status Options", "jobstatuses": "Job Statuses", "laborrates": "Labor Rates", "licensing": "Licensing", "md_parts_scan": "Parts Scan Rules", "md_ro_guard": "RO Guard", + "md_ro_guard_options": "RO Guard Options", "md_tasks_presets": "Tasks Presets", + "task_preset_options": "Task Preset Options", "md_to_emails": "Preset To Emails", "md_to_emails_emails": "Emails", "messagingpresets": "Messaging Presets", + "notification_options": "Notification Options", "notemplatesavailable": "No templates available to add.", "notespresets": "Notes Presets", + "jump_to_section": "Jump to section", "notifications": { "followers": "Notifications" }, @@ -766,11 +840,22 @@ "qbo_departmentid": "QBO Department ID", "qbo_usa": "QBO USA Compatibility", "rbac": "Role Based Access Control", + "rbac_options": "Role Based Access Control Options", "responsibilitycenters": { "costs": "Cost Centers", + "default_tax_setup": "Default Tax Setup", + "invoices": "Invoices", "profits": "Profit Centers", + "quickbooks_qbd": "QuickBooks / QBD", + "quickbooks_us": "QuickBooks US", "sales_tax_codes": "Sales Tax Codes", "tax_accounts": "Tax Accounts", + "tax_rate_short": "Rate", + "tax_surcharge_short": "Surcharge", + "tax_threshold_short": "Threshold", + "tax_tier_card": "Tier {{typeNumIterator}}", + "tax_tier_short": "Tier", + "tax_type_card": "Tax Type {{typeNum}}", "title": "Responsibility Centers", "ttl_adjustment": "Subtotal Adjustment Account", "ttl_tax_adjustment": "Tax Adjustment Account" @@ -778,6 +863,9 @@ "roguard": { "title": "RO Guard" }, + "autoemail": "Auto Email", + "jobcosting": "Job Costing", + "localmediaserver": "Local Media Server", "romepay": "Rome Pay", "scheduling": "SMART Scheduling", "scoreboardsetup": "Scoreboard Setup", @@ -785,6 +873,7 @@ "shopinfo": "Shop Information", "shoprates": "Shop Rates", "speedprint": "Speed Print Configuration", + "speedprint_configurations": "Speed Print Configurations", "ssbuckets": "Job Size Definitions", "systemsettings": "System Settings", "task-presets": "Task Presets", @@ -808,7 +897,8 @@ "tooltips": { "md_parts_scan": { "update_value_tooltip": "Some fields require coded values in order to function properly (e.g. labor and part types). Please reach out to support if you have any questions." - } + }, + "reset-color": "Reset color" }, "validation": { "centermustexist": "The chosen responsibility center does not exist.", @@ -1176,21 +1266,42 @@ "employee_teams": { "actions": { "new": "New Team", - "newmember": "New Team Member" + "newmember": "New Team Member", + "save_team": "Save Employee Team" + }, + "errors": { + "allocation_total_exact": "Team allocation must total exactly 100%.", + "duplicate_member": "Each employee can only appear once per team.", + "minimum_one_member": "Add at least one team member." }, "fields": { "active": "Active", + "allocation": "Allocation", + "allocation_percentage": "Allocation %", "employeeid": "Employee", "max_load": "Max Load", "name": "Team Name", + "payout_method": "Payout Method", "percentage": "Percent" + }, + "labels": { + "allocation_total": "Allocation Total: {{total}}%", + "members": "Members", + "team_options": "Team Options" + }, + "options": { + "commission": "Commission", + "commission_percentage": "Commission %", + "hourly": "Hourly" } }, "employees": { "actions": { + "addrate": "Add Rate", "addvacation": "Add Vacation", "new": "New Employee", "newrate": "New Rate", + "save_employee": "Save Employee", "select": "Select Employee" }, "errors": { @@ -1222,6 +1333,7 @@ "labels": { "actions": "Actions", "active": "Active", + "employee_number_short": "Employee #", "endmustbeafterstart": "End date must be after start date.", "flat_rate": "Flat Rate", "inactive": "Inactive", @@ -1365,6 +1477,7 @@ "beta": "BETA", "cancel": "Are you sure you want to cancel? Your changes will not be saved.", "changelog": "Change Log", + "click_to_begin": "Click {{action}} to begin", "clear": "Clear", "confirmpassword": "Confirm Password", "created_at": "Created At", @@ -1910,10 +2023,15 @@ "employee_refinish": "Refinish", "est_addr1": "Estimator Address", "est_co_nm": "Estimator Company", + "est_co_nm_short": "Company", "est_ct_fn": "Estimator First Name", + "est_ct_fn_short": "First Name", "est_ct_ln": "Estimator Last Name", + "est_ct_ln_short": "Last Name", "est_ea": "Estimator Email", + "est_ea_short": "Email", "est_ph1": "Estimator Phone #", + "est_ph1_short": "Phone #", "estimate_approved": "Estimate Approved", "estimate_sent_approval": "Estimate Sent for Approval", "federal_tax_payable": "Federal Tax Payable", @@ -1926,9 +2044,13 @@ "ins_co_nm": "Insurance Company Name", "ins_co_nm_short": "Ins. Co.", "ins_ct_fn": "Adjuster First Name", + "ins_ct_fn_short": "First Name", "ins_ct_ln": "Adjuster Last Name", + "ins_ct_ln_short": "Last Name", "ins_ea": "Adjuster Email", + "ins_ea_short": "Email", "ins_ph1": "Adjuster Phone #", + "ins_ph1_short": "Phone #", "intake": { "label": "Label", "max": "Maximum", @@ -3610,6 +3732,7 @@ }, "fields": { "actualhrs": "Actual Hours", + "amount": "Amount", "ciecacode": "CIECA Code", "clockhours": "Clock Hours", "clockoff": "Clock Off", @@ -3624,7 +3747,10 @@ "employee_team": "Employee Team", "flat_rate": "Flat Rate?", "memo": "Memo", + "pay": "Pay", + "payout_method": "Payout Method", "productivehrs": "Productive Hours", + "rate": "Rate", "ro_number": "Job to Post Against", "task_name": "Task" }, @@ -3643,6 +3769,10 @@ "lunch": "Lunch", "new": "New Time Ticket", "payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.", + "payout_methods": { + "commission": "Commission", + "hourly": "Hourly" + }, "pmbreak": "PM Break", "pmshift": "PM Shift", "shift": "Shift", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index c836b6d0d..3086d35b3 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -121,7 +121,7 @@ "assignedlinehours": "", "billdeleted": "", "billposted": "", - "billupdated": "", + "billmarkforreexport": "", "failedpayment": "", "jobassignmentchange": "", "jobassignmentremoved": "", @@ -292,7 +292,23 @@ }, "bodyshop": { "actions": { + "add_adjuster": "", + "add_control_number": "", + "add_cost_center": "", + "add_courtesy_car_rate_preset": "", + "add_delivery_checklist_item": "", + "add_dms_allocation": "", + "add_estimator": "", + "add_insurance_company": "", + "add_intake_checklist_item": "", + "add_jobline_preset": "", + "add_messaging_preset": "", + "add_note_preset": "", + "add_parts_order_comment": "", + "add_production_status_color": "", + "add_profit_center": "", "add_task_preset": "", + "add_to_email_preset": "", "addapptcolor": "", "addbucket": "", "addpartslocation": "", @@ -301,14 +317,17 @@ "addtemplate": "", "newlaborrate": "", "newsalestaxcode": "", + "save_shop_information": "", "newstatus": "", "testrender": "" }, "errors": { "creatingdefaultview": "", + "duplicate_job_status": "", "duplicate_insurance_company": "", "loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.", - "saving": "" + "saving": "", + "task_preset_allocation_exceeded": "" }, "fields": { "ReceivableCustomField": "", @@ -338,6 +357,7 @@ "require_actual_delivery_date": "", "templates": "" }, + "disableBillCostCalculation": "", "dms": { "apcontrol": "", "appostingaccount": "", @@ -401,6 +421,35 @@ "logo_img_path": "", "logo_img_path_height": "", "logo_img_path_width": "", + "scoreboard_setup": { + "daily_body_target": "", + "daily_paint_target": "", + "ignore_blocked_days": "", + "last_number_working_days": "", + "production_target_hours": "" + }, + "system_settings": { + "auto_email": { + "attach_pdf_to_email": "", + "from_emails": "", + "parts_order_cc": "", + "parts_return_slip_cc": "" + }, + "job_costing": { + "paint_hour_split": "", + "paint_materials_hourly_cost_rate": "", + "prep_hour_split": "", + "shop_materials_hourly_cost_rate": "", + "target_touch_time": "", + "use_paint_scale_data": "" + }, + "local_media_server": { + "enabled": "", + "http_path": "", + "network_path": "", + "token": "" + } + }, "md_categories": "", "md_ccc_rates": "", "md_classes": "", @@ -461,9 +510,13 @@ "use_approvals": "" }, "messaginglabel": "", + "messaginglabel_short": "", "messagingtext": "", + "messagingtext_short": "", "noteslabel": "", + "noteslabel_short": "", "notestext": "", + "notestext_short": "", "notifications": { "description": "", "invalid_followers": "", @@ -597,12 +650,17 @@ "federal_tax_itc": "", "gogcode": "", "gst_override": "", + "invoice_federal_tax_rate_short": "", + "invoice_local_tax_rate_short": "", + "invoice_state_tax_rate_short": "", "invoiceexemptcode": "", + "invoiceexemptcode_short": "", "item_type": "Item Type", "item_type_freight": "", "item_type_gog": "", "item_type_paint": "", "itemexemptcode": "", + "itemexemptcode_short": "", "la1": "", "la2": "", "la3": "", @@ -719,6 +777,7 @@ "customtemplates": "", "defaultcostsmapping": "", "defaultprofitsmapping": "", + "dms_setup": "", "deliverchecklist": "", "dms": { "cdk": { @@ -735,24 +794,33 @@ }, "emaillater": "", "employee_teams": "", + "employee_options": "", + "employee_rates": "", + "employee_vacation": "", "employees": "", "estimators": "", "filehandlers": "", "imexpay": "", "insurancecos": "", + "intake_delivery": "", "intakechecklist": "", "intellipay_cash_discount": "", + "job_status_options": "", "jobstatuses": "", "laborrates": "", "licensing": "", "md_parts_scan": "", "md_ro_guard": "", + "md_ro_guard_options": "", "md_tasks_presets": "", + "task_preset_options": "", "md_to_emails": "", "md_to_emails_emails": "", "messagingpresets": "", + "notification_options": "", "notemplatesavailable": "", "notespresets": "", + "jump_to_section": "", "notifications": { "followers": "" }, @@ -766,11 +834,22 @@ "qbo_departmentid": "", "qbo_usa": "", "rbac": "", + "rbac_options": "", "responsibilitycenters": { "costs": "", + "default_tax_setup": "", + "invoices": "", "profits": "", + "quickbooks_qbd": "", + "quickbooks_us": "", "sales_tax_codes": "", "tax_accounts": "", + "tax_rate_short": "", + "tax_surcharge_short": "", + "tax_threshold_short": "", + "tax_tier_card": "", + "tax_tier_short": "", + "tax_type_card": "", "title": "", "ttl_adjustment": "", "ttl_tax_adjustment": "" @@ -778,6 +857,9 @@ "roguard": { "title": "" }, + "autoemail": "", + "jobcosting": "", + "localmediaserver": "", "romepay": "", "scheduling": "", "scoreboardsetup": "", @@ -785,6 +867,7 @@ "shopinfo": "", "shoprates": "", "speedprint": "", + "speedprint_configurations": "", "ssbuckets": "", "systemsettings": "", "task-presets": "", @@ -808,7 +891,8 @@ "tooltips": { "md_parts_scan": { "update_value_tooltip": "" - } + }, + "reset-color": "" }, "validation": { "centermustexist": "", @@ -1176,21 +1260,42 @@ "employee_teams": { "actions": { "new": "", - "newmember": "" + "newmember": "", + "save_team": "" + }, + "errors": { + "allocation_total_exact": "", + "duplicate_member": "", + "minimum_one_member": "" }, "fields": { "active": "", + "allocation": "", + "allocation_percentage": "", "employeeid": "", "max_load": "", "name": "", + "payout_method": "", "percentage": "" + }, + "labels": { + "allocation_total": "", + "members": "", + "team_options": "" + }, + "options": { + "commission": "", + "commission_percentage": "", + "hourly": "" } }, "employees": { "actions": { + "addrate": "", "addvacation": "", "new": "Nuevo empleado", "newrate": "", + "save_employee": "", "select": "" }, "errors": { @@ -1222,6 +1327,7 @@ "labels": { "actions": "", "active": "", + "employee_number_short": "", "endmustbeafterstart": "", "flat_rate": "", "inactive": "", @@ -1365,6 +1471,7 @@ "beta": "", "cancel": "", "changelog": "", + "click_to_begin": "", "clear": "", "confirmpassword": "", "created_at": "", @@ -1910,10 +2017,15 @@ "employee_refinish": "", "est_addr1": "Dirección del tasador", "est_co_nm": "Tasador", + "est_co_nm_short": "", "est_ct_fn": "Nombre del tasador", + "est_ct_fn_short": "", "est_ct_ln": "Apellido del tasador", + "est_ct_ln_short": "", "est_ea": "Correo electrónico del tasador", + "est_ea_short": "", "est_ph1": "Número de teléfono del tasador", + "est_ph1_short": "", "estimate_approved": "", "estimate_sent_approval": "", "federal_tax_payable": "Impuesto federal por pagar", @@ -1926,9 +2038,13 @@ "ins_co_nm": "Nombre de la compañía de seguros", "ins_co_nm_short": "", "ins_ct_fn": "Nombre del controlador de archivos", + "ins_ct_fn_short": "", "ins_ct_ln": "Apellido del manejador de archivos", + "ins_ct_ln_short": "", "ins_ea": "Correo electrónico del controlador de archivos", + "ins_ea_short": "", "ins_ph1": "File Handler Phone #", + "ins_ph1_short": "", "intake": { "label": "", "max": "", @@ -3610,6 +3726,7 @@ }, "fields": { "actualhrs": "", + "amount": "", "ciecacode": "", "clockhours": "", "clockoff": "", @@ -3624,7 +3741,10 @@ "employee_team": "", "flat_rate": "", "memo": "", + "pay": "", + "payout_method": "", "productivehrs": "", + "rate": "", "ro_number": "", "task_name": "" }, @@ -3643,6 +3763,10 @@ "lunch": "", "new": "", "payrollclaimedtasks": "", + "payout_methods": { + "commission": "", + "hourly": "" + }, "pmbreak": "", "pmshift": "", "shift": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 059bd9bec..c561d13a3 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -120,8 +120,8 @@ "appointmentinsert": "", "assignedlinehours": "", "billdeleted": "", + "billmarkforreexport": "", "billposted": "", - "billupdated": "", "failedpayment": "", "jobassignmentchange": "", "jobassignmentremoved": "", @@ -292,7 +292,23 @@ }, "bodyshop": { "actions": { + "add_adjuster": "", + "add_control_number": "", + "add_cost_center": "", + "add_courtesy_car_rate_preset": "", + "add_delivery_checklist_item": "", + "add_dms_allocation": "", + "add_estimator": "", + "add_insurance_company": "", + "add_intake_checklist_item": "", + "add_jobline_preset": "", + "add_messaging_preset": "", + "add_note_preset": "", + "add_parts_order_comment": "", + "add_production_status_color": "", + "add_profit_center": "", "add_task_preset": "", + "add_to_email_preset": "", "addapptcolor": "", "addbucket": "", "addpartslocation": "", @@ -301,14 +317,17 @@ "addtemplate": "", "newlaborrate": "", "newsalestaxcode": "", + "save_shop_information": "", "newstatus": "", "testrender": "" }, "errors": { "creatingdefaultview": "", + "duplicate_job_status": "", "duplicate_insurance_company": "", "loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.", - "saving": "" + "saving": "", + "task_preset_allocation_exceeded": "" }, "fields": { "ReceivableCustomField": "", @@ -338,6 +357,7 @@ "require_actual_delivery_date": "", "templates": "" }, + "disableBillCostCalculation": "", "dms": { "apcontrol": "", "appostingaccount": "", @@ -401,6 +421,35 @@ "logo_img_path": "", "logo_img_path_height": "", "logo_img_path_width": "", + "scoreboard_setup": { + "daily_body_target": "", + "daily_paint_target": "", + "ignore_blocked_days": "", + "last_number_working_days": "", + "production_target_hours": "" + }, + "system_settings": { + "auto_email": { + "attach_pdf_to_email": "", + "from_emails": "", + "parts_order_cc": "", + "parts_return_slip_cc": "" + }, + "job_costing": { + "paint_hour_split": "", + "paint_materials_hourly_cost_rate": "", + "prep_hour_split": "", + "shop_materials_hourly_cost_rate": "", + "target_touch_time": "", + "use_paint_scale_data": "" + }, + "local_media_server": { + "enabled": "", + "http_path": "", + "network_path": "", + "token": "" + } + }, "md_categories": "", "md_ccc_rates": "", "md_classes": "", @@ -461,9 +510,13 @@ "use_approvals": "" }, "messaginglabel": "", + "messaginglabel_short": "", "messagingtext": "", + "messagingtext_short": "", "noteslabel": "", + "noteslabel_short": "", "notestext": "", + "notestext_short": "", "notifications": { "description": "", "invalid_followers": "", @@ -597,12 +650,17 @@ "federal_tax_itc": "", "gogcode": "", "gst_override": "", + "invoice_federal_tax_rate_short": "", + "invoice_local_tax_rate_short": "", + "invoice_state_tax_rate_short": "", "invoiceexemptcode": "", + "invoiceexemptcode_short": "", "item_type": "Item Type", "item_type_freight": "", "item_type_gog": "", "item_type_paint": "", "itemexemptcode": "", + "itemexemptcode_short": "", "la1": "", "la2": "", "la3": "", @@ -719,6 +777,7 @@ "customtemplates": "", "defaultcostsmapping": "", "defaultprofitsmapping": "", + "dms_setup": "", "deliverchecklist": "", "dms": { "cdk": { @@ -735,24 +794,33 @@ }, "emaillater": "", "employee_teams": "", + "employee_options": "", + "employee_rates": "", + "employee_vacation": "", "employees": "", "estimators": "", "filehandlers": "", "imexpay": "", "insurancecos": "", + "intake_delivery": "", "intakechecklist": "", "intellipay_cash_discount": "", + "job_status_options": "", "jobstatuses": "", "laborrates": "", "licensing": "", "md_parts_scan": "", "md_ro_guard": "", + "md_ro_guard_options": "", "md_tasks_presets": "", + "task_preset_options": "", "md_to_emails": "", "md_to_emails_emails": "", "messagingpresets": "", + "notification_options": "", "notemplatesavailable": "", "notespresets": "", + "jump_to_section": "", "notifications": { "followers": "" }, @@ -766,11 +834,22 @@ "qbo_departmentid": "", "qbo_usa": "", "rbac": "", + "rbac_options": "", "responsibilitycenters": { "costs": "", + "default_tax_setup": "", + "invoices": "", "profits": "", + "quickbooks_qbd": "", + "quickbooks_us": "", "sales_tax_codes": "", "tax_accounts": "", + "tax_rate_short": "", + "tax_surcharge_short": "", + "tax_threshold_short": "", + "tax_tier_card": "", + "tax_tier_short": "", + "tax_type_card": "", "title": "", "ttl_adjustment": "", "ttl_tax_adjustment": "" @@ -778,6 +857,9 @@ "roguard": { "title": "" }, + "autoemail": "", + "jobcosting": "", + "localmediaserver": "", "romepay": "", "scheduling": "", "scoreboardsetup": "", @@ -785,6 +867,7 @@ "shopinfo": "", "shoprates": "", "speedprint": "", + "speedprint_configurations": "", "ssbuckets": "", "systemsettings": "", "task-presets": "", @@ -808,7 +891,8 @@ "tooltips": { "md_parts_scan": { "update_value_tooltip": "" - } + }, + "reset-color": "" }, "validation": { "centermustexist": "", @@ -1176,21 +1260,42 @@ "employee_teams": { "actions": { "new": "", - "newmember": "" + "newmember": "", + "save_team": "" + }, + "errors": { + "allocation_total_exact": "", + "duplicate_member": "", + "minimum_one_member": "" }, "fields": { "active": "", + "allocation": "", + "allocation_percentage": "", "employeeid": "", "max_load": "", "name": "", + "payout_method": "", "percentage": "" + }, + "labels": { + "allocation_total": "", + "members": "", + "team_options": "" + }, + "options": { + "commission": "", + "commission_percentage": "", + "hourly": "" } }, "employees": { "actions": { + "addrate": "", "addvacation": "", "new": "Nouvel employé", "newrate": "", + "save_employee": "", "select": "" }, "errors": { @@ -1222,6 +1327,7 @@ "labels": { "actions": "", "active": "", + "employee_number_short": "", "endmustbeafterstart": "", "flat_rate": "", "inactive": "", @@ -1365,6 +1471,7 @@ "beta": "", "cancel": "", "changelog": "", + "click_to_begin": "", "clear": "", "confirmpassword": "", "created_at": "", @@ -1910,10 +2017,15 @@ "employee_refinish": "", "est_addr1": "Adresse de l'évaluateur", "est_co_nm": "Expert", + "est_co_nm_short": "", "est_ct_fn": "Prénom de l'évaluateur", + "est_ct_fn_short": "", "est_ct_ln": "Nom de l'évaluateur", + "est_ct_ln_short": "", "est_ea": "Courriel de l'évaluateur", + "est_ea_short": "", "est_ph1": "Numéro de téléphone de l'évaluateur", + "est_ph1_short": "", "estimate_approved": "", "estimate_sent_approval": "", "federal_tax_payable": "Impôt fédéral à payer", @@ -1926,9 +2038,13 @@ "ins_co_nm": "Nom de la compagnie d'assurance", "ins_co_nm_short": "", "ins_ct_fn": "Prénom du gestionnaire de fichiers", + "ins_ct_fn_short": "", "ins_ct_ln": "Nom du gestionnaire de fichiers", + "ins_ct_ln_short": "", "ins_ea": "Courriel du gestionnaire de fichiers", + "ins_ea_short": "", "ins_ph1": "Numéro de téléphone du gestionnaire de fichiers", + "ins_ph1_short": "", "intake": { "label": "", "max": "", @@ -3610,6 +3726,7 @@ }, "fields": { "actualhrs": "", + "amount": "", "ciecacode": "", "clockhours": "", "clockoff": "", @@ -3624,7 +3741,10 @@ "employee_team": "", "flat_rate": "", "memo": "", + "pay": "", + "payout_method": "", "productivehrs": "", + "rate": "", "ro_number": "", "task_name": "" }, @@ -3643,6 +3763,10 @@ "lunch": "", "new": "", "payrollclaimedtasks": "", + "payout_methods": { + "commission": "", + "hourly": "" + }, "pmbreak": "", "pmshift": "", "shift": "", diff --git a/client/src/utils/AuditTrailMappings.js b/client/src/utils/AuditTrailMappings.js index ea1494fec..23a2600ed 100644 --- a/client/src/utils/AuditTrailMappings.js +++ b/client/src/utils/AuditTrailMappings.js @@ -8,8 +8,9 @@ const AuditTrailMapping = { appointmentcancel: (lost_sale_reason) => i18n.t("audit_trail.messages.appointmentcancel", { lost_sale_reason }), appointmentinsert: (start) => i18n.t("audit_trail.messages.appointmentinsert", { start }), billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }), + billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }), billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }), - billupdated: (invoice_number) => i18n.t("audit_trail.messages.billupdated", { invoice_number }), + billupdated: (invoice_number, details) => i18n.t("audit_trail.messages.billupdated", { invoice_number, details }), jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }), jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }), jobchecklist: (type, inproduction, status) => @@ -25,6 +26,10 @@ const AuditTrailMapping = { jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }), jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"), jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"), + joblineupdate: (lineDescription, details) => + i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }), + jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"), + jobmanuallineinsert: (details) => i18n.t("audit_trail.messages.jobmanuallineinsert", { details }), jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }), jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"), jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"), @@ -71,7 +76,11 @@ const AuditTrailMapping = { i18n.t("audit_trail.messages.tasks_uncompleted", { title, uncompletedBy - }) + }), + timeticketcreated: (employee, date, details) => + i18n.t("audit_trail.messages.timeticketcreated", { employee, date, details }), + timeticketupdated: (employee, date, details) => + i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details }) }; export default AuditTrailMapping; diff --git a/client/src/utils/auditTrailDetails.js b/client/src/utils/auditTrailDetails.js new file mode 100644 index 000000000..16398267a --- /dev/null +++ b/client/src/utils/auditTrailDetails.js @@ -0,0 +1,186 @@ +import dayjs from "./day"; + +const EMPTY_VALUE = "<>"; +const NO_CHANGES = "No changes"; + +const BILL_LINE_KEYS = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"]; +const JOB_LINE_SKIP_KEYS = new Set(["ah_detail_line", "prt_dsmk_p"]); +const DATE_ONLY_KEYS = new Set(["date"]); +const DATE_TIME_KEYS = new Set(["clockon", "clockoff"]); +const CURRENCY_KEYS = new Set(["actual_price", "actual_cost", "act_price", "db_price", "rate"]); +const HOUR_KEYS = new Set(["productivehrs", "actualhrs", "mod_lb_hrs"]); + +const isBlank = (value) => value == null || value === ""; + +const isStructuredValue = (value) => value != null && typeof value === "object" && !dayjs.isDayjs?.(value); + +const formatDate = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD")); + +const formatDateTime = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD HH:mm")); + +const formatNumber = (value, fractionDigits) => + typeof value === "number" ? value.toFixed(fractionDigits) : String(value); + +const compareValue = (key, value) => { + if (isBlank(value)) return EMPTY_VALUE; + if (DATE_TIME_KEYS.has(key)) return formatDateTime(value); + if (DATE_ONLY_KEYS.has(key)) return formatDate(value); + if (dayjs.isDayjs?.(value)) return formatDateTime(value); + return String(value); +}; + +const buildFieldChangeDetails = ({ keys, original = {}, updated = {}, displayValue, skippedKeys = new Set() }) => + keys + .filter((key) => key !== "__typename" && !skippedKeys.has(key)) + .filter((key) => !isStructuredValue(original[key]) && !isStructuredValue(updated[key])) + .map((key) => { + if (compareValue(key, original[key]) === compareValue(key, updated[key])) return null; + return `${key}: ${displayValue(key, original[key])} -> ${displayValue(key, updated[key])}`; + }) + .filter(Boolean); + +const formatBillValue = (key, value) => { + if (isBlank(value)) return EMPTY_VALUE; + if (DATE_ONLY_KEYS.has(key)) return formatDate(value); + if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value); + return String(value); +}; + +const formatJobLineValue = (key, value) => { + if (isBlank(value)) return EMPTY_VALUE; + if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value); + if (HOUR_KEYS.has(key)) return formatNumber(value, 1); + return String(value); +}; + +const getEmployeeName = (employeeId, employees = [], fallbackEmployee) => { + if ( + (employeeId == null || fallbackEmployee?.id === employeeId) && + (fallbackEmployee?.first_name || fallbackEmployee?.last_name) + ) { + return [fallbackEmployee.first_name, fallbackEmployee.last_name].filter(Boolean).join(" "); + } + + const employee = employees.find(({ id }) => id === employeeId); + if (employee) { + return [employee.first_name, employee.last_name].filter(Boolean).join(" "); + } + + return employeeId ? String(employeeId) : EMPTY_VALUE; +}; + +const formatTimeTicketValue = (key, value, { employees = [], fallbackEmployee } = {}) => { + if (isBlank(value)) return EMPTY_VALUE; + if (key === "employeeid") return getEmployeeName(value, employees, fallbackEmployee); + if (DATE_TIME_KEYS.has(key)) return formatDateTime(value); + if (DATE_ONLY_KEYS.has(key)) return formatDate(value); + if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value); + if (HOUR_KEYS.has(key)) return formatNumber(value, 1); + if (typeof value === "boolean") return value ? "true" : "false"; + return String(value); +}; + +const buildBillLineSummary = (line) => + BILL_LINE_KEYS.map((key) => `${key}: ${formatBillValue(key, line[key])}`).join(", "); + +export function buildBillUpdateAuditDetails({ originalBill = {}, bill = {}, billlines = [] }) { + const updatedBill = { ...bill, billlines }; + const billKeys = Array.from(new Set([...Object.keys(originalBill), ...Object.keys(updatedBill)])).filter( + (key) => key !== "billlines" + ); + + const changed = buildFieldChangeDetails({ + keys: billKeys, + original: originalBill, + updated: updatedBill, + displayValue: formatBillValue + }); + + const originalBillLines = originalBill.billlines ?? []; + const updatedBillLines = updatedBill.billlines ?? []; + + const addedLines = updatedBillLines + .filter((line) => !line.id) + .map((line) => `+${line.line_desc || line.description || "new line"} (${buildBillLineSummary(line)})`); + + const removedLines = originalBillLines + .filter((line) => !updatedBillLines.some((updatedLine) => updatedLine.id === line.id)) + .map( + (line) => `-${line.line_desc || line.description || line.id || "removed line"} (${buildBillLineSummary(line)})` + ); + + const modifiedLines = updatedBillLines + .filter((line) => line.id) + .flatMap((line) => { + const originalLine = originalBillLines.find(({ id }) => id === line.id); + if (!originalLine) return []; + + const lineChanges = buildFieldChangeDetails({ + keys: BILL_LINE_KEYS, + original: originalLine, + updated: line, + displayValue: formatBillValue + }); + + if (!lineChanges.length) return []; + + return [`${line.line_desc || line.description || line.id}: ${lineChanges.join("; ")}`]; + }); + + if (addedLines.length) changed.push(`billlines added: ${addedLines.join(" | ")}`); + if (removedLines.length) changed.push(`billlines removed: ${removedLines.join(" | ")}`); + if (modifiedLines.length) changed.push(`billlines modified: ${modifiedLines.join(" | ")}`); + + return changed.length ? changed.join("; ") : NO_CHANGES; +} + +export function buildJobLineInsertAuditDetails(values = {}) { + const details = Object.entries(values) + .filter(([key, value]) => !JOB_LINE_SKIP_KEYS.has(key) && !isBlank(value)) + .map(([key, value]) => `${key}: ${formatJobLineValue(key, value)}`); + + return details.length ? details.join("; ") : NO_CHANGES; +} + +export function buildJobLineUpdateAuditDetails({ originalLine = {}, values = {} }) { + const details = buildFieldChangeDetails({ + keys: Object.keys(values), + original: originalLine, + updated: values, + displayValue: formatJobLineValue, + skippedKeys: JOB_LINE_SKIP_KEYS + }); + + return details.length ? details.join("; ") : NO_CHANGES; +} + +export function buildTimeTicketAuditSummary({ originalTicket = {}, submittedValues = {}, employees = [] }) { + const normalizedOriginal = { + ...originalTicket, + jobid: originalTicket.job?.id ?? originalTicket.jobid ?? null + }; + + const details = buildFieldChangeDetails({ + keys: Object.keys(submittedValues), + original: normalizedOriginal, + updated: submittedValues, + displayValue: (key, value) => + formatTimeTicketValue(key, value, { + employees, + fallbackEmployee: key === "employeeid" ? normalizedOriginal.employee : null + }) + }); + + const employeeName = getEmployeeName( + submittedValues.employeeid ?? normalizedOriginal.employeeid, + employees, + normalizedOriginal.employee + ); + + return { + date: formatDate(submittedValues.date ?? normalizedOriginal.date), + details: details.length ? details.join("; ") : NO_CHANGES, + employeeName, + jobid: submittedValues.jobid ?? normalizedOriginal.jobid ?? null + }; +} diff --git a/client/tests/e2e/commission-based-cut.e2e.js b/client/tests/e2e/commission-based-cut.e2e.js new file mode 100644 index 000000000..6202108d8 --- /dev/null +++ b/client/tests/e2e/commission-based-cut.e2e.js @@ -0,0 +1,140 @@ +/* eslint-disable */ + +import { expect, test } from "@playwright/test"; +import { acceptEulaIfPresent, login } from "./utils/login"; + +async function openCommissionCutHarness(page) { + await page.goto("/manage/_test?fixture=commission-cut"); + await acceptEulaIfPresent(page); + await expect(page.getByRole("heading", { name: "Commission Cut Test Harness" })).toBeVisible(); +} + +test.describe("Commission-based cut", () => { + test.skip(!process.env.TEST_USERNAME || !process.env.TEST_PASSWORD, "Requires TEST_USERNAME and TEST_PASSWORD."); + + test("renders payout previews and completes Pay All from the commission-cut harness", async ({ page }) => { + let calculateLaborCalls = 0; + let payAllCalls = 0; + + await login(page, { + email: process.env.TEST_USERNAME, + password: process.env.TEST_PASSWORD + }); + + await page.route("**/payroll/calculatelabor", async (route) => { + calculateLaborCalls += 1; + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + employeeid: "emp-1", + mod_lbr_ty: "LAA", + expectedHours: 4, + claimedHours: 1 + }, + { + employeeid: "emp-2", + mod_lbr_ty: "LAB", + expectedHours: 2, + claimedHours: 1 + } + ]) + }); + }); + + await page.route("**/payroll/payall", async (route) => { + payAllCalls += 1; + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: "tt-1" }]) + }); + }); + + await openCommissionCutHarness(page); + await expect(page.getByText("Claim Task Preview")).toBeVisible(); + await expect(page.getByRole("cell", { name: "Commission" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "Hourly" })).toBeVisible(); + await expect( + page.getByText( + "There are currently 1.25 hours of repair lines that are unassigned. These hours are not including in the above calculations and must be paid manually." + ) + ).toBeVisible(); + + await expect(page.getByRole("button", { name: "Pay All" })).toBeVisible(); + await page.getByRole("button", { name: "Pay All" }).click(); + + await expect.poll(() => calculateLaborCalls).toBeGreaterThan(0); + await expect.poll(() => payAllCalls).toBe(1); + await expect(page.getByText("All hours paid out successfully.")).toBeVisible(); + }); + + test("shows the backend error when Pay All is rejected", async ({ page }) => { + await login(page, { + email: process.env.TEST_USERNAME, + password: process.env.TEST_PASSWORD + }); + + await page.route("**/payroll/calculatelabor", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + employeeid: "emp-1", + mod_lbr_ty: "LAA", + expectedHours: 4, + claimedHours: 1 + } + ]) + }); + }); + + await page.route("**/payroll/payall", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: false, + error: "Not all hours have been assigned." + }) + }); + }); + + await openCommissionCutHarness(page); + await page.getByRole("button", { name: "Pay All" }).click(); + + await expect(page.getByText("Error flagging hours. Not all hours have been assigned.")).toBeVisible(); + }); + + test("shows a negative labor difference when previously claimed hours exceed the current expected hours", async ({ + page + }) => { + await login(page, { + email: process.env.TEST_USERNAME, + password: process.env.TEST_PASSWORD + }); + + await page.route("**/payroll/calculatelabor", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + employeeid: "emp-1", + mod_lbr_ty: "LAA", + expectedHours: 2, + claimedHours: 5 + } + ]) + }); + }); + + await openCommissionCutHarness(page); + + await expect(page.locator("strong").filter({ hasText: "-3" }).first()).toBeVisible(); + }); +}); diff --git a/client/tests/e2e/utils/login.js b/client/tests/e2e/utils/login.js index 8b6c2e9f8..9219890e7 100644 --- a/client/tests/e2e/utils/login.js +++ b/client/tests/e2e/utils/login.js @@ -1,5 +1,48 @@ import { expect } from "@playwright/test"; +const formatToday = () => { + const today = new Date(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const year = today.getFullYear(); + return `${month}/${day}/${year}`; +}; + +export async function acceptEulaIfPresent(page) { + const eulaDialog = page.getByRole("dialog", { name: "Terms and Conditions" }); + + const eulaVisible = + (await eulaDialog.isVisible().catch(() => false)) || + (await eulaDialog + .waitFor({ + state: "visible", + timeout: 5000 + }) + .then(() => true) + .catch(() => false)); + + if (!eulaVisible) { + return; + } + + const markdownCard = page.locator(".eula-markdown-card"); + await markdownCard.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + + await page.getByRole("textbox", { name: "First Name" }).fill("Codex"); + await page.getByRole("textbox", { name: "Last Name" }).fill("Tester"); + await page.getByRole("textbox", { name: "Legal Business Name" }).fill("Codex QA"); + await page.getByRole("textbox", { name: "Date Accepted" }).fill(formatToday()); + await page.getByRole("checkbox", { name: "I accept the terms and conditions of this agreement." }).check(); + + const acceptButton = page.getByRole("button", { name: "Accept EULA" }); + await expect(acceptButton).toBeEnabled({ timeout: 10000 }); + await acceptButton.click(); + await expect(eulaDialog).not.toBeVisible({ timeout: 10000 }); +} + export async function login(page, { email, password }) { // Navigate to the login page await page.goto("/"); // Adjust if your login route differs (e.g., '/login') @@ -16,6 +59,8 @@ export async function login(page, { email, password }) { // Wait for navigation or success indicator (e.g., redirect to /manage/) await page.waitForURL(/\/manage\//, { timeout: 10000 }); // Adjust based on redirect + await acceptEulaIfPresent(page); + // Verify successful login (e.g., check for a dashboard element) await expect(page.locator("text=Manage")).toBeVisible(); // Adjust to your app’s post-login UI } diff --git a/client/tests/setup.js b/client/tests/setup.js index 8b4799a23..b544949b1 100644 --- a/client/tests/setup.js +++ b/client/tests/setup.js @@ -1,5 +1,45 @@ -import { afterEach } from "vitest"; +import { afterEach, vi } from "vitest"; import { cleanup } from "@testing-library/react"; import "@testing-library/jest-dom"; +if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }); +} + +if (!window.ResizeObserver) { + window.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +if (!window.IntersectionObserver) { + window.IntersectionObserver = class IntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +if (!window.scrollTo) { + window.scrollTo = vi.fn(); +} + +if (!HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = vi.fn(); +} + afterEach(() => cleanup()); diff --git a/client/vite.config.js b/client/vite.config.js index 4886ef13a..55c79e91d 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -173,12 +173,12 @@ export default defineConfig(({ command, mode }) => { open: true, proxy: { "/ws": { - target: "ws://localhost:4000", + target: "http://localhost:4000", secure: false, ws: true }, "/wss": { - target: "ws://localhost:4000", + target: "http://localhost:4000", secure: false, ws: true }, @@ -206,13 +206,13 @@ export default defineConfig(({ command, mode }) => { https: httpsCerts, proxy: { "/ws": { - target: "ws://localhost:4000", + target: "http://localhost:4000", rewriteWsOrigin: true, secure: false, ws: true }, "/wss": { - target: "ws://localhost:4000", + target: "http://localhost:4000", rewriteWsOrigin: true, secure: false, ws: true diff --git a/hasura/migrations/1773331250571_alter_table_public_employee_team_members_add_column_payout_method/down.sql b/hasura/migrations/1773331250571_alter_table_public_employee_team_members_add_column_payout_method/down.sql new file mode 100644 index 000000000..c2f4b37cc --- /dev/null +++ b/hasura/migrations/1773331250571_alter_table_public_employee_team_members_add_column_payout_method/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."employee_team_members" add column "payout_method" text +-- null; diff --git a/hasura/migrations/1773331250571_alter_table_public_employee_team_members_add_column_payout_method/up.sql b/hasura/migrations/1773331250571_alter_table_public_employee_team_members_add_column_payout_method/up.sql new file mode 100644 index 000000000..a239436f1 --- /dev/null +++ b/hasura/migrations/1773331250571_alter_table_public_employee_team_members_add_column_payout_method/up.sql @@ -0,0 +1,2 @@ +alter table "public"."employee_team_members" add column "payout_method" text + null; diff --git a/hasura/migrations/1773331301662_alter_table_public_employee_team_members_add_column_commission_rates/down.sql b/hasura/migrations/1773331301662_alter_table_public_employee_team_members_add_column_commission_rates/down.sql new file mode 100644 index 000000000..2a545d7c0 --- /dev/null +++ b/hasura/migrations/1773331301662_alter_table_public_employee_team_members_add_column_commission_rates/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."employee_team_members" add column "commission_rates" jsonb +-- null; diff --git a/hasura/migrations/1773331301662_alter_table_public_employee_team_members_add_column_commission_rates/up.sql b/hasura/migrations/1773331301662_alter_table_public_employee_team_members_add_column_commission_rates/up.sql new file mode 100644 index 000000000..6264d5da3 --- /dev/null +++ b/hasura/migrations/1773331301662_alter_table_public_employee_team_members_add_column_commission_rates/up.sql @@ -0,0 +1,2 @@ +alter table "public"."employee_team_members" add column "commission_rates" jsonb + null; diff --git a/hasura/migrations/1773331441524_alter_table_public_timetickets_add_column_payout_context/down.sql b/hasura/migrations/1773331441524_alter_table_public_timetickets_add_column_payout_context/down.sql new file mode 100644 index 000000000..21da54502 --- /dev/null +++ b/hasura/migrations/1773331441524_alter_table_public_timetickets_add_column_payout_context/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."timetickets" add column "payout_context" jsonb +-- null; diff --git a/hasura/migrations/1773331441524_alter_table_public_timetickets_add_column_payout_context/up.sql b/hasura/migrations/1773331441524_alter_table_public_timetickets_add_column_payout_context/up.sql new file mode 100644 index 000000000..d51cf92cc --- /dev/null +++ b/hasura/migrations/1773331441524_alter_table_public_timetickets_add_column_payout_context/up.sql @@ -0,0 +1,2 @@ +alter table "public"."timetickets" add column "payout_context" jsonb + null; diff --git a/hasura/migrations/1773331546522_alter_table_public_tt_approval_queue_add_column_payout_context/down.sql b/hasura/migrations/1773331546522_alter_table_public_tt_approval_queue_add_column_payout_context/down.sql new file mode 100644 index 000000000..08782d8ea --- /dev/null +++ b/hasura/migrations/1773331546522_alter_table_public_tt_approval_queue_add_column_payout_context/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."tt_approval_queue" add column "payout_context" jsonb +-- null; diff --git a/hasura/migrations/1773331546522_alter_table_public_tt_approval_queue_add_column_payout_context/up.sql b/hasura/migrations/1773331546522_alter_table_public_tt_approval_queue_add_column_payout_context/up.sql new file mode 100644 index 000000000..493be33c4 --- /dev/null +++ b/hasura/migrations/1773331546522_alter_table_public_tt_approval_queue_add_column_payout_context/up.sql @@ -0,0 +1,2 @@ +alter table "public"."tt_approval_queue" add column "payout_context" jsonb + null; diff --git a/localstack/init/10-bootstrap.sh b/localstack/init/10-bootstrap.sh index ee8183d1e..72a18528a 100755 --- a/localstack/init/10-bootstrap.sh +++ b/localstack/init/10-bootstrap.sh @@ -51,7 +51,8 @@ awslocal ses verify-email-identity --email-address noreply@imex.online --region # Secrets ensure_secret_file "CHATTER_PRIVATE_KEY" "/tmp/certs/io-ftp-test.key" -ensure_secret_string "CHATTER_COMPANY_KEY_6713" "${CHATTER_COMPANY_KEY_6713:-REPLACE_ME}" +ensure_secret_string "CHATTER_COMPANY_KEY_6713" "${CHATTER_COMPANY_KEY_6713}" +ensure_secret_string "CHATTER_COMPANY_KEY_6746" "${CHATTER_COMPANY_KEY_6746}" # Logs ensure_log_group "development" diff --git a/os-loader.js b/os-loader.js index 2e46c6fa7..982f841a7 100644 --- a/os-loader.js +++ b/os-loader.js @@ -32,6 +32,7 @@ async function OpenSearchUpdateHandler(req, res) { clm_no clm_total comment + dms_id ins_co_nm owner_owing ownr_co_nm diff --git a/package-lock.json b/package-lock.json index 1d5caf37a..4d58a40ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,16 @@ "version": "0.2.0", "license": "UNLICENSED", "dependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.997.0", - "@aws-sdk/client-elasticache": "^3.997.0", - "@aws-sdk/client-s3": "^3.997.0", - "@aws-sdk/client-secrets-manager": "^3.997.0", - "@aws-sdk/client-ses": "^3.997.0", - "@aws-sdk/client-sqs": "^3.997.0", - "@aws-sdk/client-textract": "^3.997.0", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/lib-storage": "^3.997.0", - "@aws-sdk/s3-request-presigner": "^3.997.0", + "@aws-sdk/client-cloudwatch-logs": "^3.1020.0", + "@aws-sdk/client-elasticache": "^3.1020.0", + "@aws-sdk/client-s3": "^3.1020.0", + "@aws-sdk/client-secrets-manager": "^3.1020.0", + "@aws-sdk/client-ses": "^3.1020.0", + "@aws-sdk/client-sqs": "^3.1020.0", + "@aws-sdk/client-textract": "^3.1020.0", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/lib-storage": "^3.1020.0", + "@aws-sdk/s3-request-presigner": "^3.1020.0", "@documenso/sdk-typescript": "^0.8.0", "@jsreport/nodejs-client": "^4.1.0", "@opensearch-project/opensearch": "^2.13.0", @@ -26,10 +26,10 @@ "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.1", "aws4": "^1.13.2", - "axios": "^1.13.5", + "axios": "^1.14.0", "axios-curlirize": "^2.0.0", "better-queue": "^3.8.12", - "bullmq": "^5.70.1", + "bullmq": "^5.71.1", "chart.js": "^4.5.1", "cloudinary": "^2.9.0", "compression": "^1.8.1", @@ -39,20 +39,20 @@ "dinero.js": "^1.9.1", "dotenv": "^17.3.1", "express": "^4.21.1", - "fast-xml-parser": "^5.4.1", - "firebase-admin": "^13.6.1", + "fast-xml-parser": "^5.5.9", + "firebase-admin": "^13.7.0", "fuse.js": "^7.1.0", - "graphql": "^16.13.0", + "graphql": "^16.13.2", "graphql-request": "^6.1.0", "intuit-oauth": "^4.2.2", - "ioredis": "^5.9.3", + "ioredis": "^5.10.1", "json-2-csv": "^5.5.10", "jsonwebtoken": "^9.0.3", "juice": "^11.1.1", "lodash": "^4.17.23", "moment": "^2.30.1", - "moment-timezone": "^0.6.0", - "multer": "^2.0.2", + "moment-timezone": "^0.6.1", + "multer": "^2.1.1", "mustache": "^4.2.0", "node-persist": "^4.0.4", "nodemailer": "^6.10.0", @@ -62,15 +62,15 @@ "recursive-diff": "^1.0.9", "rimraf": "^6.1.3", "skia-canvas": "^3.0.8", - "soap": "^1.7.1", + "soap": "^1.8.0", "socket.io": "^4.8.3", "socket.io-adapter": "^2.5.6", "ssh2-sftp-client": "^11.0.0", - "twilio": "^5.12.2", + "twilio": "^5.13.1", "uuid": "^11.1.0", "winston": "^3.19.0", "winston-cloudwatch": "^6.3.0", - "xml-formatter": "^3.6.7", + "xml-formatter": "^3.7.0", "xml2js": "^0.6.2", "xmlbuilder2": "^4.0.3", "yazl": "^3.3.1" @@ -79,12 +79,12 @@ "@eslint/js": "^9.39.2", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", - "globals": "^17.3.0", + "globals": "^17.4.0", "mock-require": "^3.0.3", "p-limit": "^3.1.0", "prettier": "^3.8.1", "supertest": "^7.2.2", - "vitest": "^4.0.18" + "vitest": "^4.1.2" }, "engines": { "node": ">=22.13.0", @@ -294,52 +294,52 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.997.0.tgz", - "integrity": "sha512-u0h34MhUdCOhmLcSBQVygxqpwS6AsYMjl5Brj/bFgsQpSh9zVy32ZSHROCvXKfvV3webY///WP44ZiXuSgQzZA==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1020.0.tgz", + "integrity": "sha512-ezS+wZbSJYbncXtfO0BOyNdqeRnKPSRPZNq111aydoZoW0Q/FnTiRbISZoMLqOCnwJVOX3EZMGNGpUvVyJ+CGQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/eventstream-serde-browser": "^4.2.9", - "@smithy/eventstream-serde-config-resolver": "^4.3.9", - "@smithy/eventstream-serde-node": "^4.2.9", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.13", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -347,50 +347,50 @@ } }, "node_modules/@aws-sdk/client-elasticache": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-elasticache/-/client-elasticache-3.997.0.tgz", - "integrity": "sha512-M8YsSXAT6Whi3sKwCFXKh4ZxkNeAHYGNh0NTxyDpuWZAThRb+bzxKngivH0zTNuyOcmlR3/arPzD1+a9AyuycA==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-elasticache/-/client-elasticache-3.1020.0.tgz", + "integrity": "sha512-cuYpuMTjsXXbQrrWuqMCZID+oi4D8w3w8aixdVe4Yri//x++mcebxe+oZGRZlKGWZPKQYg1GpCTm+bu8CWygYA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-utf8": "^4.2.1", - "@smithy/util-waiter": "^4.2.9", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.13", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -398,65 +398,65 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.997.0.tgz", - "integrity": "sha512-a4z12iq/bJVJXfVOOKsYMDhxZwf+n8xieCuW+zI07qtRAuMiKr2vUtHPBbKncrF+hqnsq/Wmh48bu2yziGhIbg==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1020.0.tgz", + "integrity": "sha512-ibfxjP5zLUqpujLE0OTgD+jZ3KStx9dTASL7d7Eekw4sv7ZHv1UN6CPDcKnCNXdPzlBWi5Wc5lWJ4sU1M8ygEQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.4", - "@aws-sdk/middleware-expect-continue": "^3.972.4", - "@aws-sdk/middleware-flexible-checksums": "^3.972.11", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-location-constraint": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-sdk-s3": "^3.972.13", - "@aws-sdk/middleware-ssec": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/signature-v4-multi-region": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/eventstream-serde-browser": "^4.2.9", - "@smithy/eventstream-serde-config-resolver": "^4.3.9", - "@smithy/eventstream-serde-node": "^4.2.9", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-blob-browser": "^4.2.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/hash-stream-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/md5-js": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-stream": "^4.5.14", - "@smithy/util-utf8": "^4.2.1", - "@smithy/util-waiter": "^4.2.9", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.6", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-sdk-s3": "^3.972.27", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/signature-v4-multi-region": "^3.996.15", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.13", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.21", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -464,49 +464,49 @@ } }, "node_modules/@aws-sdk/client-secrets-manager": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.997.0.tgz", - "integrity": "sha512-5r+1P1iuILRhtKk7j5gEvfiJpweHk5TWXFJl5BKKKuE47esgd2R5cAwyPJId/5cPJln2yC9qevz9zneoLpWNVw==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1020.0.tgz", + "integrity": "sha512-FRQUPEAeRWK/DO/IJWWVQdmfVYifeNQksrToREokbGjJbCD912eNP5szOtqP7kd52QweznXlfLqv3OeTVnP5Ew==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.13", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -514,50 +514,50 @@ } }, "node_modules/@aws-sdk/client-ses": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.997.0.tgz", - "integrity": "sha512-FkzdUCmZagYJLcA3gEh7naDnCLdpNda1uwknMw0QwlVzeKm/SJtrLpR83nuCdbgn2yD8oiUWYp04n7pw4x9zWg==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.1020.0.tgz", + "integrity": "sha512-UfksKJVrZp4esWxF8GuIBCth++jYpGLqkINj0lQaHq/6kz6u6/Md/q8PlTVmLRQo38NCcb9XKg4G0sKOYN7+Hw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-utf8": "^4.2.1", - "@smithy/util-waiter": "^4.2.9", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.13", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -565,51 +565,51 @@ } }, "node_modules/@aws-sdk/client-sqs": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.997.0.tgz", - "integrity": "sha512-1kj518kNZn2Vnpb7x5WyBcQ4ajrq4WaCgC3ru7KI56LiGfl8/djVXn/kmV05QeFAc57snSdQggAmuRwlCJJp/g==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1020.0.tgz", + "integrity": "sha512-DDKv7bucz6usrvOZ+O6f8mCrm5mTD1yOC9rYDjbUmUPP50S0HLCT6ZVqcXlKaucHcO0l9sWBx8K4jrvMTCkfDg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-sdk-sqs": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/md5-js": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-sdk-sqs": "^3.972.18", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.13", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -617,49 +617,49 @@ } }, "node_modules/@aws-sdk/client-textract": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-textract/-/client-textract-3.997.0.tgz", - "integrity": "sha512-pl9zXetJZkdQ0rCSjlg2Kpm3E9zYAZm/WA+POy1nPF0oRB0c07/x8apF1GzVlZNNR81hw4JU58pRKSfFSETyrg==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-textract/-/client-textract-3.1020.0.tgz", + "integrity": "sha512-7aVLbqnrDeom8Zygv04kcDYdwZR450X4pvR1vF4w6LNZxIBjt4VGhZ4I0Tlvri0KV9aeOPy97xDEKwktDGEgBQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.13", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -667,23 +667,23 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.13.tgz", - "integrity": "sha512-eCFiLyBhJR7c/i8hZOETdzj2wsLFzi2L/w9/jajOgwmGqO8xrUExqkTZqdjROkwU62owqeqSuw4sIzlCv1E/ww==", + "version": "3.973.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.26.tgz", + "integrity": "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/xml-builder": "^3.972.6", - "@smithy/core": "^3.23.4", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/property-provider": "^4.2.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/signature-v4": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.16", + "@smithy/core": "^3.23.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -691,12 +691,12 @@ } }, "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.1.tgz", - "integrity": "sha512-CmT9RrQol36hUdvp4dk+BRV47JBRIE+I46yAOKyb/SoMH7mKOBwk6jUpFZhF8B+LCnWnefnM6jT/WsfQ5M1kCQ==", + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -704,15 +704,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.11.tgz", - "integrity": "sha512-hbyoFuVm3qOAGfIPS9t7jCs8GFLFoaOs8ZmYp/chqciuHDyEGv+J365ip7YSvXSrxxUbeW9NyB1hTLt40NBMRg==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz", + "integrity": "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -720,20 +720,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.13.tgz", - "integrity": "sha512-a864QxQWFkdCZ5wQF0QZNKTbqAc/DFQNeARp4gOyZZdql5RHjj4CppUSfwAzS9cpw2IPY3eeJjWqLZ1QiDB/6w==", + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz", + "integrity": "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/property-provider": "^4.2.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/util-stream": "^4.5.14", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" }, "engines": { @@ -741,24 +741,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.11.tgz", - "integrity": "sha512-kvPFn626ABLzxmjFMoqMRtmFKMeiUdWPhwxhmuPu233tqHnNuXzHv0MtrZlkzHd+rwlh9j0zCbQo89B54wIazQ==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.27.tgz", + "integrity": "sha512-Um26EsNSUfVUX0wUXnUA1W3wzKhVy6nviEElsh5lLZUYj9bk6DXOPnpte0gt+WHubcVfVsRk40bbm4KaroTEag==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-env": "^3.972.11", - "@aws-sdk/credential-provider-http": "^3.972.13", - "@aws-sdk/credential-provider-login": "^3.972.11", - "@aws-sdk/credential-provider-process": "^3.972.11", - "@aws-sdk/credential-provider-sso": "^3.972.11", - "@aws-sdk/credential-provider-web-identity": "^3.972.11", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/credential-provider-imds": "^4.2.9", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-login": "^3.972.27", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.27", + "@aws-sdk/credential-provider-web-identity": "^3.972.27", + "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -766,18 +766,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.11.tgz", - "integrity": "sha512-stdy09EpBTmsxGiXe1vB5qtXNww9wact36/uWLlSV0/vWbCOUAY2JjhPXoDVLk8n+E6r0M5HeZseLk+iTtifxg==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.27.tgz", + "integrity": "sha512-t3ehEtHomGZwg5Gixw4fYbYtG9JBnjfAjSDabxhPEu/KLLUp0BB37/APX7MSKXQhX6ZH7pseuACFJ19NrAkNdg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -785,22 +785,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.12.tgz", - "integrity": "sha512-gMWGnHbNSKWRj+PAiuSg0EDpEwpyIgk0v9U6EuZ1C/5/BUv25Way+E+UFB7r+YYkscuBJMJ+ai8E2K0Q8dx50g==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.28.tgz", + "integrity": "sha512-rren+P6k5rShG5PX61iVi40kKdueyuMLBRTctQbyR5LooO9Ygr5L6R7ilG7RF1957NSH3KC3TU206fZuKwjSpQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.11", - "@aws-sdk/credential-provider-http": "^3.972.13", - "@aws-sdk/credential-provider-ini": "^3.972.11", - "@aws-sdk/credential-provider-process": "^3.972.11", - "@aws-sdk/credential-provider-sso": "^3.972.11", - "@aws-sdk/credential-provider-web-identity": "^3.972.11", - "@aws-sdk/types": "^3.973.2", - "@smithy/credential-provider-imds": "^4.2.9", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-ini": "^3.972.27", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.27", + "@aws-sdk/credential-provider-web-identity": "^3.972.27", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -808,16 +808,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.11.tgz", - "integrity": "sha512-B049fvbv41vf0Fs5bCtbzHpruBDp61sPiFDxUmkAJ/zvgSAturpj2rqzV1rj2clg4mb44Uxp9rgpcODexNFlFA==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz", + "integrity": "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -825,18 +825,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.11.tgz", - "integrity": "sha512-vX9z8skN8vPtamVWmSCm4KQohub+1uMuRzIo4urZ2ZUMBAl1bqHatVD/roCb3qRfAyIGvZXCA/AWS03BQRMyCQ==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.27.tgz", + "integrity": "sha512-CWXeGjlbBuHcm9appZUgXKP2zHDyTti0/+gXpSFJ2J3CnSwf1KWjicjN0qG2ozkMH6blrrzMrimeIOEYNl238Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/token-providers": "3.997.0", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/token-providers": "3.1020.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -844,17 +844,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.11.tgz", - "integrity": "sha512-VR2Ju/QBdOjnWNIYuxRml63eFDLGc6Zl8aDwLi1rzgWo3rLBgtaWhWVBAijhVXzyPdQIOqdL8hvll5ybqumjeQ==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.27.tgz", + "integrity": "sha512-CUY4hQIFswdQNEsRGEzGBUKGMK5KpqmNDdu2ROMgI+45PLFS8H0y3Tm7kvM16uvvw3n1pVxk85tnRVUTgtaa1w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -862,14 +862,15 @@ } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.997.0.tgz", - "integrity": "sha512-G5z1CBZVCRStYjgCg3Qu9LjtWAGCUa0JouPU3o8nplSIzpBE4CCJBiWZj7q1/qiIyEBxChf9apqHYJMNN1LDOQ==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1020.0.tgz", + "integrity": "sha512-SvFM+jukgDkCCeszTtGTJ59kAvWeCI5vnBV0eMau4Uj1w0KItwahDVGDo+xQa+r7pp3kolZF1vDmdv37A5+J8A==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/smithy-client": "^4.11.7", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", @@ -879,21 +880,21 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.997.0" + "@aws-sdk/client-s3": "^3.1020.0" } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.4.tgz", - "integrity": "sha512-4W+1SPx5eWetSurqk7WNnldNr++k4UYcP2XmPnCf8yLFdUZ4NKKJA3j+zVuWmhOu7xKmEAyo9j3f+cy22CEVKg==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", - "@smithy/util-config-provider": "^4.2.1", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -901,14 +902,14 @@ } }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.4.tgz", - "integrity": "sha512-lxU2ieIWtK9nqWxA+W4ldev31tRPjkkdt+QDBWGiwUNJsNwSJFVhkuIV9cbBPxTCT0nmYyJwvJ/2TYYJLMwmMA==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -916,24 +917,24 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.11.tgz", - "integrity": "sha512-niA/vhtS/xR4hEHIsPLEvgsccpqve+uJ4Gtizctsa21HfHmIZi5bWJD8kPcN+SfAgrlnuBG2YKFX0rRbzylg7A==", + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.6.tgz", + "integrity": "sha512-YckB8k1ejbyCg/g36gUMFLNzE4W5cERIa4MtsdO+wpTmJEP0+TB7okWIt7d8TDOvnb7SwvxJ21E4TGOBxFpSWQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/crc64-nvme": "^3.972.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/is-array-buffer": "^4.2.1", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-stream": "^4.5.14", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.21", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -941,14 +942,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.4.tgz", - "integrity": "sha512-4q2Vg7/zOB10huDBLjzzTwVjBpG22X3J3ief2XrJEgTaANZrNfA3/cGbCVNAibSbu/nIYA7tDk8WCdsIzDDc4Q==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -956,13 +957,13 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.4.tgz", - "integrity": "sha512-EP1qs0JV2smcKhZpwDMuzMBx9Q5qyU/RuZ02/qh/yBA3jnZKuNhB1lsQKkicvXg7LOeoqyxXLKOP/PJOugX8yg==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -970,13 +971,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.4.tgz", - "integrity": "sha512-xFqPvTysuZAHSkdygT+ken/5rzkR7fhOoDPejAJQslZpp0XBepmCJnDOqA57ERtCTBpu8wpjTFI1ETd4S0AXEw==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -984,15 +985,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.4.tgz", - "integrity": "sha512-tVbRaayUZ7y2bOb02hC3oEPTqQf2A0HpPDwdMl1qTmye/q8Mq1F1WiIoFkQwG/YQFvbyErYIDMbYzIlxzzLtjQ==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz", + "integrity": "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", + "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1000,24 +1001,24 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.13.tgz", - "integrity": "sha512-rGBz1n6PFxg1+5mnN1/IczesPwx0W39DZt2JPjqPiZAZ7LAqH8FS4AsawSNZqr+UFJfqtTXYpeLQnMfbMAgHhg==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.27.tgz", + "integrity": "sha512-gomO6DZwx+1D/9mbCpcqO5tPBqYBK7DtdgjTIjZ4yvfh/S7ETwAPS0XbJgP2JD8Ycr5CwVrEkV1sFtu3ShXeOw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/core": "^3.23.4", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/signature-v4": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/util-config-provider": "^4.2.1", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-stream": "^4.5.14", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.21", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1025,16 +1026,16 @@ } }, "node_modules/@aws-sdk/middleware-sdk-sqs": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.9.tgz", - "integrity": "sha512-wM2OXD1J2KeIOv/sWTmt+FrOhoR0Ujj5jtoW69ArXJqobY2hFJj8CLRuOEGWoVUNRUcNQBKvL8QbkzrAI7tnCw==", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.18.tgz", + "integrity": "sha512-BdsGFuBJUX5PnuZkEV6JRB5g/6ts7iGmN3pXwyoiGCCM2HHXrlFqjkBs+iPX7yO884WqYeQJpme7nwn4DzU5xw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1042,13 +1043,13 @@ } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.4.tgz", - "integrity": "sha512-jzysKNnfwqjTOeF4s1QcxYQ8WB1ZIw/KMhOAX2UGYsmpVPHZ1cV6IYRfBQnt0qnDYom1pU3b5jOG8TA9n6LAbQ==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1056,17 +1057,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.13.tgz", - "integrity": "sha512-p1kVYbzBxRmhuOHoL/ANJPCedqUxnVgkEjxPoxt5pQv/yzppHM7aBWciYEE9TZY59M421D3GjLfZIZBoEFboVQ==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.27.tgz", + "integrity": "sha512-TIRLO5UR2+FVUGmhYoAwVkKhcVzywEDX/5LzR9tjy1h8FQAXOtFg2IqgmwvxU7y933rkTn9rl6AdgcAUgQ1/Kg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@smithy/core": "^3.23.4", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.13", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -1074,48 +1076,48 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.1.tgz", - "integrity": "sha512-XHVLFRGkuV2gh2uwBahCt65ALMb5wMpqplXEZIvFnWOCPlk60B7h7M5J9Em243K8iICDiWY6KhBEqVGfjTqlLA==", + "version": "3.996.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.17.tgz", + "integrity": "sha512-7B0HIX0tEFmOSJuWzdHZj1WhMXSryM+h66h96ZkqSncoY7J6wq61KOu4Kr57b/YnJP3J/EeQYVFulgR281h+7A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.13", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.13", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1123,15 +1125,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.4.tgz", - "integrity": "sha512-3GrJYv5eI65oCKveBZP7Q246dVP+tqeys9aKMB0dfX1glUWfppWlxIu52derqdNb9BX9lxYmeiaBcBIqOAYSgQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz", + "integrity": "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/config-resolver": "^4.4.7", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1139,18 +1141,18 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.997.0.tgz", - "integrity": "sha512-C5tV0o3OATA9lBnTYIBgKonj6gTIo1KSbmUu/FFgVN8mZvTMbJqdXqf/c83EDAFHNED+0I5zcjl9GkSGZTz7Sg==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1020.0.tgz", + "integrity": "sha512-13slasDcOC+Dfi252bcB6MCDavfLP11DsAAxROKr3fyvMTWOh/gFZJyE1a5sBhKAQElzMyqlOLvxPp8cyqvEQQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/signature-v4-multi-region": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-format-url": "^3.972.4", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.15", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-format-url": "^3.972.8", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1158,16 +1160,16 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.1.tgz", - "integrity": "sha512-Mj4npuEtVHFjGZHTBwhBvBzmgKHY7UsfroZWWzjpVP5YJaMTPeihsotuQLba5uQthEZyaeWs6dTu3Shr0qKFFw==", + "version": "3.996.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.15.tgz", + "integrity": "sha512-Ukw2RpqvaL96CjfH/FgfBmy/ZosHBqoHBCFsN61qGg99F33vpntIVii8aNeh65XuOja73arSduskoa4OJea9RQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/protocol-http": "^5.3.9", - "@smithy/signature-v4": "^5.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/middleware-sdk-s3": "^3.972.27", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1175,17 +1177,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.997.0.tgz", - "integrity": "sha512-UdG36F7lU9aTqGFRieEyuRUJlgEJBqKeKKekC0esH21DbUSKhPR1kZBah214kYasIaWe1hLJLaqUigoTa5hZAQ==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1020.0.tgz", + "integrity": "sha512-T61KA/VKl0zVUubdxigr1ut7SEpwE1/4CIKb14JDLyTAOne2yWKtQE1dDCSHl0UqrZNwW/bTt+EBHfQbslZJdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1193,12 +1195,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.2.tgz", - "integrity": "sha512-maTZwGsALtnAw4TJr/S6yERAosTwPduu0XhUV+SdbvRZtCOgSgk1ttL2R0XYzvkYSpvbtJocn77tBXq2AKglBw==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1206,9 +1208,9 @@ } }, "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz", - "integrity": "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==", + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1218,15 +1220,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.1.tgz", - "integrity": "sha512-7cJyd+M5i0IoqWkJa1KFx8KNCGIx+Ywu+lT53KpqX7ReVwz03DCKUqvZ/y65vdKwo9w9/HptSAeLDluO5MpGIg==", + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-endpoints": "^3.2.9", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" }, "engines": { @@ -1234,14 +1236,14 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.4.tgz", - "integrity": "sha512-rPm9g4WvgTz4ko5kqseIG5Vp5LUAbWBBDalm4ogHLMc0i20ChwQWqwuTUPJSu8zXn43jIM0xO2KZaYQsFJb+ew==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.8.tgz", + "integrity": "sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/querystring-builder": "^4.2.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -1261,27 +1263,28 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.4.tgz", - "integrity": "sha512-GHb+8XHv6hfLWKQKAKaSOm+vRvogg07s+FWtbR3+eCXXPSFn9XVmiYF4oypAxH7dGIvoxkVG/buHEnzYukyJiA==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.12.tgz", - "integrity": "sha512-c1n3wBK6te+Vd9qU86nF8AsYuiBsxLn0AADGWyFX7vEADr3btaAg5iPQT6GYj6rvzSOEVVisvaAatOWInlJUbQ==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.13.tgz", + "integrity": "sha512-s1dCJ0J9WU9UPkT3FFqhKTSquYTkqWXGRaapHFyWwwJH86ZussewhNST5R5TwXVL1VSHq4aJVl9fWK+svaRVCQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1297,13 +1300,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.6.tgz", - "integrity": "sha512-YrXu+UnfC8IdARa4ZkrpcyuRmA/TVgYW6Lcdtvi34NQgRjM1hTirNirN+rGb+s/kNomby8oJiIAu0KNbiZC7PA==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", - "fast-xml-parser": "5.3.6", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, "engines": { @@ -1311,9 +1314,9 @@ } }, "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", "funding": [ { "type": "github", @@ -1322,16 +1325,18 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "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" @@ -2127,9 +2132,9 @@ } }, "node_modules/@google-cloud/storage": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz", - "integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -2139,7 +2144,7 @@ "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", - "fast-xml-parser": "^4.4.1", + "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", @@ -2153,38 +2158,6 @@ "node": ">=14" } }, - "node_modules/@google-cloud/storage/node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "strnum": "^1.1.1" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/@google-cloud/storage/node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true - }, "node_modules/@google-cloud/storage/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -2316,9 +2289,9 @@ } }, "node_modules/@ioredis/commands": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", - "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "license": "MIT" }, "node_modules/@isaacs/cliui": { @@ -3341,23 +3314,10 @@ "win32" ] }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.10.tgz", - "integrity": "sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/chunked-blob-reader": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.1.tgz", - "integrity": "sha512-y5d4xRiD6TzeP5BWlb+Ig/VFqF+t9oANNhGeMqyzU7obw7FYgTgVi50i5JqBTeKp+TABeDIeeXFZdz65RipNtA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3367,12 +3327,12 @@ } }, "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.2.tgz", - "integrity": "sha512-QzzYIlf4yg0w5TQaC9VId3B3ugSk1MI/wb7tgcHtd7CBV9gNRKZrhc2EPSxSZuDy10zUZ0lomNMgkc6/VVe8xg==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-base64": "^4.3.1", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -3380,16 +3340,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.7.tgz", - "integrity": "sha512-RISbtc12JKdFRYadt2kW12Cp6XCSU00uFaBZPZqInNVSrRdJFPY/S6nd6/sV7+ySTgGPiKrERtnimEFI6sSweQ==", + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", + "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.9", - "@smithy/types": "^4.12.1", - "@smithy/util-config-provider": "^4.2.1", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -3397,20 +3357,20 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.4", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.4.tgz", - "integrity": "sha512-IH7G3hWxUhd2Z6HtvjZ1EiyDBCRYRr2sngOB9KUWf96XQ8JP2O5ascUH6TouW5YCIMFaVnKADEscM/vUfI3TvA==", + "version": "3.23.13", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz", + "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.10", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-stream": "^4.5.14", - "@smithy/util-utf8": "^4.2.1", - "@smithy/uuid": "^1.1.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.21", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -3418,15 +3378,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.9.tgz", - "integrity": "sha512-Jf723a38EGAzWHxJHzb9DtBq7lrvdJlkCAPWQdN/oiznovx5yWXCFCVspzDe8JU6b+k9hJXYB5duFZpb+3mB6Q==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.9", - "@smithy/property-provider": "^4.2.9", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -3434,14 +3394,14 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.10.tgz", - "integrity": "sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3449,13 +3409,13 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.10.tgz", - "integrity": "sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3463,12 +3423,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.10.tgz", - "integrity": "sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==", + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3476,13 +3436,13 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.10.tgz", - "integrity": "sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3490,13 +3450,13 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.10.tgz", - "integrity": "sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3504,15 +3464,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.10.tgz", - "integrity": "sha512-qF4EcrEtEf2P6f2kGGuSVe1lan26cn7PsWJBC3vZJ6D16Fm5FSN06udOMVoW6hjzQM3W7VDFwtyUG2szQY50dA==", + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.9", - "@smithy/querystring-builder": "^4.2.9", - "@smithy/types": "^4.12.1", - "@smithy/util-base64": "^4.3.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -3520,14 +3480,14 @@ } }, "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.11.tgz", - "integrity": "sha512-DrcAx3PM6AEbWZxsKl6CWAGnVwiz28Wp1ZhNu+Hi4uI/6C1PIZBIaPM2VoqBDAsOWbM6ZVzOEQMxFLLdmb4eBQ==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", "license": "Apache-2.0", "dependencies": { - "@smithy/chunked-blob-reader": "^5.2.1", - "@smithy/chunked-blob-reader-native": "^4.2.2", - "@smithy/types": "^4.13.0", + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3535,14 +3495,14 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.10.tgz", - "integrity": "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3550,13 +3510,13 @@ } }, "node_modules/@smithy/hash-stream-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.10.tgz", - "integrity": "sha512-w78xsYrOlwXKwN5tv1GnKIRbHb1HygSpeZMP6xDxCPGf1U/xDHjCpJu64c5T35UKyEPwa0bPeIcvU69VY3khUA==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-utf8": "^4.2.1", + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3564,12 +3524,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.10.tgz", - "integrity": "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3577,9 +3537,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz", - "integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3589,13 +3549,13 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.10.tgz", - "integrity": "sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-utf8": "^4.2.1", + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3603,13 +3563,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.10.tgz", - "integrity": "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3617,18 +3577,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.18", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.18.tgz", - "integrity": "sha512-4OS3TP3IWZysT8KlSG/UwfKdelJmuQ2CqVNfrkjm2Rsm146/DuSTfXiD1ulgWpp9L6lJmPYfWTp7/m4b4dQSdQ==", + "version": "4.4.28", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", + "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.4", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-middleware": "^4.2.9", + "@smithy/core": "^3.23.13", + "@smithy/middleware-serde": "^4.2.16", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -3636,19 +3596,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.35", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.35.tgz", - "integrity": "sha512-sz+Th9ofKypOtaboPTcyZtIfCs2LNb84bzxEhPffCElyMorVYDBdeGzxYqSLC6gWaZUqpPSbj5F6TIxYUlSCfQ==", + "version": "4.4.45", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.45.tgz", + "integrity": "sha512-td1PxpwDIaw5/oP/xIRxBGxJKoF1L4DBAwbZ8wjMuXBYOP/r2ZE/Ocou+mBHx/yk9knFEtDBwhSrYVn+Mz4pHw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/service-error-classification": "^4.2.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/uuid": "^1.1.1", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -3656,13 +3616,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.10.tgz", - "integrity": "sha512-BQsdoi7ma4siJAzD0S6MedNPhiMcTdTLUqEUjrHeT1TJppBKWnwqySg34Oh/uGRhJeBd1sAH2t5tghBvcyD6tw==", + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", + "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@smithy/core": "^3.23.13", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3670,12 +3631,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.9.tgz", - "integrity": "sha512-pid7ksBr7nm0X/3paIlGo9Fh3UK1pQ5yH0007tBmdkVvv+AsBZAOzC2dmLhlzDWKkSB+ZCiiyDArjAW3klkbMg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3683,14 +3644,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.9.tgz", - "integrity": "sha512-EjdDTVGnnyJ9y8jXIfkF45UUZs21/Pp8xaMTZySLoC0xI3EhY7jq4co3LQnhh/bB6VVamd9ELpYJWLDw2ANhZA==", + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3698,15 +3659,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.11.tgz", - "integrity": "sha512-kQNJFwzYA9y+Fj3h9t1ToXYOJBobwUVEc6/WX45urJXyErgG0WOsres8Se8BAiFCMe8P06OkzRgakv7bQ5S+6Q==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", + "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/querystring-builder": "^4.2.9", - "@smithy/types": "^4.12.1", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3714,12 +3674,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.9.tgz", - "integrity": "sha512-ibHwLxq4KlbfueoNxMNrZkG+O7V/5XKrewhDGYn0p9DYKCsdsofuWHKdX3QW4zHlAUfLStqdCUSDi/q/9WSjwA==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3727,12 +3687,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.10.tgz", - "integrity": "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3740,13 +3700,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.9.tgz", - "integrity": "sha512-/AIDaq0+ehv+QfeyAjCUFShwHIt+FA1IodsV/2AZE5h4PUZcQYv5sjmy9V67UWfsBoTjOPKUFYSRfGoNW9T2UQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", - "@smithy/util-uri-escape": "^4.2.1", + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3754,12 +3714,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.9.tgz", - "integrity": "sha512-kZ9AHhrYTea3UoklXudEnyA4duy9KAWERC28+ft8y8HIhR3yGsjv1PFTgzMpB+5L4tQKXNTwFbVJMeRK20vpHQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3767,24 +3727,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.9.tgz", - "integrity": "sha512-DYYd4xrm9Ozik+ZT4f5ZqSXdzscVHF/tFCzqieIFcLrjRDxWSgRtvtXOohJGoniLfPcBcy5ltR3tp2Lw4/d9ag==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1" + "@smithy/types": "^4.13.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.4.tgz", - "integrity": "sha512-tA5Cm11BHQCk/67y6VPIWydLh/pMY90jqOEWIr/2VAzTOoDwGpwp0C/AuHBc3/xWSOA5m5PXLN+lIOrsnTm/PQ==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3792,18 +3752,18 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.10.tgz", - "integrity": "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-uri-escape": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3811,17 +3771,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.7.tgz", - "integrity": "sha512-gQP2J3qB/Wmc26gdmB8gA6zq2o2spG5sEU3o7TaTATBJEk29sYGWdEFoGEy91BczSpifTo0DQhVYjZXBEVcrpA==", + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", + "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.4", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", - "@smithy/util-stream": "^4.5.14", + "@smithy/core": "^3.23.13", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" }, "engines": { @@ -3829,9 +3789,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3841,13 +3801,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.9.tgz", - "integrity": "sha512-gYs8FrnwKoIvL+GyPz6VvweCkrXqHeD+KnOAxB+NFy6mLr4l75lFrn3dZ413DG0K2TvFtN7L43x7r8hyyohYdg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.9", - "@smithy/types": "^4.12.1", + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3855,13 +3815,13 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz", - "integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3869,9 +3829,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.1.tgz", - "integrity": "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3881,9 +3841,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.2.tgz", - "integrity": "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3893,12 +3853,12 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz", - "integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", + "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -3906,9 +3866,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.1.tgz", - "integrity": "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3918,14 +3878,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.34", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.34.tgz", - "integrity": "sha512-m75CH7xaVG8ErlnfXsIBLrgVrApejrvUpohr41CMdeWNcEu/Ouvj9fbNA7oW9Qpr0Awf+BmDRrYx72hEKgY+FQ==", + "version": "4.3.44", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz", + "integrity": "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3933,17 +3893,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.37", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.37.tgz", - "integrity": "sha512-1LcAt0PV1dletxiGwcw2IJ8vLNhfkir02NTi1i/CFCY2ObtM5wDDjn/8V2dbPrbyoh6OTFH+uayI1rSVRBMT3A==", + "version": "4.2.48", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz", + "integrity": "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.7", - "@smithy/credential-provider-imds": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/property-provider": "^4.2.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", + "@smithy/config-resolver": "^4.4.13", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.8", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3951,13 +3911,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.9.tgz", - "integrity": "sha512-9FTqTzKxCFelCKdtHb22BTbrLgw7tTI+D6r/Ci/njI0tzqWLQctS0uEDTzraCR5K6IJItfFp1QmESlBytSpRhQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.9", - "@smithy/types": "^4.12.1", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3965,9 +3925,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz", - "integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3977,12 +3937,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.10.tgz", - "integrity": "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -3990,13 +3950,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.9.tgz", - "integrity": "sha512-79hfhL/oxP40SCXJGfjfE9pjbUVfHhXZFpCWXTHqXSluzaVy7jwWs9Ui7lLbfDBSp+7i+BIwgeVIRerbIRWN6g==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.9", - "@smithy/types": "^4.12.1", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -4004,18 +3964,18 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.14", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.14.tgz", - "integrity": "sha512-IOBEiJTOltSx6MAfwkx/GSVM8/UCJxdtw13haP5OEL543lb1DN6TAypsxv+qcj4l/rKcpapbS6zK9MQGBOhoaA==", + "version": "4.5.21", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz", + "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/types": "^4.12.1", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.1", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -4023,9 +3983,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.1.tgz", - "integrity": "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4035,12 +3995,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz", - "integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -4048,13 +4008,12 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.10.tgz", - "integrity": "sha512-4eTWph/Lkg1wZEDAyObwme0kmhEb7J/JjibY2znJdrYRgKbKqB7YoEhhJVJ4R1g/SYih4zuwX7LpJaM8RsnTVg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.14.tgz", + "integrity": "sha512-2zqq5o/oizvMaFUlNiTyZ7dbgYv1a893aGut2uaxtbzTx/VYYnRxWzDHuD/ftgcw94ffenua+ZNLrbqwUYE+Bg==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -4062,9 +4021,9 @@ } }, "node_modules/@smithy/uuid": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.1.tgz", - "integrity": "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4389,31 +4348,31 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4422,7 +4381,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -4434,26 +4393,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { @@ -4461,13 +4420,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4476,9 +4436,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "license": "MIT", "funding": { @@ -4486,14 +4446,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4967,14 +4928,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axios-curlirize": { @@ -5255,13 +5216,13 @@ } }, "node_modules/bullmq": { - "version": "5.70.1", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.70.1.tgz", - "integrity": "sha512-HjfGHfICkAClrFL0Y07qNbWcmiOCv1l+nusupXUjrvTPuDEyPEJ23MP0lUwUs/QEy1a3pWt/P/sCsSZ1RjRK+w==", + "version": "5.71.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.71.1.tgz", + "integrity": "sha512-kOBfdcsHmO6wwmIjpersoVdYQ7jkjTgky4Yop0loc7QwSdgxliSzD69U9ijZuRrkyCJwz5p5eqxeGeQkJ0YGZQ==", "license": "MIT", "dependencies": { "cron-parser": "4.9.0", - "ioredis": "5.9.3", + "ioredis": "5.10.1", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.4", @@ -5748,6 +5709,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -5931,6 +5899,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6523,9 +6500,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -7138,21 +7115,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", @@ -7161,8 +7126,24 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -7204,6 +7185,29 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7277,18 +7281,17 @@ } }, "node_modules/firebase-admin": { - "version": "13.6.1", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.6.1.tgz", - "integrity": "sha512-Zgc6yPtmPxAZo+FoK6LMG6zpSEsoSK8ifIR+IqF4oWuC3uWZU40OjxgfLTSFcsRlj/k/wD66zNv2UiTRreCNSw==", + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", "license": "Apache-2.0", "dependencies": { "@fastify/busboy": "^3.0.0", "@firebase/database-compat": "^2.0.0", "@firebase/database-types": "^1.0.6", - "@types/node": "^22.8.7", "farmhash-modern": "^1.1.0", "fast-deep-equal": "^3.1.1", - "google-auth-library": "^9.14.2", + "google-auth-library": "^10.6.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.1.0", "node-forge": "^1.3.1", @@ -7299,7 +7302,79 @@ }, "optionalDependencies": { "@google-cloud/firestore": "^7.11.0", - "@google-cloud/storage": "^7.14.0" + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/firebase-admin/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/firebase-admin/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/flat-cache": { @@ -7397,6 +7472,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -7508,6 +7595,7 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", + "optional": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -7528,6 +7616,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -7537,6 +7626,7 @@ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "license": "Apache-2.0", + "optional": true, "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", @@ -7666,9 +7756,9 @@ } }, "node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -7700,6 +7790,7 @@ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "license": "Apache-2.0", + "optional": true, "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -7755,6 +7846,7 @@ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=14" } @@ -7778,9 +7870,9 @@ "license": "ISC" }, "node_modules/graphql": { - "version": "16.13.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.0.tgz", - "integrity": "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==", + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -7804,6 +7896,7 @@ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", + "optional": true, "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" @@ -8146,12 +8239,12 @@ } }, "node_modules/ioredis": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", - "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", "license": "MIT", "dependencies": { - "@ioredis/commands": "1.5.0", + "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", @@ -9234,15 +9327,6 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -9252,18 +9336,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mock-require": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", @@ -9301,9 +9373,9 @@ } }, "node_modules/moment-timezone": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.0.tgz", - "integrity": "sha512-ldA5lRNm3iJCWZcBCab4pnNL3HSZYXVb/3TYr75/1WCTWYuTqYUb5f/S384pncYjJ88lbO8Z4uPDvmoluHJc8Q==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.1.tgz", + "integrity": "sha512-1B9lmAhB9D9/sHaPC1N7wLFEVUoFldxOpOO96lOD1PvJ43vCd0ozDPbu0FEL3++VvawOlDkq8YD373tJmP5JHw==", "license": "MIT", "dependencies": { "moment": "^2.29.4" @@ -9350,21 +9422,22 @@ } }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mustache": { @@ -9424,6 +9497,26 @@ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-eta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/node-eta/-/node-eta-0.9.0.tgz", @@ -9854,6 +9947,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "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", @@ -10127,10 +10235,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -10742,10 +10853,13 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "license": "ISC" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/scmp": { "version": "2.1.0", @@ -11036,17 +11150,17 @@ } }, "node_modules/soap": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/soap/-/soap-1.7.1.tgz", - "integrity": "sha512-PYDzr2F0eb0cpyTB+hg21rzj5/wfo1AeKy3HnOVx65+eFDUUY1FOtqC/ev0pwW5Y9UcvHWJ5egr8HQsCzE92Ig==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/soap/-/soap-1.8.0.tgz", + "integrity": "sha512-WRIzZm4M13a9j1t8yMdZZtbbkxNatXAhvtO8UXc/LvdfZ/Op1MqZS6qsAbILLsLTk3oLM/PRw0XOG0U53dAZzg==", "license": "MIT", "dependencies": { - "axios": "^1.13.5", + "axios": "^1.13.6", "axios-ntlm": "^1.4.6", "debug": "^4.4.3", "follow-redirects": "^1.15.11", "formidable": "^3.5.4", - "sax": "^1.4.1", + "sax": "^1.5.0", "whatwg-mimetype": "4.0.0", "xml-crypto": "^6.1.2" }, @@ -11219,9 +11333,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -11522,9 +11636,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", @@ -11759,9 +11873,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -11814,15 +11928,15 @@ "license": "Unlicense" }, "node_modules/twilio": { - "version": "5.12.2", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.12.2.tgz", - "integrity": "sha512-yjTH04Ig0Z3PAxIXhwrto0IJC4Gv7lBDQQ9f4/P9zJhnxVdd+3tENqBMJOtdmmRags3X0jl2IGKEQefCEpJE9g==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.13.1.tgz", + "integrity": "sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==", "license": "MIT", "dependencies": { - "axios": "^1.12.0", + "axios": "^1.13.5", "dayjs": "^1.11.9", "https-proxy-agent": "^5.0.0", - "jsonwebtoken": "^9.0.2", + "jsonwebtoken": "^9.0.3", "qs": "^6.14.1", "scmp": "^2.1.0", "xmlbuilder": "^13.0.2" @@ -12177,31 +12291,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -12217,12 +12331,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -12251,6 +12366,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -12282,6 +12400,15 @@ "node": ">=4.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -12686,9 +12813,9 @@ } }, "node_modules/xml-formatter": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-3.6.7.tgz", - "integrity": "sha512-IsfFYJQuoDqtUlKhm4EzeoBOb+fQwzQVeyxxAQ0sThn/nFnQmyLPTplqq4yRhaOENH/tAyujD2TBfIYzUKB6hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-3.7.0.tgz", + "integrity": "sha512-+8qTc3zv2UcJ1v9IsSIce37Dl4MQG14Cp7tWrwmy202UaI1wqRukw5QMX1JHsV+DX64yw77EgGsj2s5wGvuMbQ==", "license": "MIT", "dependencies": { "xml-parser-xo": "^4.1.5" @@ -12761,15 +12888,6 @@ "node": ">=0.6.0" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "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 7e0230da2..9ff798efb 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,16 @@ "job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js" }, "dependencies": { - "@aws-sdk/client-cloudwatch-logs": "^3.997.0", - "@aws-sdk/client-elasticache": "^3.997.0", - "@aws-sdk/client-s3": "^3.997.0", - "@aws-sdk/client-secrets-manager": "^3.997.0", - "@aws-sdk/client-ses": "^3.997.0", - "@aws-sdk/client-sqs": "^3.997.0", - "@aws-sdk/client-textract": "^3.997.0", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/lib-storage": "^3.997.0", - "@aws-sdk/s3-request-presigner": "^3.997.0", + "@aws-sdk/client-cloudwatch-logs": "^3.1020.0", + "@aws-sdk/client-elasticache": "^3.1020.0", + "@aws-sdk/client-s3": "^3.1020.0", + "@aws-sdk/client-secrets-manager": "^3.1020.0", + "@aws-sdk/client-ses": "^3.1020.0", + "@aws-sdk/client-sqs": "^3.1020.0", + "@aws-sdk/client-textract": "^3.1020.0", + "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/lib-storage": "^3.1020.0", + "@aws-sdk/s3-request-presigner": "^3.1020.0", "@documenso/sdk-typescript": "^0.8.0", "@jsreport/nodejs-client": "^4.1.0", "@opensearch-project/opensearch": "^2.13.0", @@ -35,10 +35,10 @@ "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.1", "aws4": "^1.13.2", - "axios": "^1.13.5", + "axios": "^1.14.0", "axios-curlirize": "^2.0.0", "better-queue": "^3.8.12", - "bullmq": "^5.70.1", + "bullmq": "^5.71.1", "chart.js": "^4.5.1", "cloudinary": "^2.9.0", "compression": "^1.8.1", @@ -48,20 +48,20 @@ "dinero.js": "^1.9.1", "dotenv": "^17.3.1", "express": "^4.21.1", - "fast-xml-parser": "^5.4.1", - "firebase-admin": "^13.6.1", + "fast-xml-parser": "^5.5.9", + "firebase-admin": "^13.7.0", "fuse.js": "^7.1.0", - "graphql": "^16.13.0", + "graphql": "^16.13.2", "graphql-request": "^6.1.0", "intuit-oauth": "^4.2.2", - "ioredis": "^5.9.3", + "ioredis": "^5.10.1", "json-2-csv": "^5.5.10", "jsonwebtoken": "^9.0.3", "juice": "^11.1.1", "lodash": "^4.17.23", "moment": "^2.30.1", - "moment-timezone": "^0.6.0", - "multer": "^2.0.2", + "moment-timezone": "^0.6.1", + "multer": "^2.1.1", "mustache": "^4.2.0", "node-persist": "^4.0.4", "nodemailer": "^6.10.0", @@ -71,15 +71,15 @@ "recursive-diff": "^1.0.9", "rimraf": "^6.1.3", "skia-canvas": "^3.0.8", - "soap": "^1.7.1", + "soap": "^1.8.0", "socket.io": "^4.8.3", "socket.io-adapter": "^2.5.6", "ssh2-sftp-client": "^11.0.0", - "twilio": "^5.12.2", + "twilio": "^5.13.1", "uuid": "^11.1.0", "winston": "^3.19.0", "winston-cloudwatch": "^6.3.0", - "xml-formatter": "^3.6.7", + "xml-formatter": "^3.7.0", "xml2js": "^0.6.2", "xmlbuilder2": "^4.0.3", "yazl": "^3.3.1" @@ -88,11 +88,11 @@ "@eslint/js": "^9.39.2", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", - "globals": "^17.3.0", + "globals": "^17.4.0", "mock-require": "^3.0.3", "p-limit": "^3.1.0", "prettier": "^3.8.1", "supertest": "^7.2.2", - "vitest": "^4.0.18" + "vitest": "^4.1.2" } } diff --git a/server.js b/server.js index 82f2928e1..0398cf2cf 100644 --- a/server.js +++ b/server.js @@ -281,8 +281,8 @@ const connectToRedisCluster = async () => { redisCluster.on("node error", (error, node) => { console.dir(error); logger.log(`Redis node error`, "ERROR", "redis", "api", { - host: node.options.host, - port: node.options.port, + host: node?.options?.host, + port: node?.options?.port, message: error.message }); }); diff --git a/server/accounting/pbs/pbs-job-export.js b/server/accounting/pbs/pbs-job-export.js index ee6360682..7e0499023 100644 --- a/server/accounting/pbs/pbs-job-export.js +++ b/server/accounting/pbs/pbs-job-export.js @@ -787,7 +787,8 @@ async function RepairOrderChange(socket) { // "DatePromisedUTC": "0001-01-01T00:00:00.0000000Z", DateVehicleCompleted: socket.JobData.actual_completion, // "DateCustomerNotified": "0001-01-01T00:00:00.0000000Z", - // "CSR": "String", + "CSR": "IMEX", //Hardcoded for now as the only shop that uses this RO posting is RC and they paid for a custom build. + Shop: "RB", // "CSRRef": "00000000000000000000000000000000", // "BookingUser": "String", // "BookingUserRef": "00000000000000000000000000000000", diff --git a/server/chatter/chatter-client.js b/server/chatter/chatter-client.js index 047e94459..b9b024737 100644 --- a/server/chatter/chatter-client.js +++ b/server/chatter/chatter-client.js @@ -1,20 +1,17 @@ const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager"); const { defaultProvider } = require("@aws-sdk/credential-provider-node"); -const { isString, isEmpty } = require("lodash"); +const { InstanceRegion, InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("../utils/instanceMgr"); const CHATTER_BASE_URL = process.env.CHATTER_API_BASE_URL || "https://api.chatterresearch.com"; -const AWS_REGION = process.env.AWS_REGION || "ca-central-1"; // Configure SecretsManager client with localstack support const secretsClientOptions = { - region: AWS_REGION, + region: InstanceRegion(), credentials: defaultProvider() }; -const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME); - -if (isLocal) { - secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`; +if (InstanceIsLocalStackEnabled()) { + secretsClientOptions.endpoint = InstanceLocalStackEndpoint(); } const secretsClient = new SecretsManagerClient(secretsClientOptions); diff --git a/server/chatter/createLocation.js b/server/chatter/createLocation.js index 46766dcf8..93d5e11db 100644 --- a/server/chatter/createLocation.js +++ b/server/chatter/createLocation.js @@ -33,8 +33,6 @@ const createLocation = async (req, res) => { const { logger } = req; const { bodyshopID, googlePlaceID } = req.body; - console.dir({ body: req.body }); - if (!DEFAULT_COMPANY_ID) { logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID }); return res.json({ success: false, message: "No default company set" }); @@ -67,7 +65,7 @@ const createLocation = async (req, res) => { const chatterApi = await createChatterClient(DEFAULT_COMPANY_ID); - const locationIdentifier = `${DEFAULT_COMPANY_ID}-${bodyshop.id}`; + const locationIdentifier = bodyshop?.imexshopid ?? `${DEFAULT_COMPANY_ID}-${bodyshop.id}`; const locationPayload = { name: bodyshop.shopname, diff --git a/server/data/carfax.js b/server/data/carfax.js index 250d553bb..ec5c7b4a2 100644 --- a/server/data/carfax.js +++ b/server/data/carfax.js @@ -3,7 +3,7 @@ const Dinero = require("dinero.js"); const moment = require("moment-timezone"); const logger = require("../utils/logger"); const InstanceManager = require("../utils/instanceMgr").default; -const { isString, isEmpty } = require("lodash"); +const { InstanceIsLocalStackEnabled } = require("../utils/instanceMgr"); const fs = require("fs"); const client = require("../graphql-client/graphql-client").client; const { sendServerEmail, sendMexicoBillingEmail } = require("../email/sendemail"); @@ -35,10 +35,9 @@ const S3_BUCKET_NAME = InstanceManager({ rome: "rome-carfax-uploads" }); const region = InstanceManager.InstanceRegion; -const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME); const uploadToS3 = (jsonObj, bucketName = S3_BUCKET_NAME) => { - const webPath = isLocal + const webPath = InstanceIsLocalStackEnabled() ? `https://${bucketName}.s3.localhost.localstack.cloud:4566/${jsonObj.filename}` : `https://${bucketName}.s3.${region}.amazonaws.com/${jsonObj.filename}`; diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js index 1aecb7cfb..010f75aa6 100644 --- a/server/data/chatter-api.js +++ b/server/data/chatter-api.js @@ -250,7 +250,8 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop function buildInteractionPayload(bodyshop, j) { const isCompany = Boolean(j.ownr_co_nm); - const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`; + const locationIdentifier = bodyshop?.imexshopid ?? `${bodyshop.chatter_company_id}-${bodyshop.id}`; + const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone); if (j.actual_delivery && !timestamp) { diff --git a/server/data/chatter.js b/server/data/chatter.js index 86182fbf9..5da405b35 100644 --- a/server/data/chatter.js +++ b/server/data/chatter.js @@ -5,7 +5,8 @@ const logger = require("../utils/logger"); const fs = require("fs"); const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager"); const { defaultProvider } = require("@aws-sdk/credential-provider-node"); -const { isString, isEmpty } = require("lodash"); +const { InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("../utils/instanceMgr"); + let Client = require("ssh2-sftp-client"); const client = require("../graphql-client/graphql-client").client; @@ -151,10 +152,8 @@ async function getPrivateKey() { credentials: defaultProvider() }; - const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME); - - if (isLocal) { - secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`; + if (InstanceIsLocalStackEnabled()) { + secretsClientOptions.endpoint = InstanceLocalStackEndpoint(); } const client = new SecretsManagerClient(secretsClientOptions); diff --git a/server/email/mailer.js b/server/email/mailer.js index 6134053b6..41300caa9 100644 --- a/server/email/mailer.js +++ b/server/email/mailer.js @@ -1,20 +1,17 @@ -const { isString, isEmpty } = require("lodash"); const { defaultProvider } = require("@aws-sdk/credential-provider-node"); -const { InstanceRegion } = require("../utils/instanceMgr"); +const { InstanceRegion, InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("../utils/instanceMgr"); const aws = require("@aws-sdk/client-ses"); const nodemailer = require("nodemailer"); const logger = require("../utils/logger"); -const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME); - const sesConfig = { apiVersion: "latest", credentials: defaultProvider(), region: InstanceRegion() }; -if (isLocal) { - sesConfig.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`; +if (InstanceIsLocalStackEnabled()) { + sesConfig.endpoint = InstanceLocalStackEndpoint(); logger.logger.debug(`SES Mailer set to LocalStack end point: ${sesConfig.endpoint}`); } diff --git a/server/fortellis/fortellis.js b/server/fortellis/fortellis.js index 02ed67852..ee01691e9 100644 --- a/server/fortellis/fortellis.js +++ b/server/fortellis/fortellis.js @@ -334,30 +334,48 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome socket.emit("export-success", JobData.id); } else { //There was something wrong. Throw an error to trigger clean up. - //throw new Error("Error posting DMS Batch Transaction"); + const batchPostError = new Error(DmsBatchTxnPost.sendline || "Error posting DMS Batch Transaction"); + batchPostError.errorData = { DMSTransHeader, DmsBatchTxnPost }; + throw batchPostError; } - } catch { + } catch (error) { //Clean up the transaction and insert a faild error code // //Get the error code CreateFortellisLogEvent(socket, "DEBUG", `{6.1} Getting errors for Transaction ID ${DMSTransHeader.transID}`); - const DmsError = await QueryDmsErrWip({ socket, redisHelpers, JobData }); - // //Delete the transaction + let dmsErrors = []; + try { + const DmsError = await QueryDmsErrWip({ socket, redisHelpers, JobData }); + dmsErrors = Array.isArray(DmsError?.errMsg) ? DmsError.errMsg.filter((e) => e !== null && e !== "") : []; + + dmsErrors.forEach((e) => { + CreateFortellisLogEvent(socket, "ERROR", `Error encountered in posting transaction => ${e} `); + }); + } catch (queryError) { + CreateFortellisLogEvent( + socket, + "ERROR", + `{6.1} Unable to read ErrWIP for Transaction ID ${DMSTransHeader.transID}: ${queryError.message}` + ); + } + + //Delete the transaction, even if querying ErrWIP fails. CreateFortellisLogEvent(socket, "DEBUG", `{6.2} Deleting Transaction ID ${DMSTransHeader.transID}`); + try { + await DeleteDmsWip({ socket, redisHelpers, JobData }); + } catch (cleanupError) { + CreateFortellisLogEvent( + socket, + "ERROR", + `{6.2} Failed cleanup for Transaction ID ${DMSTransHeader.transID}: ${cleanupError.message}` + ); + } - await DeleteDmsWip({ socket, redisHelpers, JobData }); - - DmsError.errMsg.map( - (e) => - e !== null && - e !== "" && - CreateFortellisLogEvent(socket, "ERROR", `Error encountered in posting transaction => ${e} `) - ); - await InsertFailedExportLog({ - socket, - JobData, - error: DmsError.errMsg - }); + if (!error.errorData || typeof error.errorData !== "object") { + error.errorData = {}; + } + error.errorData.issues = dmsErrors.length ? dmsErrors : [error.message]; + throw error; } } } catch (error) { diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 2cf25b788..2697998c9 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2440,6 +2440,9 @@ exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) { associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) { id shopid + bodyshop { + rr_dealerid + } } }`; @@ -2461,6 +2464,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) { } percentage labor_rates + payout_method + commission_rates } } } @@ -2471,6 +2476,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) { productivehrs actualhrs ciecacode + payout_context } lbr_adjustments ro_number @@ -2562,6 +2568,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) { } percentage labor_rates + payout_method + commission_rates } } } @@ -2572,6 +2580,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) { productivehrs actualhrs ciecacode + payout_context } lbr_adjustments ro_number diff --git a/server/intellipay/lib/handleCommentBasedPayment.js b/server/intellipay/lib/handleCommentBasedPayment.js index fd2ff3ef8..5cd279a59 100644 --- a/server/intellipay/lib/handleCommentBasedPayment.js +++ b/server/intellipay/lib/handleCommentBasedPayment.js @@ -1,7 +1,7 @@ const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail"); const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries"); const getPaymentType = require("./getPaymentType"); -const moment = require("moment"); +const moment = require("moment-timezone"); const gqlClient = require("../../graphql-client/graphql-client").client; diff --git a/server/intellipay/lib/handleInvoiceBasedPayment.js b/server/intellipay/lib/handleInvoiceBasedPayment.js index 34a1a5e8d..7f404547e 100644 --- a/server/intellipay/lib/handleInvoiceBasedPayment.js +++ b/server/intellipay/lib/handleInvoiceBasedPayment.js @@ -8,7 +8,7 @@ const { const { sendTaskEmail } = require("../../email/sendemail"); const getPaymentType = require("./getPaymentType"); -const moment = require("moment"); +const moment = require("moment-timezone"); const gqlClient = require("../../graphql-client/graphql-client").client; diff --git a/server/intellipay/lib/tests/intelliPayGeneralLibs.test.js b/server/intellipay/lib/tests/intelliPayGeneralLibs.test.js index 9c9bec789..a6de12638 100644 --- a/server/intellipay/lib/tests/intelliPayGeneralLibs.test.js +++ b/server/intellipay/lib/tests/intelliPayGeneralLibs.test.js @@ -1,5 +1,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn() +})); + +vi.mock("@aws-sdk/client-secrets-manager", () => { + return { + SecretsManagerClient: vi.fn(() => ({ + send: mockSend + })), + GetSecretValueCommand: vi.fn((input) => input) + }; +}); + const getPaymentType = require("../getPaymentType"); const decodeComment = require("../decodeComment"); const getCptellerUrl = require("../getCptellerUrl"); @@ -145,28 +158,15 @@ describe("Payment Processing Functions", () => { // GetShopCredentials Tests describe("getShopCredentials", () => { const originalEnv = { ...process.env }; - let mockSend; beforeEach(() => { - mockSend = vi.fn(); - vi.mock("@aws-sdk/client-secrets-manager", () => { - return { - SecretsManagerClient: vi.fn(() => ({ - send: mockSend - })), - GetSecretValueCommand: vi.fn((input) => input) - }; - }); - + mockSend.mockReset(); process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key"; process.env.INTELLIPAY_APIKEY = "test-api-key"; - vi.resetModules(); }); afterEach(() => { process.env = { ...originalEnv }; - vi.restoreAllMocks(); - vi.unmock("@aws-sdk/client-secrets-manager"); }); it("returns environment variables in non-production environment", async () => { diff --git a/server/job/job-totals-USA.js b/server/job/job-totals-USA.js index a8bd632cc..29d541e2e 100644 --- a/server/job/job-totals-USA.js +++ b/server/job/job-totals-USA.js @@ -116,6 +116,32 @@ async function TotalsServerSide(req, res) { ret.totals.ttl_tax_adjustment = Dinero({ amount: Math.round(ttlTaxDifference * 100) }); ret.totals.total_repairs = ret.totals.total_repairs.add(ret.totals.ttl_tax_adjustment); ret.totals.net_repairs = ret.totals.net_repairs.add(ret.totals.ttl_tax_adjustment); + + if (Math.abs(totalUsTaxes) === 0) { + const laborRates = Object.values(job.cieca_pfl) + .map((v) => v.lbr_taxp) + .filter((v) => v != null); + const materialRates = Object.values(job.materials) + .map((v) => v.mat_taxp) + .filter((v) => v != null); + const partsRates = Object.values(job.parts_tax_rates) + .map((v) => { + const field = v.prt_tax_rt ?? v.part_tax_rt; + if (field == null) return null; + const raw = typeof field === "object" ? field.parsedValue : field; + return raw != null ? raw * 100 : null; + }) + .filter((v) => v != null); + const taxRate = Math.max(...laborRates, ...materialRates, ...partsRates); + + const totalTaxes = ret.totals.taxableAmounts.total.multiply(taxRate > 1 ? taxRate / 100 : taxRate); + ret.totals.taxableAmounts.total = ret.totals.taxableAmounts.total + .multiply(emsTaxTotal) + .divide(totalTaxes.toUnit()); + } else { + ret.totals.taxableAmounts.total = ret.totals.taxableAmounts.total.multiply(emsTaxTotal).divide(totalUsTaxes); + } + logger.log("job-totals-USA-ttl-tax-adj", "debug", null, job.id, { adjAmount: ttlTaxDifference }); @@ -885,6 +911,8 @@ function CalculateTaxesTotals(job, otherTotals) { STOR: Dinero() }; + const pfp = job.parts_tax_rates; + //For each line, determine if it's taxable, and if it is, add the line amount to the taxable amounts total. job.joblines .filter((jl) => !jl.removed) @@ -916,7 +944,17 @@ function CalculateTaxesTotals(job, otherTotals) { }) .multiply(val.part_qty || 0) .add(discMarkupAmount); - taxableAmounts[typeOfPart] = taxableAmounts[typeOfPart].add(partAmount); + if (taxableAmounts[typeOfPart]) { + taxableAmounts[typeOfPart] = taxableAmounts[typeOfPart].add(partAmount); + } else { + const isTaxableCustomType = + IsTrueOrYes(pfp?.[typeOfPart]?.prt_tax_in) || pfp?.[typeOfPart]?.prt_tax_in === true; + + if (isTaxableCustomType) { + if (!taxableAmounts[typeOfPart]) taxableAmounts[typeOfPart] = Dinero(); + taxableAmounts[typeOfPart] = taxableAmounts[typeOfPart].add(partAmount); + } + } } }); @@ -969,16 +1007,20 @@ function CalculateTaxesTotals(job, otherTotals) { }) ); - const pfp = job.parts_tax_rates; - //For any profile level markups/discounts, add them in now as well. Object.keys(otherTotals.parts.adjustments).forEach((key) => { const adjustmentAmount = otherTotals.parts.adjustments[key]; if (adjustmentAmount.getAmount() !== 0 && pfp[key]?.prt_tax_in) { - taxableAmounts[key] = taxableAmounts[key].add(adjustmentAmount); + if (taxableAmounts[key]) { + taxableAmounts[key] = taxableAmounts[key].add(adjustmentAmount); + } else { + taxableAmounts[key] = Dinero().add(adjustmentAmount); + } } }); + taxableAmounts.total = Object.values(taxableAmounts).reduce((acc, amount) => acc.add(amount), Dinero({ amount: 0 })); + // console.log("*** Taxable Amounts***"); // console.table(JSON.parse(JSON.stringify(taxableAmounts))); @@ -1072,6 +1114,21 @@ function CalculateTaxesTotals(job, otherTotals) { ); } } + } else if (pfp[key]) { + //Custom part types (e.g. CC*) should flow through taxableAmountsByTier too. + let assignedToTier = false; + for (let tyCounter = 1; tyCounter <= 5; tyCounter++) { + if (IsTrueOrYes(pfp[key][`prt_tx_in${tyCounter}`])) { + taxableAmountsByTier[`ty${tyCounter}Tax`] = taxableAmountsByTier[`ty${tyCounter}Tax`].add( + taxableAmounts[key] + ); + assignedToTier = true; + } + } + + if (!assignedToTier && (IsTrueOrYes(pfp[key].prt_tax_in) || pfp[key].prt_tax_in === true)) { + taxableAmountsByTier.ty1Tax = taxableAmountsByTier.ty1Tax.add(taxableAmounts[key]); + } } } catch (error) { logger.log("job-totals-USA Key with issue", "warn", null, job.id, { @@ -1174,6 +1231,7 @@ function CalculateTaxesTotals(job, otherTotals) { let ret = { subtotal: subtotal, + taxableAmounts: taxableAmounts, federal_tax: subtotal .percentage((job.federal_tax_rate || 0) * 100) .add(otherTotals.additional.pvrt.percentage((job.federal_tax_rate || 0) * 100)), diff --git a/server/job/test/job-totals.test.js b/server/job/test/job-totals.test.js index 04a915b6b..07dd5e3a4 100644 --- a/server/job/test/job-totals.test.js +++ b/server/job/test/job-totals.test.js @@ -35,6 +35,11 @@ describe("TotalsServerSide fixture tests", () => { const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith(".json")); + if (fixtureFiles.length === 0) { + it.skip("skips when no job total fixtures are present", () => {}); + return; + } + const dummyClient = { request: async () => { return {}; diff --git a/server/opensearch/os-handler.js b/server/opensearch/os-handler.js index 663e8a015..6068c6464 100644 --- a/server/opensearch/os-handler.js +++ b/server/opensearch/os-handler.js @@ -4,6 +4,7 @@ const queries = require("../graphql-client/queries"); const client = require("../graphql-client/graphql-client").client; const { pick, isNil } = require("lodash"); const { getClient } = require("../../libs/awsUtils"); +const { JOB_DOCUMENT_FIELDS, getGlobalSearchQueryStringFields } = require("./os-search-config"); async function OpenSearchUpdateHandler(req, res) { try { @@ -21,27 +22,7 @@ async function OpenSearchUpdateHandler(req, res) { switch (req.body.table.name) { case "jobs": - document = pick(req.body.event.data.new, [ - "id", - "bodyshopid", - "clm_no", - "clm_total", - "comment", - "ins_co_nm", - "owner_owing", - "ownr_co_nm", - "ownr_fn", - "ownr_ln", - "ownr_ph1", - "ownr_ph2", - "plate_no", - "ro_number", - "status", - "v_model_yr", - "v_make_desc", - "v_model_desc", - "v_vin" - ]); + document = pick(req.body.event.data.new, JOB_DOCUMENT_FIELDS); document.bodyshopid = req.body.event.data.new.shopid; break; case "vehicles": @@ -197,15 +178,18 @@ async function OpenSearchSearchHandler(req, res) { user: req.user.email }); - if (assocs.length === 0) { + if (assocs.associations.length === 0) { res.sendStatus(401); + return; } const osClient = await getClient(); + const activeAssociation = assocs.associations[0]; const bodyShopIdMatchOverride = isNil(process.env.BODY_SHOP_ID_MATCH_OVERRIDE) - ? assocs.associations[0].shopid + ? activeAssociation.shopid : process.env.BODY_SHOP_ID_MATCH_OVERRIDE; + const isReynoldsEnabled = Boolean(activeAssociation.bodyshop?.rr_dealerid); const { body } = await osClient.search({ ...(index ? { index } : { index: ["jobs", "vehicles", "owners", "bills", "payments"] }), @@ -241,21 +225,8 @@ async function OpenSearchSearchHandler(req, res) { query: `*${search}*`, // Weighted Fields fields: [ - "*ro_number^20", - "*clm_no^14", - "*v_vin^12", - "*plate_no^12", - "*ownr_ln^10", - "transactionid^10", - "paymentnum^10", - "invoice_number^10", - "*ownr_fn^8", - "*ownr_co_nm^8", - "*ownr_ph1^8", - "*ownr_ph2^8", - "*vendor.name^8", - "*comment^6" - // "*" + ...getGlobalSearchQueryStringFields({ isReynoldsEnabled }) + // "*" ] } } diff --git a/server/opensearch/os-search-config.js b/server/opensearch/os-search-config.js new file mode 100644 index 000000000..3ef8bdcdf --- /dev/null +++ b/server/opensearch/os-search-config.js @@ -0,0 +1,69 @@ +/** + * Fields to be included in the job document indexed in OpenSearch. These fields are used for both indexing and + * searching. + * @type {string[]} + */ +const JOB_DOCUMENT_FIELDS = [ + "id", + "bodyshopid", + "clm_no", + "clm_total", + "comment", + "dms_id", + "ins_co_nm", + "owner_owing", + "ownr_co_nm", + "ownr_fn", + "ownr_ln", + "ownr_ph1", + "ownr_ph2", + "plate_no", + "ro_number", + "status", + "v_model_yr", + "v_make_desc", + "v_model_desc", + "v_vin" +]; + +/** + * Fields to be included in the global search query string. These fields are used for constructing the search query. + * @type {string[]} + */ +const BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS = [ + "*ro_number^20", + "*clm_no^14", + "*v_vin^12", + "*plate_no^12", + "*ownr_ln^10", + "transactionid^10", + "paymentnum^10", + "invoice_number^10", + "*ownr_fn^8", + "*ownr_co_nm^8", + "*ownr_ph1^8", + "*ownr_ph2^8", + "*vendor.name^8", + "*comment^6" +]; + +/** + * Returns the fields to be included in the global search query string. If Reynolds is enabled, it includes the dms_id + * field with a higher boost. + * @param param0 + * @param param0.isReynoldsEnabled + * @returns {string[]} + */ +const getGlobalSearchQueryStringFields = ({ isReynoldsEnabled = false } = {}) => { + if (!isReynoldsEnabled) { + return BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS; + } + + return ["*dms_id^20", ...BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS]; +}; + +module.exports = { + JOB_DOCUMENT_FIELDS, + BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS, + getGlobalSearchQueryStringFields +}; diff --git a/server/opensearch/tests/os-search-config.test.js b/server/opensearch/tests/os-search-config.test.js new file mode 100644 index 000000000..d6e08a006 --- /dev/null +++ b/server/opensearch/tests/os-search-config.test.js @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { JOB_DOCUMENT_FIELDS, BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS, getGlobalSearchQueryStringFields } = require( + "../os-search-config" +); + +describe("os-search-config", () => { + it("indexes dms_id on job documents", () => { + expect(JOB_DOCUMENT_FIELDS).toContain("dms_id"); + }); + + it("includes dms_id in global search fields for Reynolds shops", () => { + expect(getGlobalSearchQueryStringFields({ isReynoldsEnabled: true })).toContain("*dms_id^20"); + }); + + it("keeps the default search fields unchanged for non-Reynolds shops", () => { + expect(getGlobalSearchQueryStringFields()).toEqual(BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS); + }); +}); diff --git a/server/payroll/calculate-totals.js b/server/payroll/calculate-totals.js index 9df4d5244..3fda9220c 100644 --- a/server/payroll/calculate-totals.js +++ b/server/payroll/calculate-totals.js @@ -1,20 +1,9 @@ -const Dinero = require("dinero.js"); const queries = require("../graphql-client/queries"); const logger = require("../utils/logger"); const { CalculateExpectedHoursForJob, CalculateTicketsHoursForJob } = require("./pay-all"); -// Dinero.defaultCurrency = "USD"; -// Dinero.globalLocale = "en-CA"; -Dinero.globalRoundingMode = "HALF_EVEN"; - -const get = (obj, key) => { - return key.split(".").reduce((o, x) => { - return typeof o == "undefined" || o === null ? o : o[x]; - }, obj); -}; - exports.calculatelabor = async function (req, res) { - const { jobid, calculateOnly } = req.body; + const { jobid } = req.body; logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null); const BearerToken = req.BearerToken; @@ -41,23 +30,19 @@ exports.calculatelabor = async function (req, res) { Object.keys(employeeHash).forEach((employeeIdKey) => { //At the employee level. Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => { - //At the labor level - Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => { - //At the rate level. - const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey]; - //Will the following line fail? Probably if it doesn't exist. - const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`); - if (claimedHours) { - delete ticketHash[employeeIdKey][laborTypeKey][rateKey]; - } + const expected = employeeHash[employeeIdKey][laborTypeKey]; + const claimed = ticketHash?.[employeeIdKey]?.[laborTypeKey]; - totals.push({ - employeeid: employeeIdKey, - rate: rateKey, - mod_lbr_ty: laborTypeKey, - expectedHours, - claimedHours: claimedHours || 0 - }); + if (claimed) { + delete ticketHash[employeeIdKey][laborTypeKey]; + } + + totals.push({ + employeeid: employeeIdKey, + rate: expected.rate, + mod_lbr_ty: laborTypeKey, + expectedHours: expected.hours, + claimedHours: claimed?.hours || 0 }); }); }); @@ -65,23 +50,14 @@ exports.calculatelabor = async function (req, res) { Object.keys(ticketHash).forEach((employeeIdKey) => { //At the employee level. Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => { - //At the labor level - Object.keys(ticketHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => { - //At the rate level. - const expectedHours = 0; - //Will the following line fail? Probably if it doesn't exist. - const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`); - if (claimedHours) { - delete ticketHash[employeeIdKey][laborTypeKey][rateKey]; - } + const claimed = ticketHash[employeeIdKey][laborTypeKey]; - totals.push({ - employeeid: employeeIdKey, - rate: rateKey, - mod_lbr_ty: laborTypeKey, - expectedHours, - claimedHours: claimedHours || 0 - }); + totals.push({ + employeeid: employeeIdKey, + rate: claimed.rate, + mod_lbr_ty: laborTypeKey, + expectedHours: 0, + claimedHours: claimed.hours || 0 }); }); }); @@ -101,6 +77,6 @@ exports.calculatelabor = async function (req, res) { jobid: jobid, error }); - res.status(503).send(); + res.status(400).json({ error: error.message }); } }; diff --git a/server/payroll/claim-task.js b/server/payroll/claim-task.js index 35c07cb35..6e130a5f1 100644 --- a/server/payroll/claim-task.js +++ b/server/payroll/claim-task.js @@ -1,11 +1,42 @@ -const Dinero = require("dinero.js"); const queries = require("../graphql-client/queries"); const logger = require("../utils/logger"); -const { CalculateExpectedHoursForJob } = require("./pay-all"); +const { CalculateExpectedHoursForJob, RoundPayrollHours } = require("./pay-all"); const moment = require("moment"); -// Dinero.defaultCurrency = "USD"; -// Dinero.globalLocale = "en-CA"; -Dinero.globalRoundingMode = "HALF_EVEN"; + +const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000; + +const getTaskPresetAllocationError = (taskPresets = []) => { + const totalsByLaborType = {}; + + taskPresets.forEach((taskPreset) => { + const percent = normalizePercent(taskPreset?.percent); + + if (!percent) { + return; + } + + const laborTypes = Array.isArray(taskPreset?.hourstype) ? taskPreset.hourstype : []; + + laborTypes.forEach((laborType) => { + if (!laborType) { + return; + } + + totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent); + }); + }); + + const overAllocatedType = Object.entries(totalsByLaborType).find(([, total]) => total > 100); + + if (!overAllocatedType) { + return null; + } + + const [laborType, total] = overAllocatedType; + return `Task preset percentages for labor type ${laborType} total ${total}% and cannot exceed 100%.`; +}; + +exports.GetTaskPresetAllocationError = getTaskPresetAllocationError; exports.claimtask = async function (req, res) { const { jobid, task, calculateOnly, employee } = req.body; @@ -21,12 +52,25 @@ exports.claimtask = async function (req, res) { id: jobid }); - const theTaskPreset = job.bodyshop.md_tasks_presets.presets.find((tp) => tp.name === task); + const taskPresets = job.bodyshop?.md_tasks_presets?.presets || []; + const taskPresetAllocationError = getTaskPresetAllocationError(taskPresets); + if (taskPresetAllocationError) { + res.status(400).json({ success: false, error: taskPresetAllocationError }); + return; + } + + const theTaskPreset = taskPresets.find((tp) => tp.name === task); if (!theTaskPreset) { res.status(400).json({ success: false, error: "Provided task preset not found." }); return; } + const taskAlreadyCompleted = (job.completed_tasks || []).some((completedTask) => completedTask?.name === task); + if (taskAlreadyCompleted) { + res.status(400).json({ success: false, error: "Provided task preset has already been completed for this job." }); + return; + } + //Get all of the assignments that are filtered. const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype); const ticketsToInsert = []; @@ -35,32 +79,37 @@ exports.claimtask = async function (req, res) { Object.keys(employeeHash).forEach((employeeIdKey) => { //At the employee level. Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => { - //At the labor level - Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => { - //At the rate level. - const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey] * (theTaskPreset.percent / 100); + const expected = employeeHash[employeeIdKey][laborTypeKey]; + const expectedHours = RoundPayrollHours(expected.hours * (theTaskPreset.percent / 100)); - ticketsToInsert.push({ - task_name: task, - jobid: job.id, - bodyshopid: job.bodyshop.id, - employeeid: employeeIdKey, - productivehrs: expectedHours, - rate: rateKey, - ciecacode: laborTypeKey, - flat_rate: true, - cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey], - memo: `*Flagged Task* ${theTaskPreset.memo}` - }); + ticketsToInsert.push({ + task_name: task, + jobid: job.id, + bodyshopid: job.bodyshop.id, + employeeid: employeeIdKey, + productivehrs: expectedHours, + rate: expected.rate, + ciecacode: laborTypeKey, + flat_rate: true, + created_by: employee?.name || req.user.email, + payout_context: { + ...(expected.payoutContext || {}), + generated_by: req.user.email, + generated_at: new Date().toISOString(), + generated_from: "claimtask", + task_name: task + }, + cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey], + memo: `*Flagged Task* ${theTaskPreset.memo}` }); }); }); if (!calculateOnly) { //Insert the time ticekts if we're not just calculating them. - const insertResult = await client.request(queries.INSERT_TIME_TICKETS, { + await client.request(queries.INSERT_TIME_TICKETS, { timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0) }); - const updateResult = await client.request(queries.UPDATE_JOB, { + await client.request(queries.UPDATE_JOB, { jobId: job.id, job: { status: theTaskPreset.nextstatus, @@ -82,6 +131,6 @@ exports.claimtask = async function (req, res) { jobid: jobid, error }); - res.status(503).send(); + res.status(400).json({ success: false, error: error.message }); } }; diff --git a/server/payroll/pay-all.js b/server/payroll/pay-all.js index 6a7147ca1..0e2083d7e 100644 --- a/server/payroll/pay-all.js +++ b/server/payroll/pay-all.js @@ -1,15 +1,196 @@ const Dinero = require("dinero.js"); const queries = require("../graphql-client/queries"); -const rdiff = require("recursive-diff"); - const logger = require("../utils/logger"); -// Dinero.defaultCurrency = "USD"; -// Dinero.globalLocale = "en-CA"; Dinero.globalRoundingMode = "HALF_EVEN"; +Dinero.globalFormatRoundingMode = "HALF_EVEN"; + +const PAYOUT_METHODS = { + hourly: "hourly", + commission: "commission" +}; + +const CURRENCY_PRECISION = 2; +const HOURS_PRECISION = 5; + +const toNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const normalizeNumericString = (value) => { + if (typeof value === "string") { + return value.trim(); + } + + if (typeof value === "number" && Number.isFinite(value)) { + const asString = value.toString(); + + if (!asString.toLowerCase().includes("e")) { + return asString; + } + + return value.toFixed(12).replace(/0+$/, "").replace(/\.$/, ""); + } + + return `${value ?? ""}`.trim(); +}; + +const decimalToDinero = (value, errorMessage = "Invalid numeric value.") => { + const normalizedValue = normalizeNumericString(value); + const parsedValue = Number(normalizedValue); + + if (!Number.isFinite(parsedValue)) { + throw new Error(errorMessage); + } + + const isNegative = normalizedValue.startsWith("-"); + const unsignedValue = normalizedValue.replace(/^[+-]/, ""); + const [wholePart = "0", fractionPartRaw = ""] = unsignedValue.split("."); + const wholeDigits = wholePart.replace(/\D/g, "") || "0"; + const fractionDigits = fractionPartRaw.replace(/\D/g, ""); + const amount = Number(`${wholeDigits}${fractionDigits}` || "0") * (isNegative ? -1 : 1); + + return Dinero({ + amount, + precision: fractionDigits.length + }); +}; + +const roundValueWithDinero = (value, precision, errorMessage) => + decimalToDinero(value, errorMessage).convertPrecision(precision, Dinero.globalRoundingMode).toUnit(); + +const roundCurrency = (value, errorMessage = "Invalid currency value.") => + roundValueWithDinero(value, CURRENCY_PRECISION, errorMessage); + +const roundHours = (value, errorMessage = "Invalid hours value.") => roundValueWithDinero(value, HOURS_PRECISION, errorMessage); + +const normalizePayoutMethod = (value) => + value === PAYOUT_METHODS.commission ? PAYOUT_METHODS.commission : PAYOUT_METHODS.hourly; + +const hasOwnValue = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key); + +const getJobSaleRateField = (laborType) => `rate_${String(laborType || "").toLowerCase()}`; + +const getTeamMemberLabel = (teamMember) => { + const fullName = `${teamMember?.employee?.first_name || ""} ${teamMember?.employee?.last_name || ""}`.trim(); + return fullName || teamMember?.employee?.id || teamMember?.employeeid || "unknown employee"; +}; + +const parseRequiredNumber = (value, errorMessage) => { + const parsed = Number(value); + + if (!Number.isFinite(parsed)) { + throw new Error(errorMessage); + } + + return parsed; +}; + +const buildFallbackPayoutContext = ({ laborType, rate }) => ({ + payout_type: "legacy", + payout_method: "legacy", + cut_percent_applied: null, + source_labor_rate: null, + source_labor_type: laborType, + effective_rate: roundCurrency(rate) +}); + +function BuildPayoutDetails(job, teamMember, laborType) { + const payoutMethod = normalizePayoutMethod(teamMember?.payout_method); + const teamMemberLabel = getTeamMemberLabel(teamMember); + const sourceLaborRateField = getJobSaleRateField(laborType); + + if (payoutMethod === PAYOUT_METHODS.hourly && !hasOwnValue(teamMember?.labor_rates, laborType)) { + throw new Error(`Missing hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`); + } + + if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(teamMember?.commission_rates, laborType)) { + throw new Error(`Missing commission percent for ${teamMemberLabel} on labor type ${laborType}.`); + } + + if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(job, sourceLaborRateField)) { + throw new Error(`Missing sale rate ${sourceLaborRateField} for labor type ${laborType}.`); + } + + const hourlyRate = + payoutMethod === PAYOUT_METHODS.hourly + ? roundCurrency( + parseRequiredNumber( + teamMember?.labor_rates?.[laborType], + `Invalid hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.` + ) + ) + : null; + + const commissionPercent = + payoutMethod === PAYOUT_METHODS.commission + ? roundCurrency( + parseRequiredNumber( + teamMember?.commission_rates?.[laborType], + `Invalid commission percent for ${teamMemberLabel} on labor type ${laborType}.` + ) + ) + : null; + + if (commissionPercent !== null && (commissionPercent < 0 || commissionPercent > 100)) { + throw new Error(`Commission percent for ${teamMemberLabel} on labor type ${laborType} must be between 0 and 100.`); + } + + const sourceLaborRate = + payoutMethod === PAYOUT_METHODS.commission + ? roundCurrency( + parseRequiredNumber(job?.[sourceLaborRateField], `Invalid sale rate ${sourceLaborRateField} for labor type ${laborType}.`) + ) + : null; + + const effectiveRate = + payoutMethod === PAYOUT_METHODS.commission + ? roundCurrency((sourceLaborRate * toNumber(commissionPercent)) / 100) + : hourlyRate; + + return { + effectiveRate, + payoutContext: { + payout_type: payoutMethod === PAYOUT_METHODS.commission ? "cut" : "hourly", + payout_method: payoutMethod, + cut_percent_applied: commissionPercent, + source_labor_rate: sourceLaborRate, + source_labor_type: laborType, + effective_rate: effectiveRate + } + }; +} + +function BuildGeneratedPayoutContext({ baseContext, generatedBy, generatedFrom, taskName, usedTicketFallback }) { + return { + ...(baseContext || {}), + generated_by: generatedBy, + generated_at: new Date().toISOString(), + generated_from: generatedFrom, + task_name: taskName, + used_ticket_fallback: Boolean(usedTicketFallback) + }; +} + +function getAllKeys(...objects) { + return [...new Set(objects.flatMap((obj) => (obj ? Object.keys(obj) : [])))]; +} + +function buildPayAllMemo({ deltaHours, hasExpected, hasClaimed, userEmail }) { + if (!hasClaimed && deltaHours > 0) { + return `Add unflagged hours. (${userEmail})`; + } + + if (!hasExpected && deltaHours < 0) { + return `Remove flagged hours per assignment. (${userEmail})`; + } + + return `Adjust flagged hours per assignment. (${userEmail})`; +} exports.payall = async function (req, res) { - const { jobid, calculateOnly } = req.body; + const { jobid } = req.body; logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null); const BearerToken = req.BearerToken; @@ -22,253 +203,183 @@ exports.payall = async function (req, res) { id: jobid }); - //iterate over each ticket, building a hash of team -> employee to calculate total assigned hours. - const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job); const ticketHash = CalculateTicketsHoursForJob(job); + if (assignmentHash.unassigned > 0) { res.json({ success: false, error: "Not all hours have been assigned." }); return; } - //Calculate how much time each tech should have by labor type. - //Doing this order creates a diff of changes on the ticket hash to make it the same as the employee hash. - const recursiveDiff = rdiff.getDiff(ticketHash, employeeHash, true); - const ticketsToInsert = []; + const employeeIds = getAllKeys(employeeHash, ticketHash); - recursiveDiff.forEach((diff) => { - //Every iteration is what we would need to insert into the time ticket hash - //so that it would match the employee hash exactly. - const path = diffParser(diff); + employeeIds.forEach((employeeId) => { + const expectedByLabor = employeeHash[employeeId] || {}; + const claimedByLabor = ticketHash[employeeId] || {}; - if (diff.op === "add") { - // console.log(Object.keys(diff.val)); - if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) { - //Multiple values to add. - Object.keys(diff.val).forEach((key) => { - // console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]); - // console.log("Rate", Object.keys(diff.val[key])[0]); - ticketsToInsert.push({ - task_name: "Pay All", - jobid: job.id, - bodyshopid: job.bodyshop.id, - employeeid: path.employeeid, - productivehrs: diff.val[key][Object.keys(diff.val[key])[0]], - rate: Object.keys(diff.val[key])[0], - ciecacode: key, - cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key], - flat_rate: true, - memo: `Add unflagged hours. (${req.user.email})` - }); - }); - } else { - //Only the 1 value to add. - ticketsToInsert.push({ - task_name: "Pay All", - jobid: job.id, - bodyshopid: job.bodyshop.id, - employeeid: path.employeeid, - productivehrs: path.hours, - rate: path.rate, - ciecacode: path.mod_lbr_ty, - flat_rate: true, - cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty], - memo: `Add unflagged hours. (${req.user.email})` - }); + getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => { + const expected = expectedByLabor[laborType]; + const claimed = claimedByLabor[laborType]; + const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0)); + + if (deltaHours === 0) { + return; } - } else if (diff.op === "update") { - //An old ticket amount isn't sufficient - //We can't modify the existing ticket, it might already be committed. So let's add a new one instead. + + const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate); + const payoutContext = BuildGeneratedPayoutContext({ + baseContext: + expected?.payoutContext || + claimed?.payoutContext || + buildFallbackPayoutContext({ laborType, rate: effectiveRate }), + generatedBy: req.user.email, + generatedFrom: "payall", + taskName: "Pay All", + usedTicketFallback: !expected && Boolean(claimed) + }); + ticketsToInsert.push({ task_name: "Pay All", jobid: job.id, bodyshopid: job.bodyshop.id, - employeeid: path.employeeid, - productivehrs: diff.val - diff.oldVal, - rate: path.rate, - ciecacode: path.mod_lbr_ty, + employeeid: employeeId, + productivehrs: deltaHours, + rate: effectiveRate, + ciecacode: laborType, + cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType], flat_rate: true, - cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty], - memo: `Adjust flagged hours per assignment. (${req.user.email})` + created_by: req.user.email, + payout_context: payoutContext, + memo: buildPayAllMemo({ + deltaHours, + hasExpected: Boolean(expected), + hasClaimed: Boolean(claimed), + userEmail: req.user.email + }) }); - } else { - //Has to be a delete - if (typeof diff.oldVal === "object" && Object.keys(diff.oldVal).length > 1) { - //Multiple oldValues to add. - Object.keys(diff.oldVal).forEach((key) => { - ticketsToInsert.push({ - task_name: "Pay All", - jobid: job.id, - bodyshopid: job.bodyshop.id, - employeeid: path.employeeid, - productivehrs: diff.oldVal[key][Object.keys(diff.oldVal[key])[0]] * -1, - rate: Object.keys(diff.oldVal[key])[0], - ciecacode: key, - cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key], - flat_rate: true, - memo: `Remove flagged hours per assignment. (${req.user.email})` - }); - }); - } else { - //Only the 1 value to add. - ticketsToInsert.push({ - task_name: "Pay All", - jobid: job.id, - bodyshopid: job.bodyshop.id, - employeeid: path.employeeid, - productivehrs: path.hours * -1, - rate: path.rate, - ciecacode: path.mod_lbr_ty, - cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty], - flat_rate: true, - memo: `Remove flagged hours per assignment. (${req.user.email})` - }); - } - } + }); }); - const insertResult = await client.request(queries.INSERT_TIME_TICKETS, { - timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0) - }); + const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0); - res.json(ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)); + if (filteredTickets.length > 0) { + await client.request(queries.INSERT_TIME_TICKETS, { + timetickets: filteredTickets + }); + } + + res.json(filteredTickets); } catch (error) { logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, { - jobid: jobid, + jobid, error: JSON.stringify(error) }); res.status(400).json({ error: error.message }); } }; -function diffParser(diff) { - const type = typeof diff.oldVal; - let mod_lbr_ty, rate, hours; - - if (diff.path.length === 1) { - if (diff.op === "add") { - mod_lbr_ty = Object.keys(diff.val)[0]; - rate = Object.keys(diff.val[mod_lbr_ty])[0]; - // hours = diff.oldVal[mod_lbr_ty][rate]; - } else { - mod_lbr_ty = Object.keys(diff.oldVal)[0]; - rate = Object.keys(diff.oldVal[mod_lbr_ty])[0]; - // hours = diff.oldVal[mod_lbr_ty][rate]; - } - } else if (diff.path.length === 2) { - mod_lbr_ty = diff.path[1]; - if (diff.op === "add") { - rate = Object.keys(diff.val)[0]; - } else { - rate = Object.keys(diff.oldVal)[0]; - } - } else if (diff.path.length === 3) { - mod_lbr_ty = diff.path[1]; - rate = diff.path[2]; - //hours = 0; - } - - //Set the hours - if (typeof diff.val === "number" && diff.val !== null && diff.val !== undefined) { - hours = diff.val; - } else if (diff.val !== null && diff.val !== undefined) { - if (diff.path.length === 1) { - hours = diff.val[Object.keys(diff.val)[0]][Object.keys(diff.val[Object.keys(diff.val)[0]])]; - } else { - hours = diff.val[Object.keys(diff.val)[0]]; - } - } else if (typeof diff.oldVal === "number" && diff.oldVal !== null && diff.oldVal !== undefined) { - hours = diff.oldVal; - } else { - hours = diff.oldVal[Object.keys(diff.oldVal)[0]]; - } - - const ret = { - multiVal: false, - employeeid: diff.path[0], // Always True - mod_lbr_ty, - rate, - hours - }; - return ret; -} - function CalculateExpectedHoursForJob(job, filterToLbrTypes) { const assignmentHash = { unassigned: 0 }; - const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid. + const employeeHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext } + const laborTypeFilter = Array.isArray(filterToLbrTypes) ? filterToLbrTypes : null; + job.joblines .filter((jobline) => { - if (!filterToLbrTypes) return true; - else { - return ( - filterToLbrTypes.includes(jobline.mod_lbr_ty) || - (jobline.convertedtolbr && filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty)) - ); + if (!laborTypeFilter) { + return true; } + + const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null; + return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType)); }) .forEach((jobline) => { - if (jobline.convertedtolbr) { - // Line has been converte to labor. Temporarily re-assign the hours. - jobline.mod_lbr_ty = jobline.convertedtolbr_data.mod_lbr_ty; - jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs; + const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty; + const laborHours = roundHours( + toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0) + ); + + if (laborHours === 0) { + return; } - if (jobline.mod_lb_hrs != 0) { - //Check if the line is assigned. If not, keep track of it as an unassigned line by type. - if (jobline.assigned_team === null) { - assignmentHash.unassigned = assignmentHash.unassigned + jobline.mod_lb_hrs; - } else { - //Line is assigned. - if (!assignmentHash[jobline.assigned_team]) { - assignmentHash[jobline.assigned_team] = 0; - } - assignmentHash[jobline.assigned_team] = assignmentHash[jobline.assigned_team] + jobline.mod_lb_hrs; - //Create the assignment breakdown. - const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team); + if (jobline.assigned_team === null) { + assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours); + return; + } - theTeam.employee_team_members.forEach((tm) => { - //Figure out how many hours they are owed at this line, and at what rate. + const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team); - if (!employeeHash[tm.employee.id]) { - employeeHash[tm.employee.id] = {}; - } - if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty]) { - employeeHash[tm.employee.id][jobline.mod_lbr_ty] = {}; - } - if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]]) { - employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] = 0; - } + if (!theTeam) { + assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours); + return; + } - const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100; - employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] = - employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] + hoursOwed; - }); + assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours); + + theTeam.employee_team_members.forEach((teamMember) => { + const employeeId = teamMember.employee.id; + const { effectiveRate, payoutContext } = BuildPayoutDetails(job, teamMember, laborType); + + if (!employeeHash[employeeId]) { + employeeHash[employeeId] = {}; } - } + + if (!employeeHash[employeeId][laborType]) { + employeeHash[employeeId][laborType] = { + hours: 0, + rate: effectiveRate, + payoutContext + }; + } + + const hoursOwed = roundHours((toNumber(teamMember.percentage) * laborHours) / 100); + employeeHash[employeeId][laborType].hours = roundHours(employeeHash[employeeId][laborType].hours + hoursOwed); + employeeHash[employeeId][laborType].rate = effectiveRate; + employeeHash[employeeId][laborType].payoutContext = payoutContext; + }); }); return { assignmentHash, employeeHash }; } function CalculateTicketsHoursForJob(job) { - const ticketHash = {}; // employeeid => Cieca labor type => rate => hours. - //Calculate how much each employee has been paid so far. + const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext } + job.timetickets.forEach((ticket) => { + if (!ticket?.employeeid || !ticket?.ciecacode) { + return; + } + if (!ticketHash[ticket.employeeid]) { ticketHash[ticket.employeeid] = {}; } + if (!ticketHash[ticket.employeeid][ticket.ciecacode]) { - ticketHash[ticket.employeeid][ticket.ciecacode] = {}; + ticketHash[ticket.employeeid][ticket.ciecacode] = { + hours: 0, + rate: roundCurrency(ticket.rate), + payoutContext: ticket.payout_context || null + }; } - if (!ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate]) { - ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = 0; + + ticketHash[ticket.employeeid][ticket.ciecacode].hours = roundHours( + ticketHash[ticket.employeeid][ticket.ciecacode].hours + toNumber(ticket.productivehrs) + ); + + if (ticket.rate !== null && ticket.rate !== undefined) { + ticketHash[ticket.employeeid][ticket.ciecacode].rate = roundCurrency(ticket.rate); + } + + if (ticket.payout_context) { + ticketHash[ticket.employeeid][ticket.ciecacode].payoutContext = ticket.payout_context; } - ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = - ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] + ticket.productivehrs; }); + return ticketHash; } +exports.BuildPayoutDetails = BuildPayoutDetails; exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob; exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob; +exports.RoundPayrollHours = roundHours; diff --git a/server/payroll/payroll.test.js b/server/payroll/payroll.test.js new file mode 100644 index 000000000..f8b8dcb31 --- /dev/null +++ b/server/payroll/payroll.test.js @@ -0,0 +1,1103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import mockRequire from "mock-require"; + +const logMock = vi.fn(); + +let payAllModule; +let claimTaskModule; +let calculateTotalsModule; + +const buildBaseJob = (overrides = {}) => ({ + id: "job-1", + completed_tasks: [], + rate_laa: 100, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [] + }, + joblines: [], + timetickets: [], + ...overrides +}); + +const buildReqRes = ({ job, body = {}, userEmail = "payroll@example.com" }) => { + const client = { + setHeaders: vi.fn().mockReturnThis(), + request: vi.fn().mockResolvedValueOnce({ jobs_by_pk: job }) + }; + + const req = { + body: { + jobid: job.id, + ...body + }, + user: { + email: userEmail + }, + BearerToken: "Bearer test", + userGraphQLClient: client + }; + + const res = { + json: vi.fn(), + status: vi.fn().mockReturnThis() + }; + + return { client, req, res }; +}; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mockRequire.stopAll(); + mockRequire("../utils/logger", { log: logMock }); + payAllModule = require("./pay-all"); + claimTaskModule = require("./claim-task"); + calculateTotalsModule = require("./calculate-totals"); +}); + +describe("payroll payout helpers", () => { + it("defaults team members to hourly payout when no payout method is stored", () => { + const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails( + {}, + { + labor_rates: { + LAA: 27.5 + }, + employee: { + id: "emp-1" + } + }, + "LAA" + ); + + expect(effectiveRate).toBe(27.5); + expect(payoutContext).toEqual( + expect.objectContaining({ + payout_type: "hourly", + payout_method: "hourly", + cut_percent_applied: null, + source_labor_rate: null, + source_labor_type: "LAA", + effective_rate: 27.5 + }) + ); + }); + + it("calculates commission payout rates from the raw job labor sale rate", () => { + const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails( + { + rate_laa: 120 + }, + { + payout_method: "commission", + commission_rates: { + LAA: 35 + }, + employee: { + id: "emp-1" + } + }, + "LAA" + ); + + expect(effectiveRate).toBe(42); + expect(payoutContext).toEqual( + expect.objectContaining({ + payout_type: "cut", + payout_method: "commission", + cut_percent_applied: 35, + source_labor_rate: 120, + source_labor_type: "LAA", + effective_rate: 42 + }) + ); + }); + + it("uses Dinero half-even rounding for stored hourly rates", () => { + const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails( + {}, + { + labor_rates: { + LAA: 10.005 + }, + employee: { + id: "emp-1" + } + }, + "LAA" + ); + + expect(effectiveRate).toBe(10); + expect(payoutContext.effective_rate).toBe(10); + }); + + it("throws a useful error when commission configuration is incomplete", () => { + expect(() => + payAllModule.BuildPayoutDetails( + { + rate_laa: 100 + }, + { + payout_method: "commission", + commission_rates: {}, + employee: { + first_name: "Jane", + last_name: "Doe" + } + }, + "LAA" + ) + ).toThrow("Missing commission percent for Jane Doe on labor type LAA."); + }); + + it("throws a useful error when an hourly payout rate is missing", () => { + expect(() => + payAllModule.BuildPayoutDetails( + {}, + { + labor_rates: {}, + employee: { + first_name: "John", + last_name: "Smith" + } + }, + "LAB" + ) + ).toThrow("Missing hourly payout rate for John Smith on labor type LAB."); + }); + + it("supports commission boundary values of zero and one hundred percent", () => { + const zeroPercent = payAllModule.BuildPayoutDetails( + { + rate_laa: 123.45 + }, + { + payout_method: "commission", + commission_rates: { + LAA: 0 + }, + employee: { + id: "emp-1" + } + }, + "LAA" + ); + + const fullPercent = payAllModule.BuildPayoutDetails( + { + rate_laa: 123.45 + }, + { + payout_method: "commission", + commission_rates: { + LAA: 100 + }, + employee: { + id: "emp-1" + } + }, + "LAA" + ); + + expect(zeroPercent.effectiveRate).toBe(0); + expect(zeroPercent.payoutContext.cut_percent_applied).toBe(0); + expect(fullPercent.effectiveRate).toBe(123.45); + expect(fullPercent.payoutContext.cut_percent_applied).toBe(100); + }); + + it("throws a useful error when the sale rate for a commission labor type is missing", () => { + expect(() => + payAllModule.BuildPayoutDetails( + {}, + { + payout_method: "commission", + commission_rates: { + LAA: 35 + }, + employee: { + first_name: "Sam", + last_name: "Painter" + } + }, + "LAA" + ) + ).toThrow("Missing sale rate rate_laa for labor type LAA."); + }); + + it("rejects commission percentages outside the allowed zero-to-one-hundred range", () => { + expect(() => + payAllModule.BuildPayoutDetails( + { + rate_laa: 100 + }, + { + payout_method: "commission", + commission_rates: { + LAA: -5 + }, + employee: { + first_name: "Alex", + last_name: "Painter" + } + }, + "LAA" + ) + ).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100."); + + expect(() => + payAllModule.BuildPayoutDetails( + { + rate_laa: 100 + }, + { + payout_method: "commission", + commission_rates: { + LAA: 105 + }, + employee: { + first_name: "Alex", + last_name: "Painter" + } + }, + "LAA" + ) + ).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100."); + }); +}); + +describe("payroll routes", () => { + it("aggregates claimed hours across prior ticket rates and inserts the remaining delta at the current rate", async () => { + const job = buildBaseJob({ + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 100, + payout_method: "commission", + commission_rates: { + LAA: 40 + }, + labor_rates: { + LAA: 30 + }, + employee: { + id: "emp-1", + first_name: "Jane", + last_name: "Doe" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 10, + assigned_team: "team-1", + convertedtolbr: false + } + ], + timetickets: [ + { + employeeid: "emp-1", + ciecacode: "LAA", + productivehrs: 2, + rate: 30, + payout_context: { + payout_method: "hourly" + } + }, + { + employeeid: "emp-1", + ciecacode: "LAA", + productivehrs: 3, + rate: 35, + payout_context: { + payout_method: "commission" + } + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } }); + + await payAllModule.payall(req, res); + + expect(client.request).toHaveBeenCalledTimes(2); + + const insertedTickets = client.request.mock.calls[1][1].timetickets; + expect(insertedTickets).toHaveLength(1); + expect(insertedTickets[0]).toEqual( + expect.objectContaining({ + task_name: "Pay All", + employeeid: "emp-1", + productivehrs: 5, + rate: 40, + ciecacode: "LAA", + cost_center: "Body", + created_by: "payroll@example.com" + }) + ); + expect(insertedTickets[0].payout_context).toEqual( + expect.objectContaining({ + payout_method: "commission", + cut_percent_applied: 40, + source_labor_rate: 100, + generated_from: "payall", + task_name: "Pay All", + used_ticket_fallback: false + }) + ); + expect(res.json).toHaveBeenCalledWith(insertedTickets); + }); + + it("returns a validation failure when job lines still have unassigned hours", async () => { + const job = buildBaseJob({ + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 3.5, + assigned_team: null, + convertedtolbr: false + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + + await payAllModule.payall(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: "Not all hours have been assigned." + }); + }); + + it("creates separate pay-all tickets for mixed hourly and commission team members across labor types", async () => { + const job = buildBaseJob({ + rate_laa: 100, + rate_lab: 80, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body", + LAB: "Refinish" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 50, + payout_method: "hourly", + labor_rates: { + LAA: 30, + LAB: 25 + }, + employee: { + id: "emp-hourly", + first_name: "Hourly", + last_name: "Tech" + } + }, + { + percentage: 50, + payout_method: "commission", + commission_rates: { + LAA: 40, + LAB: 50 + }, + labor_rates: { + LAA: 0, + LAB: 0 + }, + employee: { + id: "emp-commission", + first_name: "Commission", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + }, + { + mod_lbr_ty: "LAB", + mod_lb_hrs: 2, + assigned_team: "team-1", + convertedtolbr: false + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 4 } }); + + await payAllModule.payall(req, res); + + expect(client.request).toHaveBeenCalledTimes(2); + + const insertedTickets = client.request.mock.calls[1][1].timetickets; + expect(insertedTickets).toHaveLength(4); + expect(insertedTickets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + employeeid: "emp-hourly", + ciecacode: "LAA", + productivehrs: 2, + rate: 30, + cost_center: "Body", + payout_context: expect.objectContaining({ + payout_method: "hourly", + payout_type: "hourly", + effective_rate: 30 + }) + }), + expect.objectContaining({ + employeeid: "emp-hourly", + ciecacode: "LAB", + productivehrs: 1, + rate: 25, + cost_center: "Refinish", + payout_context: expect.objectContaining({ + payout_method: "hourly", + payout_type: "hourly", + effective_rate: 25 + }) + }), + expect.objectContaining({ + employeeid: "emp-commission", + ciecacode: "LAA", + productivehrs: 2, + rate: 40, + cost_center: "Body", + payout_context: expect.objectContaining({ + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 40, + source_labor_rate: 100, + effective_rate: 40 + }) + }), + expect.objectContaining({ + employeeid: "emp-commission", + ciecacode: "LAB", + productivehrs: 1, + rate: 40, + cost_center: "Refinish", + payout_context: expect.objectContaining({ + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 50, + source_labor_rate: 80, + effective_rate: 40 + }) + }) + ]) + ); + expect(res.json).toHaveBeenCalledWith(insertedTickets); + }); + + it("creates a negative pay-all adjustment at the current commission rate when the remaining expected hours drop below prior claimed hours", async () => { + const job = buildBaseJob({ + rate_laa: 120, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 100, + payout_method: "commission", + commission_rates: { + LAA: 40 + }, + employee: { + id: "emp-1", + first_name: "Current", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + } + ], + timetickets: [ + { + employeeid: "emp-1", + ciecacode: "LAA", + productivehrs: 6, + rate: 30, + payout_context: { + payout_method: "hourly", + payout_type: "hourly", + effective_rate: 30 + } + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } }); + + await payAllModule.payall(req, res); + + const insertedTickets = client.request.mock.calls[1][1].timetickets; + expect(insertedTickets).toHaveLength(1); + expect(insertedTickets[0]).toEqual( + expect.objectContaining({ + employeeid: "emp-1", + productivehrs: -2, + rate: 48, + ciecacode: "LAA", + memo: "Adjust flagged hours per assignment. (payroll@example.com)" + }) + ); + expect(insertedTickets[0].payout_context).toEqual( + expect.objectContaining({ + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 40, + source_labor_rate: 120, + effective_rate: 48, + generated_from: "payall", + used_ticket_fallback: false + }) + ); + expect(res.json).toHaveBeenCalledWith(insertedTickets); + }); + + it("uses the current commission sale rate for remaining hours when older commission tickets were created from a lower sale rate", async () => { + const job = buildBaseJob({ + rate_laa: 120, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 100, + payout_method: "commission", + commission_rates: { + LAA: 40 + }, + employee: { + id: "emp-1", + first_name: "Current", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 5, + assigned_team: "team-1", + convertedtolbr: false + } + ], + timetickets: [ + { + employeeid: "emp-1", + ciecacode: "LAA", + productivehrs: 2, + rate: 40, + payout_context: { + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 40, + source_labor_rate: 100, + effective_rate: 40 + } + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } }); + + await payAllModule.payall(req, res); + + const insertedTickets = client.request.mock.calls[1][1].timetickets; + expect(insertedTickets).toHaveLength(1); + expect(insertedTickets[0]).toEqual( + expect.objectContaining({ + employeeid: "emp-1", + productivehrs: 3, + rate: 48, + ciecacode: "LAA" + }) + ); + expect(insertedTickets[0].payout_context).toEqual( + expect.objectContaining({ + payout_method: "commission", + cut_percent_applied: 40, + source_labor_rate: 120, + effective_rate: 48, + generated_from: "payall", + used_ticket_fallback: false + }) + ); + expect(res.json).toHaveBeenCalledWith(insertedTickets); + }); + + it("rejects duplicate claim-task submissions for completed presets", async () => { + const job = buildBaseJob({ + completed_tasks: [{ name: "Disassembly" }], + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [ + { + name: "Disassembly", + hourstype: ["LAA"], + percent: 50, + nextstatus: "In Progress", + memo: "Flag disassembly" + } + ] + }, + employee_teams: [] + } + }); + + const { client, req, res } = buildReqRes({ + job, + body: { + task: "Disassembly", + calculateOnly: false, + employee: { + name: "Jane Doe", + employeeid: "emp-1" + } + } + }); + + await claimTaskModule.claimtask(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: "Provided task preset has already been completed for this job." + }); + }); + + it("rejects claim-task when task presets over-allocate the same labor type", async () => { + const job = buildBaseJob({ + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [ + { + name: "Body Prep", + hourstype: ["LAA"], + percent: 60, + nextstatus: "Prep", + memo: "Prep body work" + }, + { + name: "Body Prime", + hourstype: ["LAA"], + percent: 50, + nextstatus: "Prime", + memo: "Prime body work" + } + ] + }, + employee_teams: [] + } + }); + + const { client, req, res } = buildReqRes({ + job, + body: { + task: "Body Prep", + calculateOnly: true, + employee: { + name: "Jane Doe", + employeeid: "emp-1" + } + } + }); + + await claimTaskModule.claimtask(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: "Task preset percentages for labor type LAA total 110% and cannot exceed 100%." + }); + }); + + it("returns commission-aware claim-task previews and reports unassigned hours", async () => { + const job = buildBaseJob({ + rate_laa: 120, + rate_lab: 80, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body", + LAB: "Refinish" + } + } + }, + md_tasks_presets: { + presets: [ + { + name: "Body Prep", + hourstype: ["LAA", "LAB"], + percent: 50, + nextstatus: "Prep", + memo: "Prep body work" + } + ] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 50, + payout_method: "hourly", + labor_rates: { + LAA: 30, + LAB: 25 + }, + employee: { + id: "emp-hourly", + first_name: "Hourly", + last_name: "Tech" + } + }, + { + percentage: 50, + payout_method: "commission", + commission_rates: { + LAA: 40, + LAB: 50 + }, + employee: { + id: "emp-commission", + first_name: "Commission", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + }, + { + mod_lbr_ty: "LAB", + mod_lb_hrs: 1.5, + assigned_team: null, + convertedtolbr: false + } + ] + }); + + const { client, req, res } = buildReqRes({ + job, + body: { + task: "Body Prep", + calculateOnly: true, + employee: { + name: "Payroll Manager" + } + } + }); + + await claimTaskModule.claimtask(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith({ + unassignedHours: 1.5, + ticketsToInsert: expect.arrayContaining([ + expect.objectContaining({ + task_name: "Body Prep", + employeeid: "emp-hourly", + productivehrs: 1, + rate: 30, + ciecacode: "LAA", + created_by: "Payroll Manager", + payout_context: expect.objectContaining({ + payout_method: "hourly", + payout_type: "hourly", + generated_from: "claimtask", + task_name: "Body Prep" + }) + }), + expect.objectContaining({ + task_name: "Body Prep", + employeeid: "emp-commission", + productivehrs: 1, + rate: 48, + ciecacode: "LAA", + created_by: "Payroll Manager", + payout_context: expect.objectContaining({ + payout_method: "commission", + payout_type: "cut", + cut_percent_applied: 40, + source_labor_rate: 120, + generated_from: "claimtask", + task_name: "Body Prep" + }) + }) + ]) + }); + }); + + it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => { + const job = buildBaseJob({ + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAB: "Body" + } + } + }, + md_tasks_presets: { + presets: [ + { + name: "Teardown", + hourstype: ["LAB"], + percent: 100, + nextstatus: "In Progress", + memo: "Teardown" + } + ] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 50, + labor_rates: { + LAB: 45 + }, + employee: { + id: "emp-1", + first_name: "Configured", + last_name: "Tech" + } + }, + { + percentage: 50, + labor_rates: {}, + employee: { + id: "emp-2", + first_name: "Missing", + last_name: "Rate" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAB", + mod_lb_hrs: 4.4, + assigned_team: "team-1", + convertedtolbr: false + } + ] + }); + + const { client, req, res } = buildReqRes({ + job, + body: { + task: "Teardown", + calculateOnly: true, + employee: { + name: "Dave", + email: "dave@rome.test" + } + } + }); + + await claimTaskModule.claimtask(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: "Missing hourly payout rate for Missing Rate on labor type LAB." + }); + }); + + it("locks in the current enhanced-payroll behavior of ignoring lbr_adjustments when calculating labor totals", async () => { + const job = buildBaseJob({ + lbr_adjustments: { + LAA: 2.5 + }, + bodyshop: { + id: "shop-1", + md_responsibility_centers: { + defaults: { + costs: { + LAA: "Body" + } + } + }, + md_tasks_presets: { + presets: [] + }, + employee_teams: [ + { + id: "team-1", + employee_team_members: [ + { + percentage: 100, + payout_method: "commission", + commission_rates: { + LAA: 40 + }, + employee: { + id: "emp-1", + first_name: "Current", + last_name: "Tech" + } + } + ] + } + ] + }, + joblines: [ + { + mod_lbr_ty: "LAA", + mod_lb_hrs: 4, + assigned_team: "team-1", + convertedtolbr: false + } + ], + timetickets: [ + { + employeeid: "emp-1", + ciecacode: "LAA", + productivehrs: 1, + rate: 48, + payout_context: { + payout_method: "commission" + } + } + ] + }); + + const { client, req, res } = buildReqRes({ job }); + + await calculateTotalsModule.calculatelabor(req, res); + + expect(client.request).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith([ + { + employeeid: "emp-1", + rate: 40, + mod_lbr_ty: "LAA", + expectedHours: 4, + claimedHours: 1 + } + ]); + }); +}); diff --git a/server/rr/rr-calculate-allocations.js b/server/rr/rr-calculate-allocations.js index 7c21e3c9c..cef9a5ea9 100644 --- a/server/rr/rr-calculate-allocations.js +++ b/server/rr/rr-calculate-allocations.js @@ -46,6 +46,11 @@ const summarizeAllocationsArray = (arr) => cost: summarizeMoney(a.cost) })); +const toFiniteNumber = (value) => { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + /** * Internal per-center bucket shape for *sales*. * We keep separate buckets for RR so we can split @@ -62,6 +67,8 @@ function emptyCenterBucket() { // Labor laborTaxableSale: zero, // labor that should be taxed in RR laborNonTaxableSale: zero, // labor that should NOT be taxed in RR + laborTaxableHours: 0, + laborNonTaxableHours: 0, // Extras (MAPA/MASH/towing/PAO/etc) extrasSale: zero, // total extras (taxable + non-taxable) @@ -453,6 +460,7 @@ function buildProfitCenterHash(job, debugLog, taxContext) { const rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`; const rate = job[rateKey]; + const lineHours = toFiniteNumber(val.mod_lb_hrs); const laborAmount = Dinero({ amount: Math.round(rate * 100) @@ -460,8 +468,10 @@ function buildProfitCenterHash(job, debugLog, taxContext) { if (isLaborTaxable(val, taxContext)) { bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount); + bucket.laborTaxableHours += lineHours; } else { bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount); + bucket.laborNonTaxableHours += lineHours; } } @@ -478,6 +488,8 @@ function buildProfitCenterHash(job, debugLog, taxContext) { partsNonTaxable: summarizeMoney(b.partsNonTaxableSale), laborTaxable: summarizeMoney(b.laborTaxableSale), laborNonTaxable: summarizeMoney(b.laborNonTaxableSale), + laborTaxableHours: b.laborTaxableHours, + laborNonTaxableHours: b.laborNonTaxableHours, extras: summarizeMoney(b.extrasSale), extrasTaxable: summarizeMoney(b.extrasTaxableSale), extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale) @@ -916,6 +928,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo // Labor laborTaxableSale: bucket.laborTaxableSale, laborNonTaxableSale: bucket.laborNonTaxableSale, + laborTaxableHours: bucket.laborTaxableHours, + laborNonTaxableHours: bucket.laborNonTaxableHours, // Extras extrasSale, diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 5eefbf19a..15e2f2a34 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -1,4 +1,4 @@ -const { buildRRRepairOrderPayload } = require("./rr-job-helpers"); +const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-job-helpers"); const { buildClientAndOpts } = require("./rr-lookup"); const CreateRRLogEvent = require("./rr-logger-event"); const { withRRRequestXml } = require("./rr-log-xml"); @@ -56,6 +56,27 @@ const deriveRRStatus = (rrRes = {}) => { }; }; +const resolveRROpCode = (bodyshop, txEnvelope = {}) => { + const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop); + let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null; + + if (!opCodeOverride) { + const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null; + const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null; + const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null; + + if (opPrefix || opBase || opSuffix) { + const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim(); + if (combined) { + opCodeOverride = combined; + } + } + } + + if (!opCodeOverride && !resolvedBaseOpCode) return null; + return String(opCodeOverride || resolvedBaseOpCode).trim() || null; +}; + /** * Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story). * Used when creating RO from convert button or admin page before full job export. @@ -93,7 +114,9 @@ const createMinimalRRRepairOrder = async (args) => { const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; - // Build minimal RO payload - just header, no allocations/parts/labor + // Build minimal RO payload for early review mode. + // We keep it lightweight, but include a single labor row when we can so Ignite + // exposes the labor subsection for editing. const cleanVin = (job?.v_vin || "") .toString() @@ -116,6 +139,12 @@ const createMinimalRRRepairOrder = async (args) => { resolvedMileageIn: mileageIn }); + const earlyRoOpCode = resolveRROpCode(bodyshop, txEnvelope); + const earlyRoLabor = buildMinimalRolaborFromJob(job, { + opCode: earlyRoOpCode, + payType: "Cust" + }); + const payload = { customerNo: String(selected), advisorNo: String(advisorNo), @@ -141,9 +170,14 @@ const createMinimalRRRepairOrder = async (args) => { if (makeOverride) { payload.makeOverride = makeOverride; } + if (earlyRoLabor) { + payload.rolabor = earlyRoLabor; + } CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", { - payload + payload, + earlyRoOpCode, + hasRolabor: !!earlyRoLabor }); const response = await client.createRepairOrder(payload, finalOpts); @@ -221,15 +255,10 @@ const updateRRRepairOrderWithFullData = async (args) => { const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; - // Optional RR OpCode segments coming from the FE (RRPostForm) - const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null; - const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null; - const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null; - // RR-only extras let rrCentersConfig = null; let allocations = null; - let opCode = null; + const opCode = resolveRROpCode(bodyshop, txEnvelope); // 1) Responsibility center config (for visibility / debugging) try { @@ -280,28 +309,9 @@ const updateRRRepairOrderWithFullData = async (args) => { allocations = []; } - const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop); - - let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null; - - // If the FE only sends segments, combine them here. - if (!opCodeOverride && (opPrefix || opBase || opSuffix)) { - const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim(); - if (combined) { - opCodeOverride = combined; - } - } - - if (opCodeOverride || resolvedBaseOpCode) { - opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null; - } - CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", { opCode, - baseFromConfig: resolvedBaseOpCode, - opPrefix, - opBase, - opSuffix + baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop) }); // Build full RO payload for update with allocations @@ -426,15 +436,10 @@ const exportJobToRR = async (args) => { const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null; const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null; - // Optional RR OpCode segments coming from the FE (RRPostForm) - const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null; - const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null; - const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null; - // RR-only extras let rrCentersConfig = null; let allocations = null; - let opCode = null; + const opCode = resolveRROpCode(bodyshop, txEnvelope); // 1) Responsibility center config (for visibility / debugging) try { @@ -477,28 +482,9 @@ const exportJobToRR = async (args) => { allocations = []; } - const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop); - - let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null; - - // If the FE only sends segments, combine them here. - if (!opCodeOverride && (opPrefix || opBase || opSuffix)) { - const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim(); - if (combined) { - opCodeOverride = combined; - } - } - - if (opCodeOverride || resolvedBaseOpCode) { - opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null; - } - CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", { opCode, - baseFromConfig: resolvedBaseOpCode, - opPrefix, - opBase, - opSuffix + baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop) }); // Build RO payload for create. diff --git a/server/rr/rr-job-helpers.js b/server/rr/rr-job-helpers.js index 13ca83fac..23d003ccf 100644 --- a/server/rr/rr-job-helpers.js +++ b/server/rr/rr-job-helpers.js @@ -52,6 +52,19 @@ const asN2 = (dineroLike) => { return amount.toFixed(2); }; +const toFiniteNumber = (value) => { + if (typeof value === "number") { + return Number.isFinite(value) ? value : 0; + } + + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +}; + /** * Normalize various "money-like" shapes to integer cents. * Supports: @@ -100,6 +113,100 @@ const toMoneyCents = (value) => { const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 }); +const formatDecimal = (value, maxDecimals = 2) => { + const factor = Math.pow(10, maxDecimals); + const rounded = Math.round(Math.max(0, toFiniteNumber(value)) * factor) / factor; + if (!Number.isFinite(rounded)) return "0"; + return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0"; +}; + +const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => { + const normalizedAmount = toFiniteNumber(amountUnits); + + if (normalizedAmount <= 0) { + return { + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }; + } + + let resolvedHours = toFiniteNumber(hours); + let resolvedRate = toFiniteNumber(rate); + + if (resolvedHours > 0 && resolvedRate <= 0) { + resolvedRate = normalizedAmount / resolvedHours; + } else if (resolvedRate > 0 && resolvedHours <= 0) { + resolvedHours = normalizedAmount / resolvedRate; + } else if (resolvedHours <= 0 && resolvedRate <= 0) { + // Keep the math internally consistent even if the source job has dollars but no usable hours. + resolvedHours = 1; + resolvedRate = normalizedAmount; + } + + return { + jobTotalHrs: formatDecimal(resolvedHours), + billTime: formatDecimal(resolvedHours), + billRate: resolvedRate.toFixed(2) + }; +}; + +const buildMinimalRolaborFromJob = (job, { opCode, payType = "Cust" } = {}) => { + const trimmedOpCode = opCode != null ? String(opCode).trim() : ""; + if (!job || !trimmedOpCode) return null; + + let totalHours = 0; + let totalAmountUnits = 0; + + for (const line of job?.joblines || []) { + const laborType = typeof line?.mod_lbr_ty === "string" ? line.mod_lbr_ty.trim() : ""; + if (!laborType) continue; + + const lineHours = toFiniteNumber(line?.mod_lb_hrs ?? line?.db_hrs); + const configuredRate = toFiniteNumber(job?.[`rate_${laborType.toLowerCase()}`]); + let lineAmountUnits = toFiniteNumber(line?.lbr_amt); + + if (lineAmountUnits <= 0 && lineHours > 0 && configuredRate > 0) { + lineAmountUnits = lineHours * configuredRate; + } + + if (lineAmountUnits <= 0 && lineHours <= 0) continue; + + totalHours += lineHours; + totalAmountUnits += lineAmountUnits; + } + + if (totalAmountUnits <= 0 && totalHours <= 0) return null; + + const bill = buildRolaborBillFields({ + amountUnits: totalAmountUnits, + hours: totalHours, + rate: totalHours > 0 ? totalAmountUnits / totalHours : 0 + }); + const formattedAmount = totalAmountUnits.toFixed(2); + + return { + ops: [ + { + opCode: trimmedOpCode, + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: toFiniteNumber(job?.tax_lbr_rt) > 0 ? "T" : "N", + bill: { + payType, + ...bill + }, + amount: { + payType, + amtType: "Job", + custPrice: formattedAmount, + totalAmt: formattedAmount + } + } + ] + }; +}; + /** * Build RR estimate block from allocation totals. * @param {Array} allocations @@ -326,6 +433,13 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo // Each segment becomes its own op / JobNo with a single line segments.forEach((seg, idx) => { const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments + const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable"; + const segmentHours = isLaborSegment + ? seg.kind === "laborTaxable" + ? toFiniteNumber(alloc.laborTaxableHours) + : toFiniteNumber(alloc.laborNonTaxableHours) + : 0; + const segmentBillRate = isLaborSegment && segmentHours > 0 ? seg.saleCents / 100 / segmentHours : 0; const line = { breakOut, @@ -349,7 +463,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo // Extra metadata for UI / debugging segmentKind: seg.kind, segmentIndex: idx, - segmentCount + segmentCount, + segmentHours, + segmentBillRate }); }); } @@ -368,9 +484,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo * * We still keep a 1:1 mapping with GOG ops: each op gets a corresponding * OpCodeLaborInfo entry using the same JobNo and the same tax flag as its - * GOG line. Labor-specific hours/rate remain zeroed out, but actual labor - * sale amounts are mirrored into ROLABOR for labor segments so RR receives - * the expected labor pricing on updates. Non-labor ops remain zeroed. + * GOG line. Labor sale amounts are mirrored into ROLABOR and, when hours + * are available from allocations, weighted bill hours/rates are also + * populated so the labor subsection is editable in Ignite. * * @param {Object} rogg - result of buildRogogFromAllocations * @param {Object} opts @@ -391,6 +507,17 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => { const linePayType = firstLine.custPayTypeFlag || "C"; const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable"; const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0"; + const laborBill = isLaborSegment + ? buildRolaborBillFields({ + amountUnits: laborAmount, + hours: op.segmentHours, + rate: op.segmentBillRate + }) + : { + jobTotalHrs: "0", + billTime: "0", + billRate: "0" + }; return { opCode: op.opCode, @@ -399,9 +526,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => { custTxblNtxblFlag: txFlag, bill: { payType, - jobTotalHrs: "0", - billTime: "0", - billRate: "0" + ...laborBill }, amount: { payType, @@ -686,5 +811,6 @@ module.exports = { normalizeCustomerCandidates, normalizeVehicleCandidates, buildRogogFromAllocations, - buildRolaborFromRogog + buildRolaborFromRogog, + buildMinimalRolaborFromJob }; diff --git a/server/rr/rr-job-helpers.test.js b/server/rr/rr-job-helpers.test.js new file mode 100644 index 000000000..c3a6ee5f3 --- /dev/null +++ b/server/rr/rr-job-helpers.test.js @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const mock = require("mock-require"); + +const graphClientModuleId = require.resolve("../graphql-client/graphql-client"); +const queriesModuleId = require.resolve("../graphql-client/queries"); +const helpersModuleId = require.resolve("./rr-job-helpers"); + +const loadHelpers = () => { + mock.stopAll(); + mock(graphClientModuleId, { client: { request: async () => ({}) } }); + mock(queriesModuleId, { GET_JOB_BY_PK: "GET_JOB_BY_PK" }); + delete require.cache[helpersModuleId]; + return require(helpersModuleId); +}; + +afterEach(() => { + mock.stopAll(); + delete require.cache[helpersModuleId]; +}); + +describe("server/rr/rr-job-helpers", () => { + it("builds a single early-RO labor row from aggregated job labor", () => { + const { buildMinimalRolaborFromJob } = loadHelpers(); + + const rolabor = buildMinimalRolaborFromJob( + { + tax_lbr_rt: 13, + joblines: [ + { mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 }, + { mod_lbr_ty: "LAD", mod_lb_hrs: 1.5, lbr_amt: 180 } + ] + }, + { opCode: "51DOZ" } + ); + + expect(rolabor).toEqual({ + ops: [ + { + opCode: "51DOZ", + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: "T", + bill: { + payType: "Cust", + jobTotalHrs: "3.5", + billTime: "3.5", + billRate: "108.57" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "380.00", + totalAmt: "380.00" + } + } + ] + }); + }); + + it("populates labor bill fields from allocation hours on the full RR payload", () => { + const { buildRRRepairOrderPayload } = loadHelpers(); + + const payload = buildRRRepairOrderPayload({ + job: { + id: "job-1", + ro_number: "RO-123", + v_vin: "1HGBH41JXMN109186" + }, + selectedCustomer: { customerNo: "1134485" }, + advisorNo: "70754", + allocations: [ + { + center: "Body Labor", + partsSale: { amount: 0, precision: 2 }, + laborTaxableSale: { amount: 24000, precision: 2 }, + laborNonTaxableSale: { amount: 0, precision: 2 }, + extrasSale: { amount: 0, precision: 2 }, + totalSale: { amount: 24000, precision: 2 }, + cost: { amount: 12000, precision: 2 }, + laborTaxableHours: 2, + laborNonTaxableHours: 0, + profitCenter: { + rr_gogcode: "BL", + rr_item_type: "G", + accountdesc: "BODY LABOR" + } + } + ], + opCode: "51DOZ" + }); + + expect(payload.rolabor).toEqual({ + ops: [ + { + opCode: "51DOZ", + jobNo: "1", + custPayTypeFlag: "C", + custTxblNtxblFlag: "T", + bill: { + payType: "Cust", + jobTotalHrs: "2", + billTime: "2", + billRate: "120.00" + }, + amount: { + payType: "Cust", + amtType: "Job", + custPrice: "240.00", + totalAmt: "240.00" + } + } + ] + }); + }); +}); diff --git a/server/utils/instanceMgr.js b/server/utils/instanceMgr.js index 07759ac32..29e90eda1 100644 --- a/server/utils/instanceMgr.js +++ b/server/utils/instanceMgr.js @@ -7,14 +7,24 @@ * @property { string | object | function } promanager Return this prop if Rome. * @property { string | object | function } imex Return this prop if Rome. */ +const { isString, isEmpty } = require("lodash"); -function InstanceManager({ args, instance, debug, executeFunction, rome, promanager, imex }) { +/** + * InstanceManager is a utility function that determines which property to return based on the current instance type. + * @param param0 + * @param param0.args + * @param param0.instance + * @param param0.debug + * @param param0.executeFunction + * @param param0.rome + * @param param0.promanager + * @param param0.imex + * @returns {*|null} + * @constructor + */ +const InstanceManager = ({ args, instance, debug, executeFunction, rome, promanager, imex }) => { let propToReturn = null; - //TODO: Remove after debugging. - if (promanager) { - console.trace("ProManager Prop was used"); - } switch (instance || process.env.INSTANCE) { case "IMEX": propToReturn = imex; @@ -50,15 +60,42 @@ function InstanceManager({ args, instance, debug, executeFunction, rome, promana } if (executeFunction && typeof propToReturn === "function") return propToReturn(...args); return propToReturn === undefined ? null : propToReturn; -} +}; -exports.InstanceRegion = () => +/** + * Returns the AWS region to be used for the current instance, which is determined by the INSTANCE environment variable. + * @returns {*} + * @constructor + */ +const InstanceRegion = () => InstanceManager({ imex: "ca-central-1", rome: "us-east-2" }); -exports.InstanceEndpoints = () => +/** + * Checks if the instance is configured to use LocalStack by verifying the presence of the LOCALSTACK_HOSTNAME + * environment variable. + * @returns {boolean} + * @constructor + */ +const InstanceIsLocalStackEnabled = () => + isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME); + +/** + * Returns the LocalStack endpoint URL based on the LOCALSTACK_HOSTNAME environment variable. + * @returns {`http://${*}:4566`} + * @constructor + */ +const InstanceLocalStackEndpoint = () => `http://${process.env.LOCALSTACK_HOSTNAME}:4566`; + +/** + * Returns the appropriate endpoints for the current instance, which can be used for making API calls or other network + * requests. + * @returns {*|null} + * @constructor + */ +const InstanceEndpoints = () => InstanceManager({ imex: process.env?.NODE_ENV === "development" @@ -74,4 +111,11 @@ exports.InstanceEndpoints = () => : "https://romeonline.io" }); -exports.default = InstanceManager; +module.exports = { + InstanceManager, + InstanceRegion, + InstanceIsLocalStackEnabled, + InstanceLocalStackEndpoint, + InstanceEndpoints, + default: InstanceManager +}; diff --git a/server/utils/logger.js b/server/utils/logger.js index 9b4bea937..cf4391890 100644 --- a/server/utils/logger.js +++ b/server/utils/logger.js @@ -2,10 +2,9 @@ const InstanceManager = require("../utils/instanceMgr").default; const winston = require("winston"); const WinstonCloudWatch = require("winston-cloudwatch"); -const { isString, isEmpty } = require("lodash"); const { uploadFileToS3 } = require("./s3"); const { v4 } = require("uuid"); -const { InstanceRegion } = require("./instanceMgr"); +const { InstanceRegion, InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("./instanceMgr"); const getHostNameOrIP = require("./getHostNameOrIP"); const client = require("../graphql-client/graphql-client").client; const queries = require("../graphql-client/queries"); @@ -48,7 +47,7 @@ const normalizeLevel = (level) => (level ? level.toLowerCase() : LOG_LEVELS.debu const createLogger = () => { try { - const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME); + const isLocal = InstanceIsLocalStackEnabled(); const logGroupName = isLocal ? "development" : process.env.CLOUDWATCH_LOG_GROUP; const winstonCloudwatchTransportDefaults = { @@ -60,7 +59,7 @@ const createLogger = () => { }; if (isLocal) { - winstonCloudwatchTransportDefaults.awsOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`; + winstonCloudwatchTransportDefaults.awsOptions.endpoint = InstanceLocalStackEndpoint(); } const levelFilter = (levels) => { diff --git a/server/utils/s3.js b/server/utils/s3.js index 2ba1f0d47..bc6f82373 100644 --- a/server/utils/s3.js +++ b/server/utils/s3.js @@ -7,8 +7,7 @@ const { CopyObjectCommand } = require("@aws-sdk/client-s3"); const { defaultProvider } = require("@aws-sdk/credential-provider-node"); -const { InstanceRegion } = require("./instanceMgr"); -const { isString, isEmpty } = require("lodash"); +const { InstanceRegion, InstanceIsLocalStackEnabled, InstanceLocalStackEndpoint } = require("./instanceMgr"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const createS3Client = () => { @@ -17,10 +16,8 @@ const createS3Client = () => { credentials: defaultProvider() }; - const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME); - - if (isLocal) { - S3Options.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`; + if (InstanceIsLocalStackEnabled()) { + S3Options.endpoint = InstanceLocalStackEndpoint(); S3Options.forcePathStyle = true; // Needed for LocalStack to avoid bucket name as hostname } @@ -105,7 +102,7 @@ const createS3Client = () => { }); const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 360 }); return presignedUrl; - } + }; return { uploadFileToS3, @@ -119,7 +116,4 @@ const createS3Client = () => { }; }; - - - module.exports = createS3Client();