From e7801559d8e6e700b8c5fde899a4db67d0df26eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:55:10 +0000 Subject: [PATCH 1/4] Bump the minor-and-patch group with 6 updates Bumps the minor-and-patch group with 6 updates: | Package | From | To | | --- | --- | --- | | [zod](https://github.com/colinhacks/zod) | `4.6.5` | `4.6.6` | | [@types/bun](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bun) | `1.4.2` | `1.4.3` | | [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.104.1` | `5.104.2` | | [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) | `1.170.41` | `1.170.42` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `5.0.3` | `5.0.4` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` | Updates `zod` from 4.6.5 to 4.6.6 - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.6) Updates `@types/bun` from 1.4.2 to 1.4.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/bun) Updates `@tanstack/react-query` from 5.104.1 to 5.104.2 - [Release notes](https://github.com/TanStack/query/releases) - [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md) - [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.104.2/packages/react-query) Updates `@tanstack/react-router` from 1.170.41 to 1.170.42 - [Release notes](https://github.com/TanStack/router/releases) - [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md) - [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.170.42/packages/react-router) Updates `@vitest/coverage-v8` from 5.0.3 to 5.0.4 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/coverage-v8) Updates `vitest` from 5.0.3 to 5.0.4 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/vitest) --- updated-dependencies: - dependency-name: zod dependency-version: 4.6.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@types/bun" dependency-version: 1.4.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@tanstack/react-query" dependency-version: 5.104.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@tanstack/react-router" dependency-version: 1.170.42 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@vitest/coverage-v8" dependency-version: 5.0.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: vitest dependency-version: 5.0.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] --- bun.lock | 63 +++++++++++++++++++++++++++--------------------- package.json | 4 +-- web/package.json | 8 +++--- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/bun.lock b/bun.lock index dc17d72..0e2fcd0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "asyncup", @@ -14,12 +15,12 @@ "harper.js": "^2.10.0", "luxon": "^3.7.2", "pg": "^8.23.1", - "zod": "^4.6.5", + "zod": "^4.6.6", }, "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", "@seriousme/openapi-schema-validator": "^2.11.0", - "@types/bun": "^1.3.1", + "@types/bun": "^1.4.3", "@types/express": "^5.0.6", "@types/luxon": "^3.7.6", "@types/node": "^26.6.5", @@ -32,8 +33,8 @@ "name": "@asyncup/web", "version": "0.1.0", "dependencies": { - "@tanstack/react-query": "^5.104.1", - "@tanstack/react-router": "^1.170.41", + "@tanstack/react-query": "^5.104.2", + "@tanstack/react-router": "^1.170.42", "react": "^19.3.0", "react-dom": "^19.3.0", }, @@ -44,10 +45,10 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", - "@vitest/coverage-v8": "^5.0.3", + "@vitest/coverage-v8": "^5.0.4", "jsdom": "^30.1.2", "vite": "^8.3.4", - "vitest": "^5.0.3", + "vitest": "^5.0.4", }, }, }, @@ -306,17 +307,17 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@tanstack/history": ["@tanstack/history@1.162.4", "", {}, "sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ=="], + "@tanstack/history": ["@tanstack/history@1.162.5", "", {}, "sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA=="], - "@tanstack/query-core": ["@tanstack/query-core@5.104.1", "", {}, "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.104.2", "", {}, "sha512-/qbsxJK5c3Ve1aFIULWYNcT2jeRUAiD4HME6Eu2ZdpofHoVgAjpkQhLg5xHuOoBkTPQUJLfP7zOxy0qsBcqROA=="], - "@tanstack/react-query": ["@tanstack/react-query@5.104.1", "", { "dependencies": { "@tanstack/query-core": "5.104.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ=="], + "@tanstack/react-query": ["@tanstack/react-query@5.104.2", "", { "dependencies": { "@tanstack/query-core": "5.104.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-xKmzWGhadbE5wGS/ZmXShRITnI9oItox5GO14KvFZco3+vhTAyXS1erYzJWdiY280zapAGJXFUL9nHgwP6SV/g=="], - "@tanstack/react-router": ["@tanstack/react-router@1.170.41", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.34", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w=="], + "@tanstack/react-router": ["@tanstack/react-router@1.170.42", "", { "dependencies": { "@tanstack/history": "1.162.5", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.35", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-4wBptX7DRvc+v/XQ0MxL9rKlmSFb0ob3tSEZWF4ssSl1DD+1HAofWM3HwSn9liQN0r7e00qiv5WQMp6rObOc1g=="], "@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], - "@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.35", "", { "dependencies": { "@tanstack/history": "1.162.5", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-fNKsx/rrxppFNmCQ9mPwg4OCb1O3qfLPVrE9FVyyWyRPQk0f0NK5bdJUzTYZQ+T/Ajfllr3SEx9pntM45F5amQ=="], "@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], @@ -332,7 +333,7 @@ "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -434,15 +435,15 @@ "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@5.0.3", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/istanbul-lib-coverage": "^1.0.0", "@vitest/istanbul-lib-report": "^1.0.0", "ast-v8-to-istanbul": "^1.0.5", "magicast": "^0.5.4", "obug": "^2.1.4", "std-env": "^4.2.0", "tinyrainbow": "^3.1.1" }, "peerDependencies": { "@vitest/browser": "5.0.3", "vitest": "5.0.3" }, "optionalPeers": ["@vitest/browser"] }, "sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@5.0.4", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/istanbul-lib-coverage": "^1.0.2", "@vitest/istanbul-lib-report": "^1.0.2", "ast-v8-to-istanbul": "^1.0.7", "magicast": "^0.5.5", "obug": "^3.0.0", "std-env": "^4.3.0", "tinyrainbow": "^3.2.0" }, "peerDependencies": { "@vitest/browser": "5.0.4", "vitest": "5.0.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A=="], - "@vitest/istanbul-lib-coverage": ["@vitest/istanbul-lib-coverage@1.0.1", "", {}, "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA=="], + "@vitest/istanbul-lib-coverage": ["@vitest/istanbul-lib-coverage@1.0.2", "", {}, "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA=="], - "@vitest/istanbul-lib-report": ["@vitest/istanbul-lib-report@1.0.1", "", { "dependencies": { "@vitest/istanbul-lib-coverage": "1.0.1" } }, "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ=="], + "@vitest/istanbul-lib-report": ["@vitest/istanbul-lib-report@1.0.2", "", { "dependencies": { "@vitest/istanbul-lib-coverage": "1.0.2" } }, "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ=="], - "@vitest/mocker": ["@vitest/mocker@5.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg=="], + "@vitest/mocker": ["@vitest/mocker@5.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ=="], - "@vitest/spy": ["@vitest/spy@5.0.3", "", {}, "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg=="], + "@vitest/spy": ["@vitest/spy@5.0.4", "", {}, "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw=="], "@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="], @@ -500,7 +501,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.7", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -514,7 +515,7 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -524,7 +525,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -734,7 +735,7 @@ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], - "magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="], + "magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="], "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], @@ -782,7 +783,7 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -918,7 +919,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -928,13 +929,13 @@ "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], - "tinybench": ["tinybench@6.1.4", "", {}, "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ=="], + "tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="], - "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "tinyrainbow": ["tinyrainbow@3.2.0", "", {}, "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A=="], "tldts": ["tldts@7.4.13", "", { "dependencies": { "tldts-core": "^7.4.13" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA=="], @@ -982,7 +983,7 @@ "vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="], - "vitest": ["vitest@5.0.3", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.3", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "^6.1.4", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.3", "@vitest/browser-preview": "5.0.3", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.3", "@vitest/coverage-v8": "5.0.3", "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ=="], + "vitest": ["vitest@5.0.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.4", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.4", "@vitest/browser-preview": "5.0.4", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.4", "@vitest/coverage-v8": "5.0.4", "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg=="], "vue": ["vue@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/server-renderer": "3.5.42", "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA=="], @@ -1020,12 +1021,18 @@ "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], - "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + "zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@modelcontextprotocol/client/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + + "@modelcontextprotocol/core/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + + "@modelcontextprotocol/server/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], diff --git a/package.json b/package.json index cb50862..0fe4fa1 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,12 @@ "harper.js": "^2.10.0", "luxon": "^3.7.2", "pg": "^8.23.1", - "zod": "^4.6.5" + "zod": "^4.6.6" }, "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", "@seriousme/openapi-schema-validator": "^2.11.0", - "@types/bun": "^1.3.1", + "@types/bun": "^1.4.3", "@types/express": "^5.0.6", "@types/luxon": "^3.7.6", "@types/node": "^26.6.5", diff --git a/web/package.json b/web/package.json index b6e36b1..1521233 100644 --- a/web/package.json +++ b/web/package.json @@ -12,8 +12,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@tanstack/react-query": "^5.104.1", - "@tanstack/react-router": "^1.170.41", + "@tanstack/react-query": "^5.104.2", + "@tanstack/react-router": "^1.170.42", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -24,9 +24,9 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", - "@vitest/coverage-v8": "^5.0.3", + "@vitest/coverage-v8": "^5.0.4", "jsdom": "^30.1.2", "vite": "^8.3.4", - "vitest": "^5.0.3" + "vitest": "^5.0.4" } } From 885d8de0219dee82869bba0e17bf866965dc6d3e Mon Sep 17 00:00:00 2001 From: Ashish Kumar Date: Fri, 16 Oct 2026 08:04:00 +0530 Subject: [PATCH 2/4] Let managers approve edits to answers after the deadline (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking to edit after the deadline sends the standup's managers a card. The first decision wins and redraws every card. An approved edit, a late first answer and an undecided request all end at midnight in the standup's timezone. Team › Requests lists edit requests too, and the submission webhook names the approver. Spec: specs/005-late-edits.md --- docs/guide/api.md | 7 + docs/guide/commands.md | 11 +- docs/guide/configuration.md | 2 +- docs/guide/user-guide.md | 12 +- docs/guide/web-app.md | 6 + site/index.html | 2 +- specs/005-late-edits.md | 133 ++++++++++ src/adapters/fake/adapter.ts | 26 +- src/adapters/gchat/adapter.ts | 20 ++ src/adapters/gchat/cards.ts | 126 ++++++++- src/adapters/gchat/events.ts | 48 +++- src/api/api.ts | 2 + src/api/edit-requests.ts | 64 +++++ src/api/spec/routes.ts | 15 ++ src/api/spec/schemas.ts | 19 ++ src/core/adapter.ts | 16 +- src/core/edit-requests.ts | 197 ++++++++++++++ src/core/scheduler.ts | 6 + src/core/standup-service.ts | 32 ++- src/core/types.ts | 21 ++ src/core/webhooks.ts | 3 + src/db/repo.ts | 106 +++++++- src/mcp/tools.ts | 5 +- tests/cards-more.test.ts | 2 +- tests/cards.test.ts | 2 +- tests/gchat-adapter.test.ts | 24 +- tests/late-edits.test.ts | 449 ++++++++++++++++++++++++++++++++ tests/mcp.test.ts | 2 +- tests/migrations.test.ts | 42 ++- web/src/lib/schedule.ts | 18 +- web/src/pages/console.test.tsx | 1 + web/src/pages/schedule.test.tsx | 51 +++- web/src/pages/team.tsx | 94 +++++-- 33 files changed, 1499 insertions(+), 65 deletions(-) create mode 100644 specs/005-late-edits.md create mode 100644 src/api/edit-requests.ts create mode 100644 src/core/edit-requests.ts create mode 100644 tests/late-edits.test.ts diff --git a/docs/guide/api.md b/docs/guide/api.md index 6d26ad1..4bf3b6b 100644 --- a/docs/guide/api.md +++ b/docs/guide/api.md @@ -152,6 +152,13 @@ Today's run needs `standups.view`; the run list and insights need `reports.view` | `GET /standups/:id/runs/:date` | One run with full submissions and answers | | `GET /standups/:id/insights?weeks=8` | Weekly participation, mood and blocker series | +Requests to edit answers after the deadline are decided by anyone holding `standups.configure` on the standup: + +| Method | Path | Notes | +| --- | --- | --- | +| `GET` | `/edit-requests` | Pending requests the caller can decide, newest first: `standupId`, `standupName`, `date`, `userName`, `displayName`, `reason`, `askedAt`, `answeredAt` and `until` (midnight in the standup's timezone, as `23:59 Europe/London`) | +| `POST` | `/edit-requests/:id/approve` · `/edit-requests/:id/decline` | `{ note? }` on decline goes to the person; `404` for an unknown request, `403` without `standups.configure` on its standup, `409 refused` once decided or lapsed, or for the caller's own request | + Moods are withheld per person when the standup keeps them anonymous; only the team average is returned. A run includes `moodComments` only for callers holding `moods.comments` on the standup: each with `person`, `mood` and `comment`, or with `person: null`, sorted by mood then text, when moods are anonymous. ### Blockers diff --git a/docs/guide/commands.md b/docs/guide/commands.md index 0c480ce..3877f79 100644 --- a/docs/guide/commands.md +++ b/docs/guide/commands.md @@ -121,8 +121,11 @@ Anyone can DM the bot directly: question with "none" when there are none. The skip reason works the same way. - **Pre-fill:** the "yesterday" answer is pre-filled from your previous "today" answer. -- **Late submissions** after the deadline still post, flagged *late*; the - wrap-up isn't recalculated and late entries can no longer be edited. +- **Late submissions** after the deadline still post, flagged *late*, until + midnight in the standup's timezone; the wrap-up isn't recalculated. +- **Editing after the deadline** needs a manager's approval: **Ask to edit** on + the prompt card sends the standup's managers a card. Once approved, answers + can change until midnight, and the posted card names who approved it. - **Untagged blockers** auto-resolve when the same person submits a blocker-free standup on a later day; tagged ones need an explicit `blocker resolve`. With `escalate @user` configured, the contact gets @@ -145,7 +148,9 @@ Anyone can DM the bot directly: The DM card that asks for today's standup keeps up with you: once you submit it shows when (with **Edit answers** until the deadline), and once -you skip it shows your reason with **↩️ Undo skip**. Reminders and nudges +you skip it shows your reason with **↩️ Undo skip**. At the deadline it offers +**Ask to edit**, then shows whether your request is waiting, approved (with +**Edit answers** until midnight), declined or lapsed. Reminders and nudges update the same way. On a standup whose roster follows a Google Group, `add` keeps working: people diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 6b833d5..e84667a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -93,7 +93,7 @@ Zapier/n8n, or your own service: | Event | When | Body | | --- | --- | --- | -| `submission` | Every submission and edit | `standup`, `date`, `user`, `answers`, `mood`, `late`, `edited`. Mood comments are never sent | +| `submission` | Every submission and edit | `standup`, `date`, `user`, `answers`, `mood`, `late`, `edited`, `editApprovedBy` (who approved an edit after the deadline, otherwise `null`). Mood comments are never sent | | `wrap_up` | The run closes | `standup`, `date`, `summary` (the posted wrap-up's numbers, plus `teamMood` on anonymous standups) | | `schedule_change` | Someone's week or days off change | person, summary, who, channel — see [Personal schedules](./schedules#integrations) | | `time_off_request` | A request needs approval | person, dates, working, reason, request ids | diff --git a/docs/guide/user-guide.md b/docs/guide/user-guide.md index 27c6a6b..7bac840 100644 --- a/docs/guide/user-guide.md +++ b/docs/guide/user-guide.md @@ -25,9 +25,15 @@ in a Google Chat DM. "Yesterday" comes pre-filled from your previous "today". 3. Plans changed? Your DM card now shows *Submitted at …* with **Edit answers** until the deadline. Editing updates the posted card in place, - marked *edited*. After the deadline a first submission still posts, marked - *late*. -4. Not today? Press **🏖️ Skip today** and say why in one line. The reason goes + marked *edited*. After the deadline a first answer still posts, marked + *late*, until midnight in the standup's timezone. +4. Need to change an answer after the deadline? Press **Ask to edit** on your + card, and say why if you like. Your standup's managers get a card with + **Approve** and **Decline**. If one of them approves, you can edit until + midnight, and your posted card reads *Edited after the deadline · approved + by …*. You can ask once a day, and a request nobody decides lapses at + midnight. The wrap-up already posted stays as it was. +5. Not today? Press **🏖️ Skip today** and say why in one line. The reason goes in today's thread, and you're listed as away, never as missing. Changed your mind? **↩️ Undo skip** (or DM `unskip`) before the deadline removes that reply and brings the form back. diff --git a/docs/guide/web-app.md b/docs/guide/web-app.md index 7ed309e..2abb91a 100644 --- a/docs/guide/web-app.md +++ b/docs/guide/web-app.md @@ -86,6 +86,12 @@ Personal weeks, days off and time-off approvals also live under **Team → Edit schedule**, **My page → My schedule** and **Team → Requests** — see [Personal schedules](./schedules). +**Team → Requests** also lists requests to edit answers after the deadline, +for anyone who can configure the standup: who asked, which standup and day, +their reason, and when the request lapses (midnight in the standup's +timezone). **Approve** lets them edit until then; **Decline…** takes an +optional note sent to them. The first decision wins, in Chat or here. + ## Naming, archiving and deleting - Standup names are unique across the workspace, archived standups included, diff --git a/site/index.html b/site/index.html index f89e9b0..1b12107 100644 --- a/site/index.html +++ b/site/index.html @@ -69,7 +69,7 @@

Daily standups that run themselves.
No meeting. No SaaS. Your data.

How it works

-
09:30

Everyone gets a card

One DM, one form: yesterday, today, blockers, and a private mood with an optional comment. Likely typos are pointed out before posting, checked on your own server. Edit until the deadline; the posted card updates in place.

+
09:30

Everyone gets a card

One DM, one form: yesterday, today, blockers, and a private mood with an optional comment. Likely typos are pointed out before posting, checked on your own server. Edit until the deadline; the posted card updates in place. After it, a manager can approve a change.

As they answer

Answers land in one thread

Each answer is a card under today’s thread in your space. Away people are never nagged or counted as missing.

11:30

The wrap-up tells the truth

How many submitted and exactly who didn’t. Blockers open as items you can tag, acknowledge and resolve.

diff --git a/specs/005-late-edits.md b/specs/005-late-edits.md new file mode 100644 index 0000000..3693005 --- /dev/null +++ b/specs/005-late-edits.md @@ -0,0 +1,133 @@ +# 005: Editing answers after the deadline, with approval + +| | | +| --- | --- | +| Status | Done | +| Size | Cross-cutting (migration, API, Chat cards, web app) | +| Figma | [Specs › 005 Late edits](https://www.figma.com/design/OQfcL61rbaGakS2FjBKasI?node-id=162-56) | +| PR | [#106](https://github.com/asyncup-dev/asyncup/pull/106) | + +## Problem + +Once a standup's deadline passes, an answer can't be changed. Someone +who spots a mistake, or whose plan changed, has to post a correction +by hand, and the wrong answer stays in History, exports and webhooks. +Opening edits to everyone would let answers change after the wrap-up +has been read, so a manager should agree first. Separately, a missed +standup can be answered late at any time today, even days later, +which makes old runs change long after anyone looks at them. + +## User stories + +- As someone who answered, I want to ask to change my answers after + the deadline, so the record is right. +- As a standup manager, I want to approve or decline those requests in + Chat or the web app, so answers only change when I agree. +- As someone who missed the standup, I can still answer late that day, + without asking anyone, as today. + +## Acceptance criteria + +Late first answers + +1. Given someone has not answered, when they submit after the deadline + but on the run's day (standup timezone), then the answer posts, + marked late, with no approval, as today. +2. Given the run's day has ended, when they open or submit the form, + then it is refused: "This standup has closed. Answers for Thu + 15 Oct can no longer be added." MCP `submit_answers` refuses the + same way. + +Asking to edit + +3. When a run closes, the prompt card of everyone who answered is + redrawn: "Submitted at 09:12. The deadline has passed. Need to + change something? Ask a manager." with an **Ask to edit** button. +4. **Ask to edit**, or **Edit answers** on a card drawn before the + deadline, opens a dialog with an optional reason (up to 200 + characters) and **Send request**. +5. On sending, every manager of the standup gets a DM card: who, which + standup and day, the reason, **Approve** and **Decline…**. The + person is told "Sent to your managers. You'll hear back here." +6. Given a pending request, a second ask says "Your request is waiting + for a manager." One request per person per run: once declined, they + can't ask again that day. +7. Given the run's day has ended, **Ask to edit** says the day has + closed and sends nothing. + +Deciding + +8. Anyone holding `standups.configure` on the standup can decide, in + Chat or on the web app's Team › Requests. Nobody decides their own + request: "Your own requests go to your managers." +9. The first decision wins. Every manager's card is redrawn to + "Approved by Asha" or "Declined by Asha", and a later click says it + was already decided. +10. **Decline…** asks for an optional note, sent to the person. +11. Given no decision by the end of the run's day, the request lapses: + the person is told, and the managers' cards say "Lapsed". + +Editing after approval + +12. On approval, the person gets a DM: "Asha approved your request. + Edit your answers before 23:59 Europe/London." with **Edit + answers**, and their prompt card shows the same button. +13. Until the end of the run's day they can edit as often as they like, + from Chat or MCP. After that, edits are refused as in criterion 2. +14. The posted answer is updated in place, and its card reads "Edited + after the deadline · approved by Asha". +15. The `submission` webhook fires with `edited: true` and a new field + `editApprovedBy` (the approver's name, otherwise `null`). +16. The wrap-up already posted is not changed. + +## Non-goals + +- Approval for a first late answer. +- Asking to edit from the web app or MCP. Asking happens in Chat. +- Deciding from MCP. +- Reposting or editing the wrap-up after an approved edit. +- Editing runs from earlier days. + +## Design impact + +| Area | Change | +| --- | --- | +| Web app | Team › Requests: an **Edit requests** list beside time-off requests, with Approve and Decline…. The tab shows to anyone who can decide either kind | +| Chat cards and dialogs | Prompt card after the deadline (**Ask to edit**). Ask dialog. Manager's request card, pending and decided. DMs for approved, declined and lapsed. "Day closed" dialog error. Posted answer's "Edited after the deadline · approved by" line | +| API and OpenAPI | `GET /edit-requests` (pending requests the caller can decide), `POST /edit-requests/:id/approve` and `/decline` taking `DecisionBody`. Permission `standups.configure` on the request's standup. The `submission` webhook gains `editApprovedBy` | +| MCP | `submit_answers` refuses after the run's day ends, and refuses an edit after the deadline without approval: "Ask a manager to let you edit, from your prompt card in Chat." | +| Database | Migration 20, both dialects: `edit_requests` (run, person, reason, status `pending`, `approved`, `declined` or `lapsed`, asked at, decided at, decided by, note, and the managers' DM message names, one per person and run), and `submissions.edit_approved_by` for the posted card and the webhook | +| Permissions | None new. Deciding needs `standups.configure` | +| Docs | `user-guide.md` (late answers and asking to edit), `web-app.md` (Team › Requests), `api.md` (routes), `configuration.md` (webhook field) | +| Website | `site/index.html`: one line under answering in Chat | +| Google APIs and scopes | None | + +How it works: + +- "The run's day" ends at midnight in the standup's timezone. + `StandupService.submit` refuses after that, so Chat and MCP agree. +- `submit` lets an existing answer change after the deadline only with + an approved request for that run and person, before midnight. +- A new `EditRequests` service in `src/core` asks, decides and lapses. + The scheduler tick lapses pending requests whose day has ended. It + runs from the database, so a restart doesn't send anything twice. +- Manager cards are sent through `ChatAdapter`, and their message names + are stored so a decision can redraw all of them. +- When a run closes, the prompt cards of people who answered are + redrawn with the existing `refreshCards`. +- The prompt card then follows the request: waiting, approved (with + **Edit answers**), declined or lapsed. +- Request rows on Team › Requests wrap their buttons under the text on a + phone; time-off rows had squeezed their text there too. + +## Open questions + +None. Decided with the maintainer on 16 Oct 2026: + +- Approval is needed only to edit an answer after the deadline. +- A first late answer posts without approval, until the end of that day. +- Approvers are the standup's managers, and anyone with + `standups.configure` on it, in the web app. +- The window ends at midnight in the standup's timezone. +- The wrap-up is not reposted after an approved edit. +- One request per person per run. diff --git a/src/adapters/fake/adapter.ts b/src/adapters/fake/adapter.ts index 39f60cb..dd0f459 100644 --- a/src/adapters/fake/adapter.ts +++ b/src/adapters/fake/adapter.ts @@ -1,15 +1,17 @@ import type { ChatAdapter, SpaceInfo, SpaceMember, SpaceMembership, PromptState } from '../../core/adapter.js'; import type { Blocker, Poll, Run, RunSummary, Standup, Submission } from '../../core/types.js'; import type { TimeOffRequest } from '../../core/schedule.js'; +import type { EditRequestView } from '../../core/edit-requests.js'; interface SentDm { - kind: 'prompt' | 'reminder' | 'text' | 'blockerCard' | 'timeOffRequest'; + kind: 'prompt' | 'reminder' | 'text' | 'blockerCard' | 'timeOffRequest' | 'editRequest' | 'editApproved'; userName: string; standupId?: number; runId?: number; blockerId?: number; text?: string; request?: TimeOffRequest; + editRequest?: EditRequestView; /** A blocker card sent to the person who raised it. */ owner?: boolean; } @@ -136,6 +138,28 @@ export class FakeAdapter implements ChatAdapter { this.dms.push({ kind: 'timeOffRequest', userName: managerUserName, standupId: request.standup.id, request }); } + /** Edit request card rewrites, newest last. */ + editCardUpdates: { messageNames: string[]; request: EditRequestView }[] = []; + /** Makes edit request cards fail to send or redraw. */ + failEditCards = false; + + async sendEditRequest(managerUserName: string, request: EditRequestView): Promise { + if (this.failEditCards) throw new Error('Chat refused the card'); + this.dms.push({ kind: 'editRequest', userName: managerUserName, editRequest: request }); + this.log?.(`edit request to ${managerUserName}: ${request.person.displayName} ${request.date}`); + return `messages/fake-${++this.messageCounter}`; + } + + async updateEditRequestCards(messageNames: string[], request: EditRequestView): Promise { + if (this.failEditCards) throw new Error('Chat refused the card'); + this.editCardUpdates.push({ messageNames, request }); + } + + async sendEditApproved(userName: string, request: EditRequestView): Promise { + if (this.failEditCards) throw new Error('Chat refused the card'); + this.dms.push({ kind: 'editApproved', userName, editRequest: request }); + } + async canDm(userName: string): Promise { return !this.unreachable.has(userName); } diff --git a/src/adapters/gchat/adapter.ts b/src/adapters/gchat/adapter.ts index 60dc111..a1a548f 100644 --- a/src/adapters/gchat/adapter.ts +++ b/src/adapters/gchat/adapter.ts @@ -3,10 +3,13 @@ import type { ChatAdapter, PromptState, SpaceInfo, SpaceMember, SpaceMembership import type { SettingsService } from '../../core/settings.js'; import { chatEndpointUrl } from './addon.js'; import type { TimeOffRequest } from '../../core/schedule.js'; +import type { EditRequestView } from '../../core/edit-requests.js'; import type { Repo } from '../../db/repo.js'; import type { Blocker, Poll, Run, RunSummary, Standup, Submission } from '../../core/types.js'; import { blockerCard, + editApprovedCard, + editRequestCard, timeOffRequestCard, pollMessage, promptMessage, @@ -126,6 +129,23 @@ export class GoogleChatAdapter implements ChatAdapter { await (await this.getClient()).spaces.messages.create({ parent: dm, requestBody: timeOffRequestCard(request, await this.endpoint()) }); } + async sendEditRequest(managerUserName: string, request: EditRequestView): Promise { + const dm = await this.ensureDmSpace(managerUserName); + const res = await (await this.getClient()).spaces.messages.create({ parent: dm, requestBody: editRequestCard(request, await this.endpoint()) }); + return res.data.name ?? null; + } + + async updateEditRequestCards(messageNames: string[], request: EditRequestView): Promise { + const client = await this.getClient(); + const requestBody = editRequestCard(request, await this.endpoint()); + for (const name of messageNames) await client.spaces.messages.update({ name, updateMask: 'cardsV2', requestBody }); + } + + async sendEditApproved(userName: string, request: EditRequestView): Promise { + const dm = await this.ensureDmSpace(userName); + await (await this.getClient()).spaces.messages.create({ parent: dm, requestBody: editApprovedCard(request, await this.endpoint()) }); + } + async canDm(userName: string): Promise { try { await this.ensureDmSpace(userName); diff --git a/src/adapters/gchat/cards.ts b/src/adapters/gchat/cards.ts index c6b9e43..5d61300 100644 --- a/src/adapters/gchat/cards.ts +++ b/src/adapters/gchat/cards.ts @@ -1,4 +1,5 @@ import type { PromptState } from '../../core/adapter.js'; +import { dayLabel, untilLabel, type EditRequestView } from '../../core/edit-requests.js'; import { describeDates, type TimeOffRequest } from '../../core/schedule.js'; import { DateTime } from 'luxon'; import { LIMITS } from '../../core/validation.js'; @@ -30,6 +31,11 @@ export const VOTE_POLL_FN = 'votePoll'; export const APPROVE_REQUEST_FN = 'approveTimeOff'; export const DECLINE_REQUEST_FN = 'declineTimeOff'; export const SUBMIT_DECLINE_FN = 'submitDeclineTimeOff'; +export const OPEN_EDIT_REQUEST_FN = 'openEditRequest'; +export const SUBMIT_EDIT_REQUEST_FN = 'submitEditRequest'; +export const APPROVE_EDIT_FN = 'approveEdit'; +export const DECLINE_EDIT_FN = 'declineEdit'; +export const SUBMIT_EDIT_DECLINE_FN = 'submitDeclineEdit'; function humanDate(isoDate: string): string { return DateTime.fromISO(isoDate).toFormat('ccc, dd LLL yyyy'); @@ -78,10 +84,16 @@ function promptBody(standup: Standup, run: Run, state: PromptState, endpoint: st const due = `${standup.deadlineTime} ${standup.timezone}`; if (state.kind === 'submitted') { const at = DateTime.fromISO(state.at).setZone(standup.timezone).toFormat('HH:mm'); - return [ - { textParagraph: { text: `✅ Submitted at ${at}${state.late ? ' (late)' : ''}. You can edit your answers until ${due}.` } }, - { buttonList: { buttons: [{ text: 'Edit answers', onClick: { action: clickAction(OPEN_DIALOG_FN, param, endpoint, 'OPEN_DIALOG') } }] } }, - ]; + const done = `✅ Submitted at ${at}${state.late ? ' (late)' : ''}.`; + const button = (text: string, fn: string) => ({ buttonList: { buttons: [{ text, onClick: { action: clickAction(fn, param, endpoint, 'OPEN_DIALOG') } }] } }); + const line = (text: string) => ({ textParagraph: { text: `${done} ${text}` } }); + if (!state.closed) return [line(`You can edit your answers until ${due}.`), button('Edit answers', OPEN_DIALOG_FN)]; + const edit = state.edit; + if (!edit) return [line('The deadline has passed. Need to change something? Ask a manager.'), button('Ask to edit', OPEN_EDIT_REQUEST_FN)]; + if (edit.status === 'approved') return [line(`${html(edit.by!)} approved your request: you can edit your answers until ${edit.until}.`), button('Edit answers', OPEN_DIALOG_FN)]; + if (edit.status === 'pending') return [line('Your request to edit is waiting for a manager.')]; + if (edit.status === 'declined') return [line(`${html(edit.by!)} declined your request to edit.`)]; + return [line('Your request to edit lapsed.')]; } if (state.kind === 'removed') { return [{ textParagraph: { text: 'You’re no longer on this standup, so there’s nothing to answer today.' } }]; @@ -438,9 +450,10 @@ export function threadParentText(standup: Standup, run: Run): string { /** One participant's answers, posted (or updated) as a reply in the day's thread. */ /** Mood is personal, so it never appears here. */ export function submissionMessage(submission: Submission) { + const approved = submission.editApprovedBy ? `${submission.late ? 'edited' : 'Edited'} after the deadline · approved by ${submission.editApprovedBy}` : null; const flags = [ submission.late ? 'Submitted late' : null, - submission.editedAt ? 'edited' : null, + approved ?? (submission.editedAt ? 'edited' : null), ].filter(Boolean); return { @@ -536,7 +549,7 @@ export function timeOffRequestCard(request: TimeOffRequest, endpoint: string | n } /** Dialog asking the manager for a one-line reason before declining. */ -export function declineDialog(requestIds: string, endpoint: string | null = null) { +export function declineDialog(requestIds: string, endpoint: string | null = null, target = { fn: SUBMIT_DECLINE_FN, key: 'requestIds' }) { return { actionResponse: { type: 'DIALOG', @@ -549,7 +562,7 @@ export function declineDialog(requestIds: string, endpoint: string | null = null { textInput: { name: 'note', label: 'Why not? (sent to the person)', type: 'MULTIPLE_LINE' } }, { buttonList: { - buttons: [{ text: 'Decline request', onClick: { action: clickAction(SUBMIT_DECLINE_FN, [{ key: 'requestIds', value: requestIds }], endpoint) } }], + buttons: [{ text: 'Decline request', onClick: { action: clickAction(target.fn, [{ key: target.key, value: requestIds }], endpoint) } }], }, }, ], @@ -561,3 +574,102 @@ export function declineDialog(requestIds: string, endpoint: string | null = null }, }; } + +/** Asks why before someone's request to edit goes to their managers. */ +export function editRequestDialog(standup: Standup, run: Run, endpoint: string | null = null, again: { error?: string; reason?: string } = {}) { + return { + actionResponse: { + type: 'DIALOG', + dialogAction: { + dialog: { + body: { + sections: [ + { + widgets: [ + ...(again.error ? [errorLine(again.error)] : []), + { textParagraph: { text: `Ask to edit · ${html(standup.name)} · ${dayLabel(run.date)}
Your managers decide. If they approve, you can edit until ${untilLabel(standup)}.` } }, + { + textInput: { + name: 'reason', + label: 'Why? (optional)', + type: 'SINGLE_LINE', + value: again.reason || undefined, + hintText: `Sent to your managers with the request. Up to ${LIMITS.textMax} characters.`, + }, + }, + { buttonList: { buttons: [{ text: 'Send request', onClick: { action: clickAction(SUBMIT_EDIT_REQUEST_FN, [{ key: 'runId', value: String(run.id) }], endpoint) } }] } }, + ], + }, + ], + }, + }, + }, + }, + }; +} + +/** DM card for a manager: someone asks to edit their answers after the deadline; redrawn once decided or lapsed. */ +export function editRequestCard(request: EditRequestView, endpoint: string | null = null) { + const zone = request.standup.timezone; + const time = (iso: string | null) => DateTime.fromISO(iso!).setZone(zone).toFormat('HH:mm'); + const name = request.person.displayName; + const reason = request.reason ? `“${html(request.reason)}” ` : ''; + const params = [{ key: 'editRequestId', value: String(request.id) }]; + const outcome: Record = { + pending: `${reason}Approve and ${name} can edit their answers until ${request.until}. Decline and ${name} is told, with your note. Unanswered by then, it lapses.`, + approved: `${reason}✅ Approved by ${request.decidedBy} at ${time(request.decidedAt)}. ${name} can edit until ${request.until}.`, + declined: `${reason}❌ Declined by ${request.decidedBy} at ${time(request.decidedAt)}.${request.note ? ` “${html(request.note)}”` : ''}`, + lapsed: `${reason}⌛ Lapsed: not decided by ${request.until}.`, + }; + return { + cardsV2: [ + { + cardId: `edit-request-${request.id}`, + card: { + header: { title: `Edit request · ${request.standup.name}`, subtitle: `${name} · ${dayLabel(request.date)} · answered at ${time(request.answeredAt)}` }, + sections: [ + { + widgets: [ + { textParagraph: { text: outcome[request.status] } }, + ...(request.status === 'pending' + ? [ + { + buttonList: { + buttons: [ + { text: 'Approve', onClick: { action: clickAction(APPROVE_EDIT_FN, params, endpoint) } }, + { text: 'Decline', onClick: { action: clickAction(DECLINE_EDIT_FN, params, endpoint, 'OPEN_DIALOG') } }, + ], + }, + }, + ] + : []), + ], + }, + ], + }, + }, + ], + }; +} + +/** DM card telling someone their edit was approved, with the way back into the form. */ +export function editApprovedCard(request: EditRequestView, endpoint: string | null = null) { + return { + cardsV2: [ + { + cardId: `edit-approved-${request.id}`, + card: { + header: { title: `📋 ${request.standup.name}`, subtitle: humanDate(request.date) }, + sections: [ + { + widgets: [ + { textParagraph: { text: `✅ ${html(request.decidedBy!)} approved your request. Edit your answers before ${request.until}.` } }, + { buttonList: { buttons: [{ text: 'Edit answers', onClick: { action: clickAction(OPEN_DIALOG_FN, [{ key: 'runId', value: String(request.runId) }], endpoint, 'OPEN_DIALOG') } }] } }, + ], + }, + ], + }, + }, + ], + }; +} diff --git a/src/adapters/gchat/events.ts b/src/adapters/gchat/events.ts index 4ace105..91c1bc9 100644 --- a/src/adapters/gchat/events.ts +++ b/src/adapters/gchat/events.ts @@ -5,6 +5,7 @@ import type { PromptState } from '../../core/adapter.js'; import type { CommandHandler, Mention } from '../../core/commands.js'; import type { PollService } from '../../core/poll-service.js'; import type { StandupService } from '../../core/standup-service.js'; +import { closedText } from '../../core/edit-requests.js'; import { describeDates, parseDateSpec, type ScheduleService } from '../../core/schedule.js'; import type { Repo } from '../../db/repo.js'; import { cleanSkipNote, LIMITS } from '../../core/validation.js'; @@ -17,7 +18,14 @@ import { } from '../../core/types.js'; import { ACK_BLOCKER_FN, + APPROVE_EDIT_FN, APPROVE_REQUEST_FN, + DECLINE_EDIT_FN, + editRequestCard, + editRequestDialog, + OPEN_EDIT_REQUEST_FN, + SUBMIT_EDIT_DECLINE_FN, + SUBMIT_EDIT_REQUEST_FN, DECLINE_REQUEST_FN, SUBMIT_DECLINE_FN, declineDialog, @@ -219,14 +227,22 @@ export class EventRouter { const runId = Number(getParameter(event, 'runId')); const user = eventUser(event); - if (fn === OPEN_DIALOG_FN) { + if (fn === OPEN_DIALOG_FN || fn === OPEN_EDIT_REQUEST_FN) { if (!Number.isInteger(runId)) return dialogError('This standup prompt is no longer valid.'); const run = await this.repo.getRunById(runId); if (!run) return dialogError('This standup prompt is no longer valid.'); const standup = (await this.repo.getStandupById(run.standupId))!; + const existing = await this.repo.getSubmission(run.id, user.userName); + if (run.status === 'closed') { + if (this.service.edits.dayOver(standup, run)) return dialogError(closedText(run)); + // After the deadline an answer changes only with a manager's approval; until then, offer to ask. + if (existing && !(await this.service.edits.canEdit(standup, run, user.userName))) { + const refusal = await this.service.edits.askRefusal(standup, run, user.userName); + return refusal ? dialogError(refusal) : editRequestDialog(standup, run, await this.endpoint()); + } + } const prefill = await this.service.getPrefill(standup, run, user.userName); // Editing keeps the mood and its comment as they were. - const existing = await this.repo.getSubmission(run.id, user.userName); return standupDialog(runId, standupQuestions(standup), standup.moodEnabled, prefill, await this.endpoint(), { mood: existing?.mood ?? undefined, moodComment: existing?.moodComment ?? undefined }); } @@ -234,6 +250,31 @@ export class EventRouter { return this.onDialogSubmit(event, runId, user); } + if (fn === SUBMIT_EDIT_REQUEST_FN) { + const run = Number.isInteger(runId) ? await this.repo.getRunById(runId) : null; + if (!run) return dialogError('This standup prompt is no longer valid.'); + const reason = getFormValue(event, 'reason').trim(); + if (reason.length > LIMITS.textMax) { + const standup = (await this.repo.getStandupById(run.standupId))!; + return editRequestDialog(standup, run, await this.endpoint(), { error: `Keep it to ${LIMITS.textMax} characters.`, reason }); + } + const result = await this.service.edits.ask(run.id, user, reason || null); + return result.ok ? dialogOk(result.message) : dialogError(result.message); + } + + if (fn === APPROVE_EDIT_FN || fn === DECLINE_EDIT_FN || fn === SUBMIT_EDIT_DECLINE_FN) { + const id = Number(getParameter(event, 'editRequestId')) || 0; + if (fn === DECLINE_EDIT_FN) return declineDialog(String(id), await this.endpoint(), { fn: SUBMIT_EDIT_DECLINE_FN, key: 'editRequestId' }); + const actor = { userName: user.userName, displayName: user.displayName, admin: false }; + if (fn === APPROVE_EDIT_FN) { + const result = await this.service.edits.decide(id, true, actor, null); + // The clicked card shows the outcome, whoever decided first. + return result.request ? { actionResponse: { type: 'UPDATE_MESSAGE' }, ...editRequestCard(result.request, await this.endpoint()) } : { text: result.message }; + } + const result = await this.service.edits.decide(id, false, actor, getFormValue(event, 'note').trim() || null); + return result.ok ? dialogOk(result.message) : dialogError(result.message); + } + if (fn === OPEN_SKIP_FN) return skipDialog(runId, await this.endpoint()); if (fn === SKIP_TODAY_FN || fn === UNSKIP_FN) { @@ -376,7 +417,8 @@ export class EventRouter { ); } const messages: Record = { - already_submitted: 'This run is closed — late submissions can no longer be edited.', + already_submitted: 'The deadline has passed. Ask a manager to let you edit, from your prompt card.', + day_closed: closedText(run), run_not_found: 'This standup form is no longer valid.', not_a_participant: "You're not on this standup's roster — ask your admin to `add` you.", }; diff --git a/src/api/api.ts b/src/api/api.ts index 272ca2b..fdb1f0c 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -13,6 +13,7 @@ import type { RosterSync } from '../core/roster-sync.js'; import type { ScheduleService } from '../core/schedule.js'; import type { Repo } from '../db/repo.js'; import { registerBlockerRoutes } from './blockers.js'; +import { registerEditRequestRoutes } from './edit-requests.js'; import { registerMcpRoutes } from './mcp.js'; import { registerMemberRoutes } from './member.js'; import { registerScheduleRoutes } from './schedule.js'; @@ -125,6 +126,7 @@ export function registerApi(app: Express, deps: ApiDeps): void { registerPeopleRoutes(api, ctx); registerMemberRoutes(api, ctx); registerScheduleRoutes(api, ctx); + registerEditRequestRoutes(api, ctx); registerSettingsRoutes(api, ctx); registerSpaceRoutes(api, ctx); registerMcpRoutes(api, ctx); diff --git a/src/api/edit-requests.ts b/src/api/edit-requests.ts new file mode 100644 index 0000000..155c81b --- /dev/null +++ b/src/api/edit-requests.ts @@ -0,0 +1,64 @@ +import type { Response, Router } from 'express'; +import type { EditRequestView } from '../core/edit-requests.js'; +import { canSomewhere } from '../core/permissions.js'; +import { allowed, apiError, principalOf, type ApiContext } from './shared.js'; + +export function editRequestView(r: EditRequestView) { + return { + id: r.id, + standupId: r.standup.id, + standupName: r.standup.name, + date: r.date, + userName: r.person.userName, + displayName: r.person.displayName, + reason: r.reason, + askedAt: r.askedAt, + answeredAt: r.answeredAt, + until: r.until, + }; +} + +/** Requests to edit answers after the deadline: whoever configures a standup decides its requests. */ +export function registerEditRequestRoutes(api: Router, ctx: ApiContext): void { + const service = (res: Response) => { + if (!ctx.service) apiError(res, 503, 'unavailable', 'Answers are not available on this install.'); + return ctx.service; + }; + + api.get('/edit-requests', async (req, res) => { + const svc = service(res); + if (!svc) return; + const p = principalOf(req); + if (!canSomewhere(p.access, 'standups.configure')) { + apiError(res, 403, 'forbidden', 'Only admins and managers see requests.'); + return; + } + const mine = (await svc.edits.listPending()).filter((r) => r.standup.tenantId === p.tenantId && allowed(p, 'standups.configure', r.standup.id)); + res.json({ requests: mine.map(editRequestView) }); + }); + + for (const decision of ['approve', 'decline'] as const) { + api.post(`/edit-requests/:id/${decision}`, async (req, res) => { + const svc = service(res); + if (!svc) return; + const p = principalOf(req); + const request = await svc.edits.getView(Number(req.params.id) || 0); + if (!request || request.standup.tenantId !== p.tenantId) { + apiError(res, 404, 'not_found', 'No such request.'); + return; + } + if (!allowed(p, 'standups.configure', request.standup.id)) { + apiError(res, 403, 'forbidden', `Only the managers of ${request.standup.name} decide this.`); + return; + } + const note = req.body?.note ? String(req.body.note).trim().slice(0, 200) || null : null; + const actor = { userName: p.user?.userName ?? 'operator', displayName: p.user?.name ?? 'Operator', admin: true }; + const result = await svc.edits.decide(request.id, decision === 'approve', actor, note); + if (!result.ok) { + apiError(res, 409, 'refused', result.message); + return; + } + res.json({ ok: true, message: result.message }); + }); + } +} diff --git a/src/api/spec/routes.ts b/src/api/spec/routes.ts index f56d5e5..0787206 100644 --- a/src/api/spec/routes.ts +++ b/src/api/spec/routes.ts @@ -189,6 +189,21 @@ export const ROUTES: RouteSpec[] = [ errors: [409, 503], }, + // asking to edit answers after the deadline + { method: 'get', path: '/edit-requests', summary: 'Requests to edit answers after the deadline, waiting for a decision', tag: 'Runs', access: 'manager', permission: 'standups.configure', responses: { 200: S.EditRequests }, errors: [503] }, + ...(['approve', 'decline'] as const).map((decision) => ({ + method: 'post' as const, + path: `/edit-requests/:id/${decision}`, + summary: decision === 'approve' ? 'Let someone edit their answers until midnight' : 'Decline a request to edit answers', + tag: 'Runs', + access: 'manager' as const, + permission: 'standups.configure' as const, + params: { id: { schema: z.string(), description: 'Edit request id' } }, + body: S.DecisionBody, + responses: { 200: S.Decision }, + errors: [404, 409, 503], + })), + // settings { method: 'get', path: '/settings', summary: 'Workspace settings', tag: 'Settings', access: 'admin', permission: 'settings.manage', responses: { 200: S.Settings } }, { method: 'patch', path: '/settings', summary: 'Change workspace settings', tag: 'Settings', access: 'admin', permission: 'settings.manage', body: S.SettingsPatch, responses: { 200: S.Settings }, errors: [400, 409] }, diff --git a/src/api/spec/schemas.ts b/src/api/spec/schemas.ts index 5c4e58f..0789b74 100644 --- a/src/api/spec/schemas.ts +++ b/src/api/spec/schemas.ts @@ -376,6 +376,24 @@ export const ScheduleChange = named( export const TimeOffRequests = named('TimeOffRequests', z.strictObject({ requests: z.array(Override) })); export const Decision = named('Decision', z.strictObject({ ok: z.literal(true), message: z.string() })); +export const EditRequest = named( + 'EditRequest', + z.strictObject({ + id, + standupId: id, + standupName: z.string(), + date: z.string(), + userName: z.string(), + displayName: z.string(), + reason: z.string().nullable(), + askedAt: z.string(), + answeredAt: z.string(), + until: z.string(), + }), + 'Someone asking to change their answers after the deadline. `until` is when an approved edit, or the request, ends: midnight in the standup’s timezone.', +); +export const EditRequests = named('EditRequests', z.strictObject({ requests: z.array(EditRequest) })); + // --- settings, verification and MCP const secret = z.strictObject({ set: z.boolean() }); @@ -638,6 +656,7 @@ export type MyStandups = z.infer; export type MySubmissions = z.infer; export type Override = z.infer; export type Schedule = z.infer; +export type EditRequest = z.infer; export type Settings = z.infer; export type Verification = z.infer; export type McpToken = z.infer; diff --git a/src/core/adapter.ts b/src/core/adapter.ts index 9e45923..e9605ec 100644 --- a/src/core/adapter.ts +++ b/src/core/adapter.ts @@ -1,5 +1,6 @@ import type { Blocker, Poll, Run, RunSummary, Standup, Submission } from './types.js'; import type { TimeOffRequest } from './schedule.js'; +import type { EditRequestView, EditState } from './edit-requests.js'; /** A space the app has been added to. */ export interface SpaceInfo { @@ -24,7 +25,11 @@ export interface SpaceMembership { * APIs directly — only this interface. New platforms = new implementation. */ /** Where someone stands on a run, as their prompt cards show it. */ -export type PromptState = { kind: 'open' } | { kind: 'submitted'; at: string; late: boolean } | { kind: 'skipped'; note: string | null } | { kind: 'removed' }; +export type PromptState = + | { kind: 'open' } + | { kind: 'submitted'; at: string; late: boolean; closed?: boolean; edit?: EditState | null } + | { kind: 'skipped'; note: string | null } + | { kind: 'removed' }; export interface ChatAdapter { /** DM the user a prompt with a way to fill in the standup form; returns the card's message name when known. */ @@ -72,6 +77,15 @@ export interface ChatAdapter { /** DM a manager a time-off request with Approve / Decline. */ sendTimeOffRequest(managerUserName: string, request: TimeOffRequest): Promise; + /** DM a manager a request to edit answers after the deadline; returns the card's message name when known. */ + sendEditRequest(managerUserName: string, request: EditRequestView): Promise; + + /** Redraw the managers' cards for a request once it is decided or lapses. */ + updateEditRequestCards(messageNames: string[], request: EditRequestView): Promise; + + /** Tell someone their edit was approved, with a way to edit. */ + sendEditApproved(userName: string, request: EditRequestView): Promise; + /** * Whether the platform can DM this user right now (e.g. the Chat app is * installed for them). Lets `add` warn immediately instead of failing diff --git a/src/core/edit-requests.ts b/src/core/edit-requests.ts new file mode 100644 index 0000000..20dadbc --- /dev/null +++ b/src/core/edit-requests.ts @@ -0,0 +1,197 @@ +import { DateTime } from 'luxon'; +import type { ChatAdapter } from './adapter.js'; +import type { Mention } from './commands.js'; +import { can, resolveAccess } from './permissions.js'; +import type { Actor } from './schedule.js'; +import type { StandupService } from './standup-service.js'; +import type { EditRequest, EditRequestStatus, Run, Standup } from './types.js'; +import type { Repo } from '../db/repo.js'; + +/** A request with what its cards and the web app show around it. */ +export interface EditRequestView extends Omit { + standup: Standup; + date: string; + person: Mention; + /** When the answer was first posted. */ + answeredAt: string; + /** When the window closes, as people read it: "23:59 Europe/London". */ + until: string; +} + +/** How a prompt card shows someone's request, if they made one. */ +export interface EditState { + status: EditRequestStatus; + by: string | null; + until: string; +} + +export type EditResult = { ok: true; message: string; request?: EditRequestView } | { ok: false; message: string; request?: EditRequestView }; + +/** Midnight after the run's day in the standup's timezone: late answers and approved edits stop then. */ +export function dayEnd(standup: Standup, run: Run): DateTime { + return DateTime.fromISO(run.date, { zone: standup.timezone }).plus({ days: 1 }).startOf('day'); +} + +export const untilLabel = (standup: Standup) => `23:59 ${standup.timezone}`; +export const dayLabel = (date: string) => DateTime.fromISO(date).setLocale('en-GB').toFormat('ccc d LLL'); +export const closedText = (run: Run) => `This standup has closed. Answers for ${dayLabel(run.date)} can no longer be added.`; + +/** + * Changing answers after the deadline, with a manager's approval. Asking + * sends the standup's managers a card; the first decision wins and redraws + * every card. Approved edits, and undecided requests, end at midnight. + */ +export class EditRequests { + constructor( + private repo: Repo, + private adapter: ChatAdapter, + private service: StandupService, + private now: () => DateTime, + private log: (msg: string) => void, + ) {} + + dayOver(standup: Standup, run: Run): boolean { + return this.now() >= dayEnd(standup, run); + } + + /** Whether an answer to a closed run may change now. */ + async canEdit(standup: Standup, run: Run, userName: string): Promise { + if (this.dayOver(standup, run)) return false; + return (await this.repo.getEditRequestFor(run.id, userName))?.status === 'approved'; + } + + /** Why someone can't ask, or null when they can. */ + async askRefusal(standup: Standup, run: Run, userName: string): Promise { + if (this.dayOver(standup, run)) return closedText(run); + if (run.status === 'open') return 'The deadline hasn’t passed, so you can still edit your answers.'; + if (!(await this.repo.getSubmission(run.id, userName))) return 'You haven’t answered yet, so there is nothing to edit. Answers can still be added late, until midnight.'; + const existing = await this.repo.getEditRequestFor(run.id, userName); + if (!existing) return null; + if (existing.status === 'pending') return 'Your request is waiting for a manager.'; + if (existing.status === 'approved') return `${existing.decidedBy} approved your request. Edit your answers before ${untilLabel(standup)}.`; + return `${existing.decidedBy} declined your request, so you can’t ask again for ${dayLabel(run.date)}.`; + } + + async ask(runId: number, person: Mention, reason: string | null): Promise { + const run = await this.repo.getRunById(runId); + if (!run) return { ok: false, message: 'This standup prompt is no longer valid.' }; + const standup = (await this.repo.getStandupById(run.standupId))!; + const refusal = await this.askRefusal(standup, run, person.userName); + if (refusal) return { ok: false, message: refusal }; + const request = await this.repo.createEditRequest({ runId: run.id, userName: person.userName, displayName: person.displayName, reason: reason?.trim() || null, askedAt: this.now().toISO()! }); + const view = await this.view(request); + const managers = (await this.repo.listAdmins(standup.id)).filter((a) => a.userName !== person.userName); + const cards: string[] = []; + for (const manager of managers) { + try { + const name = await this.adapter.sendEditRequest(manager.userName, view); + if (name) cards.push(name); + } catch (err) { + this.log(`edit request to ${manager.userName} failed: ${err}`); + } + } + await this.repo.setEditRequestCards(request.id, cards); + await this.service.refreshCards(standup, run, person.userName); + return { + ok: true, + message: managers.length ? '📨 Sent to your managers. You’ll hear back here.' : '📨 Sent. Nobody else manages this standup in Chat, so an admin decides in the web app.', + }; + } + + /** + * A manager approves or declines. `actor.admin` means the caller's right to + * decide was already checked (the API does that); nobody decides their own. + */ + async decide(id: number, approve: boolean, actor: Actor, note: string | null): Promise { + const request = await this.repo.getEditRequest(id); + if (!request) return { ok: false, message: 'That request no longer exists.' }; + const run = (await this.repo.getRunById(request.runId))!; + const standup = (await this.repo.getStandupById(run.standupId))!; + if (request.status === 'pending' && this.dayOver(standup, run)) await this.lapseOne(request, standup, run); + const settled = await this.settled(id); + if (settled) return settled; + if (request.userName === actor.userName) return { ok: false, message: 'Your own requests go to your managers.' }; + if (!actor.admin) { + const access = await resolveAccess(this.repo, { tenantId: standup.tenantId, userName: actor.userName }, false); + if (!can(access, 'standups.configure', standup.id)) return { ok: false, message: `Only the managers of ${standup.name} can decide this.` }; + } + const decided = await this.repo.decideEditRequest(id, approve ? 'approved' : 'declined', this.now().toISO()!, actor, note); + if (!decided) return (await this.settled(id))!; + const view = await this.view((await this.repo.getEditRequest(id))!); + await this.redraw(request, view); + try { + if (approve) await this.adapter.sendEditApproved(request.userName, view); + else await this.adapter.sendDm(request.userName, `❌ ${actor.displayName} declined your request to edit your answers for ${dayLabel(run.date)}.${note ? ` “${note}”` : ''}`); + } catch (err) { + this.log(`telling ${request.userName} about their edit request failed: ${err}`); + } + await this.service.refreshCards(standup, run, request.userName); + const message = approve ? `✅ Approved. ${request.displayName} can edit until ${untilLabel(standup)}.` : `❌ Declined. ${request.displayName} has been told.`; + return { ok: true, message, request: view }; + } + + /** Requests nobody decided before midnight lapse; safe to run every tick. */ + async lapse(): Promise { + for (const request of await this.repo.listPendingEditRequests()) { + const run = await this.repo.getRunById(request.runId); + if (!run) continue; + const standup = (await this.repo.getStandupById(run.standupId))!; + if (this.dayOver(standup, run)) await this.lapseOne(request, standup, run); + } + } + + /** Pending requests, newest first, for the web app and API. */ + async listPending(): Promise { + const views: EditRequestView[] = []; + for (const request of await this.repo.listPendingEditRequests()) views.push(await this.view(request)); + return views; + } + + async getView(id: number): Promise { + const request = await this.repo.getEditRequest(id); + return request ? this.view(request) : null; + } + + /** How someone's prompt card shows their request. */ + async stateFor(standup: Standup, run: Run, userName: string): Promise { + const request = await this.repo.getEditRequestFor(run.id, userName); + return request ? { status: request.status, by: request.decidedBy, until: untilLabel(standup) } : null; + } + + private async lapseOne(request: EditRequest, standup: Standup, run: Run): Promise { + if (!(await this.repo.decideEditRequest(request.id, 'lapsed', this.now().toISO()!, null, null))) return; + await this.redraw(request, await this.view((await this.repo.getEditRequest(request.id))!)); + try { + await this.adapter.sendDm(request.userName, `⌛ Your request to edit your answers for ${dayLabel(run.date)} wasn’t answered that day, so it lapsed. Your answers stay as they were.`); + } catch (err) { + this.log(`telling ${request.userName} their edit request lapsed failed: ${err}`); + } + await this.service.refreshCards(standup, run, request.userName); + } + + /** The answer for a request that is no longer pending, or null while it is. */ + private async settled(id: number): Promise { + const request = (await this.repo.getEditRequest(id))!; + if (request.status === 'pending') return null; + const view = await this.view(request); + if (request.status === 'lapsed') return { ok: false, message: 'That request lapsed at midnight.', request: view }; + return { ok: false, message: `${request.decidedBy} already ${request.status} this request.`, request: view }; + } + + private async redraw(request: EditRequest, view: EditRequestView): Promise { + if (request.cardMessages.length === 0) return; + try { + await this.adapter.updateEditRequestCards(request.cardMessages, view); + } catch (err) { + this.log(`redrawing edit request ${request.id} failed: ${err}`); + } + } + + private async view(request: EditRequest): Promise { + const run = (await this.repo.getRunById(request.runId))!; + const standup = (await this.repo.getStandupById(run.standupId))!; + const submission = await this.repo.getSubmission(run.id, request.userName); + const { userName, displayName, cardMessages: _cards, ...rest } = request; + return { ...rest, standup, date: run.date, person: { userName, displayName }, answeredAt: submission?.submittedAt ?? request.askedAt, until: untilLabel(standup) }; + } +} diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index 87061f9..b342131 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -76,6 +76,11 @@ export class Scheduler { this.log(`standup ${standup.id} (${standup.name}): ${err}`); } } + try { + await this.service.edits.lapse(); + } catch (err) { + this.log(`edit requests: ${err}`); + } try { await this.providers.leave?.sweep(); } catch (err) { @@ -248,6 +253,7 @@ export class Scheduler { private async closeRun(standup: Standup, run: Run): Promise { await this.repo.closeRun(run.id); this.log(`closed run ${run.id} for "${standup.name}" ${run.date}`); + await this.service.refreshClosedCards(standup, run); try { await this.providers.schedule?.onRunClosed(standup, run); } catch (err) { diff --git a/src/core/standup-service.ts b/src/core/standup-service.ts index 82ae92b..04eeafa 100644 --- a/src/core/standup-service.ts +++ b/src/core/standup-service.ts @@ -2,6 +2,7 @@ import { DateTime } from 'luxon'; import type { ChatAdapter, PromptState } from './adapter.js'; import type { WebhookNotifier } from './webhooks.js'; import type { Repo } from '../db/repo.js'; +import { EditRequests } from './edit-requests.js'; import { runProgress } from './progress.js'; import { roundMood } from './insights.js'; import { @@ -18,7 +19,7 @@ import { export type SubmitResult = | { ok: true; late: boolean; edited: boolean } - | { ok: false; reason: 'already_submitted' | 'run_not_found' | 'not_a_participant' }; + | { ok: false; reason: 'already_submitted' | 'day_closed' | 'run_not_found' | 'not_a_participant' }; export type SkipResult = 'skipped' | 'already_submitted' | 'not_found'; export type UnskipResult = 'unskipped' | 'not_skipped' | 'closed' | 'not_found'; @@ -30,12 +31,18 @@ export class StandupService { private now: () => DateTime = () => DateTime.utc(), private webhooks: WebhookNotifier | null = null, private log: (msg: string) => void = () => {}, - ) {} + ) { + this.edits = new EditRequests(repo, adapter, this, now, (msg) => this.log(msg)); + } + + /** Asking to edit answers after the deadline. */ + readonly edits: EditRequests; /** * Records a dialog submission and posts it under the day's thread. * Re-submitting while the run is open edits the existing card in place; - * submissions after the deadline still post, flagged as late. + * after the deadline that needs a manager's approval. A first answer after + * the deadline still posts, flagged as late. Both stop at midnight. */ async submit( runId: number, @@ -53,15 +60,22 @@ export class StandupService { const standup = (await this.repo.getStandupById(run.standupId))!; const existing = await this.repo.getSubmission(run.id, userName); + if (run.status === 'closed' && this.edits.dayOver(standup, run)) return { ok: false, reason: 'day_closed' }; if (existing) { - if (run.status === 'closed') return { ok: false, reason: 'already_submitted' }; + let approvedBy = existing.editApprovedBy; + if (run.status === 'closed') { + const request = await this.repo.getEditRequestFor(run.id, userName); + if (request?.status !== 'approved') return { ok: false, reason: 'already_submitted' }; + approvedBy = request.decidedBy; + } const updated = await this.repo.updateSubmission( existing.id, input.answers, input.mood, input.moodComment ?? null, this.now().toISO()!, + approvedBy, ); await this.repo.deleteBlockersOpenedBy(run.id, userName); await this.trackBlockers(standup, run, userName, displayName, input, { resolveOthers: false }); @@ -92,13 +106,14 @@ export class StandupService { } /** Rewrites the prompt, reminder and nudge cards a person was sent, to show they submitted, skipped or are still due. */ - private async refreshCards(standup: Standup, run: Run, userName: string): Promise { + async refreshCards(standup: Standup, run: Run, userName: string): Promise { const names = await this.repo.listCardMessages(run.id, userName); if (names.length === 0) return; const submission = await this.repo.getSubmission(run.id, userName); const rp = (await this.repo.listRunParticipants(run.id)).find((p) => p.userName === userName); + const closed = (await this.repo.getRunById(run.id))?.status === 'closed'; const state: PromptState = submission - ? { kind: 'submitted', at: submission.submittedAt, late: submission.late } + ? { kind: 'submitted', at: submission.submittedAt, late: submission.late, ...(closed ? { closed, edit: await this.edits.stateFor(standup, run, userName) } : {}) } : rp?.skippedAt ? { kind: 'skipped', note: rp.skipNote } : { kind: 'open' }; @@ -109,6 +124,11 @@ export class StandupService { } } + /** At the deadline, everyone who answered sees how to ask for an edit. */ + async refreshClosedCards(standup: Standup, run: Run): Promise { + for (const submission of await this.repo.listSubmissions(run.id)) await this.refreshCards(standup, run, submission.userName); + } + private async trackBlockers( standup: Standup, run: Run, diff --git a/src/core/types.ts b/src/core/types.ts index f5ffd34..41ba6e6 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -226,6 +226,27 @@ export interface Submission extends SubmissionInput { /** Chat message resource name of the posted card (for edits). */ messageName: string | null; moodComment: string | null; + /** Who let this answer change after the deadline, if anyone did. */ + editApprovedBy: string | null; +} + +export type EditRequestStatus = 'pending' | 'approved' | 'declined' | 'lapsed'; + +/** Someone asking to change their answers after the deadline. */ +export interface EditRequest { + id: number; + runId: number; + userName: string; + displayName: string; + reason: string | null; + status: EditRequestStatus; + askedAt: string; + decidedAt: string | null; + decidedBy: string | null; + decidedByUser: string | null; + note: string | null; + /** The managers' request cards, so a decision can redraw them all. */ + cardMessages: string[]; } export interface Blocker { diff --git a/src/core/webhooks.ts b/src/core/webhooks.ts index bac1187..70bdd76 100644 --- a/src/core/webhooks.ts +++ b/src/core/webhooks.ts @@ -11,6 +11,8 @@ export type WebhookEvent = mood: string | null; late: boolean; edited: boolean; + /** Who let the answer change after the deadline, if anyone did. */ + editApprovedBy: string | null; } | { event: 'wrap_up'; @@ -61,6 +63,7 @@ export class WebhookNotifier { mood: submission.mood, late: submission.late, edited, + editApprovedBy: submission.editApprovedBy, }); } diff --git a/src/db/repo.ts b/src/db/repo.ts index 0e8927a..d3fdcf4 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -7,6 +7,8 @@ import type { BlockerTag, BlockerUpdate, CustomRole, + EditRequest, + EditRequestStatus, McpActivity, McpToken, Mood, @@ -447,6 +449,25 @@ CREATE TABLE leave_presence ( error_at TEXT ); CREATE INDEX idx_leave_presence_user ON leave_presence(user_name); +`, // 20 — asking to edit answers after the deadline + ` +CREATE TABLE edit_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL DEFAULT 'pending', + asked_at TEXT NOT NULL, + decided_at TEXT, + decided_by TEXT, + decided_by_user TEXT, + note TEXT, + card_messages TEXT, + UNIQUE (run_id, user_name) +); +CREATE INDEX idx_edit_requests_status ON edit_requests(status); +ALTER TABLE submissions ADD COLUMN edit_approved_by TEXT; `, ]; @@ -773,6 +794,25 @@ CREATE TABLE leave_presence ( error_at TEXT ); CREATE INDEX idx_leave_presence_user ON leave_presence(user_name); +`, // 20 — edit requests (mirrors the SQLite migration) + ` +CREATE TABLE edit_requests ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + run_id INTEGER NOT NULL, + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL DEFAULT 'pending', + asked_at TEXT NOT NULL, + decided_at TEXT, + decided_by TEXT, + decided_by_user TEXT, + note TEXT, + card_messages TEXT, + UNIQUE (run_id, user_name) +); +CREATE INDEX idx_edit_requests_status ON edit_requests(status); +ALTER TABLE submissions ADD COLUMN edit_approved_by TEXT; `, ]; @@ -919,6 +959,24 @@ function toSubmission(row: any): Submission { editedAt: row.edited_at ?? null, messageName: row.message_name ?? null, moodComment: row.mood_comment ?? null, + editApprovedBy: row.edit_approved_by ?? null, + }; +} + +function toEditRequest(row: any): EditRequest { + return { + id: row.id, + runId: row.run_id, + userName: row.user_name, + displayName: row.display_name, + reason: row.reason ?? null, + status: row.status, + askedAt: row.asked_at, + decidedAt: row.decided_at ?? null, + decidedBy: row.decided_by ?? null, + decidedByUser: row.decided_by_user ?? null, + note: row.note ?? null, + cardMessages: row.card_messages ? JSON.parse(row.card_messages) : [], }; } @@ -1715,17 +1773,59 @@ export class Repo { return (await this.getSubmissionById(id))!; } - async updateSubmission(id: number, answers: Answer[], mood: Mood | null, moodComment: string | null, editedAt: string): Promise { - await this.db.run('UPDATE submissions SET answers = ?, mood = ?, mood_comment = ?, edited_at = ? WHERE id = ?', [ + async updateSubmission(id: number, answers: Answer[], mood: Mood | null, moodComment: string | null, editedAt: string, editApprovedBy: string | null = null): Promise { + await this.db.run('UPDATE submissions SET answers = ?, mood = ?, mood_comment = ?, edited_at = ?, edit_approved_by = ? WHERE id = ?', [ JSON.stringify(answers), mood, moodComment, editedAt, + editApprovedBy, id, ]); return (await this.getSubmissionById(id))!; } + // --- edit requests --- + + async createEditRequest(input: { runId: number; userName: string; displayName: string; reason: string | null; askedAt: string }): Promise { + const id = await this.db.insert('INSERT INTO edit_requests (run_id, user_name, display_name, reason, asked_at) VALUES (?, ?, ?, ?, ?)', [ + input.runId, + input.userName, + input.displayName, + input.reason, + input.askedAt, + ]); + return (await this.getEditRequest(id))!; + } + + async getEditRequest(id: number): Promise { + const row = await this.db.get('SELECT * FROM edit_requests WHERE id = ?', [id]); + return row ? toEditRequest(row) : null; + } + + async getEditRequestFor(runId: number, userName: string): Promise { + const row = await this.db.get('SELECT * FROM edit_requests WHERE run_id = ? AND user_name = ?', [runId, userName]); + return row ? toEditRequest(row) : null; + } + + async listPendingEditRequests(): Promise { + const rows = await this.db.all(`SELECT * FROM edit_requests WHERE status = 'pending' ORDER BY asked_at DESC, id DESC`); + return rows.map(toEditRequest); + } + + /** Records a decision or a lapse; false when the request was no longer pending. */ + async decideEditRequest(id: number, status: Exclude, at: string, by: { userName: string; displayName: string } | null, note: string | null): Promise { + const result = await this.db.run( + `UPDATE edit_requests SET status = ?, decided_at = ?, decided_by = ?, decided_by_user = ?, note = ? WHERE id = ? AND status = 'pending'`, + [status, at, by?.displayName ?? null, by?.userName ?? null, note, id], + ); + return result.changes > 0; + } + + async setEditRequestCards(id: number, messageNames: string[]): Promise { + await this.db.run('UPDATE edit_requests SET card_messages = ? WHERE id = ?', [JSON.stringify(messageNames), id]); + } + async setSubmissionMessageName(id: number, messageName: string): Promise { await this.db.run('UPDATE submissions SET message_name = ? WHERE id = ?', [messageName, id]); } @@ -2198,6 +2298,7 @@ export class Repo { const b = await this.db.run(`DELETE FROM blockers WHERE standup_id IN (${standups})`, [tenantId]); await this.db.run(`DELETE FROM poll_votes WHERE poll_id IN (${polls})`, [tenantId]); const p = await this.db.run(`DELETE FROM polls WHERE standup_id IN (${standups})`, [tenantId]); + await this.db.run(`DELETE FROM edit_requests WHERE run_id IN (${runs})`, [tenantId]); const sub = await this.db.run(`DELETE FROM submissions WHERE run_id IN (${runs})`, [tenantId]); await this.db.run(`DELETE FROM run_participants WHERE run_id IN (${runs})`, [tenantId]); const r = await this.db.run(`DELETE FROM runs WHERE standup_id IN (${standups})`, [tenantId]); @@ -2220,6 +2321,7 @@ export class Repo { const b = await this.db.run('DELETE FROM blockers WHERE standup_id = ?', [standupId]); await this.db.run(`DELETE FROM poll_votes WHERE poll_id IN (${polls})`, [standupId]); const p = await this.db.run('DELETE FROM polls WHERE standup_id = ?', [standupId]); + await this.db.run(`DELETE FROM edit_requests WHERE run_id IN (${runs})`, [standupId]); const sub = await this.db.run(`DELETE FROM submissions WHERE run_id IN (${runs})`, [standupId]); await this.db.run(`DELETE FROM run_participants WHERE run_id IN (${runs})`, [standupId]); const r = await this.db.run('DELETE FROM runs WHERE standup_id = ?', [standupId]); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 396133e..979e983 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -533,8 +533,9 @@ export function buildMcpServer(deps: McpToolDeps, token: McpToken, p: Principal) return server; } -const SUBMIT_REASONS: Record<'run_not_found' | 'not_a_participant' | 'already_submitted', string> = { +const SUBMIT_REASONS: Record<'run_not_found' | 'not_a_participant' | 'already_submitted' | 'day_closed', string> = { run_not_found: 'Today’s run has not opened yet.', not_a_participant: 'You are not on this standup’s roster today.', - already_submitted: 'Today’s run is closed; your answers can no longer change.', + already_submitted: 'The deadline has passed. Ask a manager to let you edit, from your prompt card in Chat.', + day_closed: 'This standup has closed for the day.', }; diff --git a/tests/cards-more.test.ts b/tests/cards-more.test.ts index 255d1fc..ff37228 100644 --- a/tests/cards-more.test.ts +++ b/tests/cards-more.test.ts @@ -165,7 +165,7 @@ describe('cards (reminder, blocker, poll, thread and summary variants)', () => { late: false, submittedAt: '2026-06-10T04:30:00Z', editedAt: null, - messageName: null, moodComment: null, + messageName: null, moodComment: null, editApprovedBy: null, }; const header: any = submissionMessage(submission).cardsV2[0]!.card.header; expect(header.title).toBe('📝 Alice'); diff --git a/tests/cards.test.ts b/tests/cards.test.ts index b0d09f8..21d89d6 100644 --- a/tests/cards.test.ts +++ b/tests/cards.test.ts @@ -111,7 +111,7 @@ describe('cards', () => { late: false, submittedAt: '2026-06-10T04:30:00Z', editedAt: null, - messageName: null, moodComment: null, + messageName: null, moodComment: null, editApprovedBy: null, }; const clean = JSON.stringify(submissionMessage(base)); expect(clean).toContain('📝 Alice'); diff --git a/tests/gchat-adapter.test.ts b/tests/gchat-adapter.test.ts index 80e6ecf..1538165 100644 --- a/tests/gchat-adapter.test.ts +++ b/tests/gchat-adapter.test.ts @@ -45,7 +45,7 @@ const submission: Submission = { late: false, submittedAt: '2026-06-10T04:30:00Z', editedAt: null, - messageName: 'spaces/team/messages/sub-1', moodComment: null, + messageName: 'spaces/team/messages/sub-1', moodComment: null, editApprovedBy: null, }; const summary: RunSummary = { @@ -234,6 +234,28 @@ describe('GoogleChatAdapter', () => { expect(card.card.header.subtitle).toBe('Alice tagged you'); }); + it('DMs edit request cards, redraws them by name, and sends the approval card', async () => { + const { gchat, calls, state } = await makeAdapter(); + const view = { id: 4, runId: 9, standup, date: '2026-06-10', person: { userName: 'users/alice', displayName: 'Alice' }, reason: null, status: 'pending', askedAt: '2026-06-10T07:00:00.000Z', answeredAt: '2026-06-10T04:30:00.000Z', decidedAt: null, decidedBy: null, decidedByUser: null, note: null, until: '23:59 Asia/Kolkata' } as const; + expect(await gchat.sendEditRequest('users/mgr', view)).toBe('spaces/x/messages/1'); + state.createdName = () => undefined; + expect(await gchat.sendEditRequest('users/lead', view)).toBeNull(); + await gchat.updateEditRequestCards(['spaces/x/messages/1', 'spaces/x/messages/2'], { ...view, status: 'lapsed' }); + await gchat.sendEditApproved('users/alice', { ...view, status: 'approved', decidedBy: 'Lead' }); + const creates = calls.filter((c) => c.method === 'create'); + expect(creates.map((c) => [c.params.parent, c.params.requestBody.cardsV2[0].cardId])).toEqual([ + ['spaces/dm-mgr', 'edit-request-4'], + ['spaces/dm-lead', 'edit-request-4'], + ['spaces/dm-alice', 'edit-approved-4'], + ]); + const updates = calls.filter((c) => c.method === 'update'); + expect(updates.map((c) => [c.params.name, c.params.updateMask])).toEqual([ + ['spaces/x/messages/1', 'cardsV2'], + ['spaces/x/messages/2', 'cardsV2'], + ]); + expect(JSON.stringify(updates[0]!.params.requestBody)).toContain('⌛ Lapsed'); + }); + it('posts a poll to the space and returns the message name, or null when absent', async () => { const { gchat, calls, state } = await makeAdapter(); expect(await gchat.postPoll(standup, poll, [1, 0])).toBe('spaces/x/messages/1'); diff --git a/tests/late-edits.test.ts b/tests/late-edits.test.ts new file mode 100644 index 0000000..ba812c0 --- /dev/null +++ b/tests/late-edits.test.ts @@ -0,0 +1,449 @@ +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { + APPROVE_EDIT_FN, + DECLINE_EDIT_FN, + editApprovedCard, + editRequestCard, + editRequestDialog, + OPEN_DIALOG_FN, + OPEN_EDIT_REQUEST_FN, + promptMessage, + submissionMessage, + SUBMIT_EDIT_DECLINE_FN, + SUBMIT_EDIT_REQUEST_FN, +} from '../src/adapters/gchat/cards.js'; +import { EventRouter } from '../src/adapters/gchat/events.js'; +import { newSession, sealSession } from '../src/auth/session.js'; +import { createServer } from '../src/server.js'; +import { ANSWERS, makeStack, seedStandup, TENANT } from './helpers.js'; + +const alice = { name: 'users/alice', displayName: 'Alice', type: 'HUMAN' }; +const bob = { name: 'users/bob', displayName: 'Bob', type: 'HUMAN' }; +const lead = { name: 'users/lead', displayName: 'Lead', type: 'HUMAN' }; +const mgr = { name: 'users/mgr', displayName: 'Manager', type: 'HUMAN' }; + +const click = (fn: string, parameters: Record, user = alice, formInputs?: Record) => ({ + type: 'CARD_CLICKED', + ...(formInputs ? { isDialogEvent: true } : {}), + common: { + invokedFunction: fn, + parameters, + ...(formInputs ? { formInputs: Object.fromEntries(Object.entries(formInputs).map(([k, v]) => [k, { stringInputs: { value: [v] } }])) } : {}), + }, + user, + space: { name: `spaces/dm-${user.name.slice(6)}`, spaceType: 'DIRECT_MESSAGE' }, +}); +const status = (reply: any) => reply.actionResponse.dialogAction.actionStatus; +const dialogText = (reply: any) => JSON.stringify(reply.actionResponse.dialogAction.dialog); + +/** A run Alice answered on time, closed at the 11:30 deadline; Lead and Manager manage the standup. */ +async function setup(opts: { webhookFetch?: typeof fetch } = {}) { + const stack = await makeStack(opts); + const standup = await seedStandup(stack.repo); + await stack.repo.addAdmin(standup.id, 'users/lead', 'Lead'); + await stack.repo.addAdmin(standup.id, 'users/mgr', 'Manager'); + stack.clock.set('2026-06-10T09:30'); // Wednesday + await stack.scheduler.tick(); + const run = (await stack.repo.getRun(standup.id, '2026-06-10'))!; + stack.clock.set('2026-06-10T10:00'); + await stack.service.submit(run.id, 'users/alice', 'Alice', ANSWERS); + stack.clock.set('2026-06-10T11:31'); + await stack.scheduler.tick(); + const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT, stack.polls, async () => null, stack.schedule); + const ask = (reason = 'Wrong ticket number under Today') => router.handle(click(SUBMIT_EDIT_REQUEST_FN, { runId: String(run.id) }, alice, { reason })); + return { ...stack, standup, run, router, ask }; +} + +describe('late first answers', () => { + it('post without approval until midnight, then the day closes (criteria 1 and 2)', async () => { + const { service, run, router, clock, adapter } = await setup(); + clock.set('2026-06-10T15:00'); + expect(await service.submit(run.id, 'users/bob', 'Bob', ANSWERS)).toEqual({ ok: true, late: true, edited: false }); + expect(adapter.dms.filter((d) => d.kind === 'editRequest')).toEqual([]); + + clock.set('2026-06-11T00:00'); + expect(await service.submit(run.id, 'users/carol', 'Carol', ANSWERS)).toEqual({ ok: false, reason: 'day_closed' }); + const closed = 'This standup has closed. Answers for Wed 10 Jun can no longer be added.'; + expect(status(await router.handle(click(OPEN_DIALOG_FN, { runId: String(run.id) }, { name: 'users/carol', displayName: 'Carol', type: 'HUMAN' })))).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: closed }); + const submit: any = await router.handle({ ...click('submitStandup', { runId: String(run.id) }, { name: 'users/carol', displayName: 'Carol', type: 'HUMAN' }, { q0: 'a', q1: 'b', q2: 'none', mood: 'good' }) }); + expect(status(submit)).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: closed }); + }); +}); + +describe('asking to edit after the deadline', () => { + it('redraws the prompt card of everyone who answered when the run closes (criterion 3)', async () => { + const { adapter, run, standup, repo } = await setup(); + const names = await repo.listCardMessages(run.id, 'users/alice'); + expect(adapter.cardUpdates.at(-1)).toEqual({ messageNames: names, runId: run.id, state: { kind: 'submitted', at: expect.any(String), late: false, closed: true, edit: null } }); + // Bob never answered, so his card is left as it was. + const bobs = await repo.listCardMessages(run.id, 'users/bob'); + expect(adapter.cardUpdates.some((u) => u.messageNames.includes(bobs[0]!))).toBe(false); + const card = JSON.stringify(promptMessage(standup, run, null, { kind: 'submitted', at: '2026-06-10T10:00:00.000+05:30', late: false, closed: true, edit: null })); + expect(card).toContain('✅ Submitted at 10:00. The deadline has passed. Need to change something? Ask a manager.'); + expect(card).toContain(`"text":"Ask to edit","onClick":{"action":{"function":"${OPEN_EDIT_REQUEST_FN}","interaction":"OPEN_DIALOG"`); + }); + + it('opens the request dialog from Ask to edit, or from an Edit answers button drawn before the deadline (criterion 4)', async () => { + const { router, run } = await setup(); + for (const fn of [OPEN_EDIT_REQUEST_FN, OPEN_DIALOG_FN]) { + const reply: any = await router.handle(click(fn, { runId: String(run.id) })); + expect(dialogText(reply)).toContain('"name":"reason"'); + expect(dialogText(reply)).toContain(SUBMIT_EDIT_REQUEST_FN); + } + const long: any = await router.handle(click(SUBMIT_EDIT_REQUEST_FN, { runId: String(run.id) }, alice, { reason: 'x'.repeat(201) })); + expect(dialogText(long)).toContain('Keep it to 200 characters.'); + expect(dialogText(long)).toContain('x'.repeat(201)); + }); + + it('sends every manager a card and tells the person (criterion 5)', async () => { + const { ask, adapter, repo, run } = await setup(); + expect(status(await ask())).toEqual({ statusCode: 'OK', userFacingMessage: '📨 Sent to your managers. You’ll hear back here.' }); + const cards = adapter.dms.filter((d) => d.kind === 'editRequest'); + expect(cards.map((d) => d.userName)).toEqual(['users/lead', 'users/mgr']); + expect(cards[0]!.editRequest).toMatchObject({ status: 'pending', reason: 'Wrong ticket number under Today', person: { userName: 'users/alice', displayName: 'Alice' }, date: '2026-06-10', until: '23:59 Asia/Kolkata' }); + const request = (await repo.getEditRequestFor(run.id, 'users/alice'))!; + expect(request).toMatchObject({ status: 'pending', reason: 'Wrong ticket number under Today', cardMessages: [expect.any(String), expect.any(String)] }); + expect(adapter.cardUpdates.at(-1)!.state).toMatchObject({ kind: 'submitted', closed: true, edit: { status: 'pending', by: null, until: '23:59 Asia/Kolkata' } }); + }); + + it('leaves the reason out when none is given, and skips a manager who is the one asking', async () => { + const { service, adapter, repo, run, standup } = await setup(); + await service.submit(run.id, 'users/bob', 'Bob', ANSWERS); + await repo.addAdmin(standup.id, 'users/bob', 'Bob'); + expect(await service.edits.ask(run.id, { userName: 'users/bob', displayName: 'Bob' }, ' ')).toEqual({ ok: true, message: '📨 Sent to your managers. You’ll hear back here.' }); + expect(adapter.dms.filter((d) => d.kind === 'editRequest').map((d) => d.userName)).toEqual(['users/lead', 'users/mgr']); + expect((await repo.getEditRequestFor(run.id, 'users/bob'))!.reason).toBeNull(); + }); + + it('says an admin decides in the web app when nobody else manages the standup', async () => { + const { service, repo, run, standup, adapter } = await setup(); + await repo.removeAdmin(standup.id, 'users/lead'); + await repo.removeAdmin(standup.id, 'users/mgr'); + adapter.unreachable.add('users/nobody'); + expect(await service.edits.ask(run.id, { userName: 'users/alice', displayName: 'Alice' }, null)).toEqual({ + ok: true, + message: '📨 Sent. Nobody else manages this standup in Chat, so an admin decides in the web app.', + }); + expect((await repo.getEditRequestFor(run.id, 'users/alice'))!.status).toBe('pending'); + }); + + it('allows one request per person per run, and nothing once the day ends (criteria 6 and 7)', async () => { + const { ask, service, router, run, clock } = await setup(); + await ask(); + const waiting = 'Your request is waiting for a manager.'; + expect(status(await ask())).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: waiting }); + expect(status(await router.handle(click(OPEN_EDIT_REQUEST_FN, { runId: String(run.id) })))).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: waiting }); + const id = (await service.edits.listPending())[0]!.id; + await service.edits.decide(id, false, { userName: 'users/lead', displayName: 'Lead', admin: false }, null); + expect(status(await ask())).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: 'Lead declined your request, so you can’t ask again for Wed 10 Jun.' }); + clock.set('2026-06-11T00:00'); + expect(status(await router.handle(click(OPEN_EDIT_REQUEST_FN, { runId: String(run.id) })))).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: 'This standup has closed. Answers for Wed 10 Jun can no longer be added.' }); + expect(await service.edits.ask(run.id, { userName: 'users/alice', displayName: 'Alice' }, null)).toEqual({ ok: false, message: 'This standup has closed. Answers for Wed 10 Jun can no longer be added.' }); + }); + + it('refuses requests that make no sense', async () => { + const { service, run, router } = await setup(); + const me = { userName: 'users/bob', displayName: 'Bob' }; + expect(await service.edits.ask(999, me, null)).toEqual({ ok: false, message: 'This standup prompt is no longer valid.' }); + expect(await service.edits.ask(run.id, me, null)).toEqual({ ok: false, message: 'You haven’t answered yet, so there is nothing to edit. Answers can still be added late, until midnight.' }); + expect(status(await router.handle(click(SUBMIT_EDIT_REQUEST_FN, { runId: 'x' })))).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: 'This standup prompt is no longer valid.' }); + expect(status(await router.handle(click(OPEN_EDIT_REQUEST_FN, { runId: '999' })))).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: 'This standup prompt is no longer valid.' }); + }); + + it('tells someone whose run is still open to edit instead', async () => { + const stack = await makeStack(); + const standup = await seedStandup(stack.repo); + stack.clock.set('2026-06-10T09:30'); + await stack.scheduler.tick(); + const run = (await stack.repo.getRun(standup.id, '2026-06-10'))!; + await stack.service.submit(run.id, 'users/alice', 'Alice', ANSWERS); + expect(await stack.service.edits.ask(run.id, { userName: 'users/alice', displayName: 'Alice' }, null)).toEqual({ ok: false, message: 'The deadline hasn’t passed, so you can still edit your answers.' }); + }); +}); + +describe('deciding', () => { + it('lets the first manager decide, redraws every card, and refuses the rest (criteria 8 and 9)', async () => { + const { ask, router, adapter, repo, run, service } = await setup(); + await ask(); + const request = (await repo.getEditRequestFor(run.id, 'users/alice'))!; + const approved: any = await router.handle(click(APPROVE_EDIT_FN, { editRequestId: String(request.id) }, lead)); + expect(approved.actionResponse).toEqual({ type: 'UPDATE_MESSAGE' }); + expect(JSON.stringify(approved)).toContain('✅ Approved by Lead at 11:31. Alice can edit until 23:59 Asia/Kolkata.'); + expect(adapter.editCardUpdates.at(-1)).toMatchObject({ messageNames: request.cardMessages, request: { status: 'approved', decidedBy: 'Lead' } }); + + const late: any = await router.handle(click(APPROVE_EDIT_FN, { editRequestId: String(request.id) }, mgr)); + expect(late.actionResponse).toEqual({ type: 'UPDATE_MESSAGE' }); + expect(JSON.stringify(late)).toContain('Approved by Lead'); + expect(await service.edits.decide(request.id, false, { userName: 'users/mgr', displayName: 'Manager', admin: false }, null)).toMatchObject({ ok: false, message: 'Lead already approved this request.' }); + expect((await repo.getEditRequest(request.id))!).toMatchObject({ status: 'approved', decidedBy: 'Lead', decidedByUser: 'users/lead' }); + }); + + it('only lets the standup’s managers decide, never the person asking (criterion 8)', async () => { + const { ask, router, repo, run, service } = await setup(); + await ask(); + const id = (await repo.getEditRequestFor(run.id, 'users/alice'))!.id; + expect(await router.handle(click(APPROVE_EDIT_FN, { editRequestId: String(id) }, bob))).toEqual({ text: 'Only the managers of Daily Standup can decide this.' }); + expect(await service.edits.decide(id, true, { userName: 'users/alice', displayName: 'Alice', admin: true }, null)).toEqual({ ok: false, message: 'Your own requests go to your managers.' }); + expect(await router.handle(click(APPROVE_EDIT_FN, { editRequestId: '999' }, lead))).toEqual({ text: 'That request no longer exists.' }); + expect((await repo.getEditRequest(id))!.status).toBe('pending'); + }); + + it('asks for an optional note when declining and sends it to the person (criterion 10)', async () => { + const { ask, router, adapter, repo, run } = await setup(); + await ask(); + const id = String((await repo.getEditRequestFor(run.id, 'users/alice'))!.id); + const dialog: any = await router.handle(click(DECLINE_EDIT_FN, { editRequestId: id }, mgr)); + expect(dialogText(dialog)).toContain(SUBMIT_EDIT_DECLINE_FN); + const declined: any = await router.handle(click(SUBMIT_EDIT_DECLINE_FN, { editRequestId: id }, mgr, { note: ' The wrap-up has gone out. ' })); + expect(status(declined)).toEqual({ statusCode: 'OK', userFacingMessage: '❌ Declined. Alice has been told.' }); + expect(adapter.dms.at(-1)).toEqual({ kind: 'text', userName: 'users/alice', text: '❌ Manager declined your request to edit your answers for Wed 10 Jun. “The wrap-up has gone out.”' }); + expect(adapter.editCardUpdates.at(-1)!.request).toMatchObject({ status: 'declined', note: 'The wrap-up has gone out.' }); + expect(adapter.cardUpdates.at(-1)!.state).toMatchObject({ edit: { status: 'declined', by: 'Manager' } }); + const again: any = await router.handle(click(SUBMIT_EDIT_DECLINE_FN, { editRequestId: id }, lead, { note: '' })); + expect(status(again)).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: 'Manager already declined this request.' }); + }); + + it('lapses undecided requests once the day ends, once (criterion 11)', async () => { + const { ask, scheduler, adapter, repo, run, clock, service } = await setup(); + await ask(); + const id = (await repo.getEditRequestFor(run.id, 'users/alice'))!.id; + clock.set('2026-06-10T23:59'); + await scheduler.tick(); + expect((await repo.getEditRequest(id))!.status).toBe('pending'); + clock.set('2026-06-11T00:01'); + await scheduler.tick(); + await scheduler.tick(); + expect((await repo.getEditRequest(id))!.status).toBe('lapsed'); + const lapsed = 'Your request to edit your answers for Wed 10 Jun wasn’t answered that day, so it lapsed. Your answers stay as they were.'; + expect(adapter.dms.filter((d) => d.text?.includes('lapsed'))).toEqual([{ kind: 'text', userName: 'users/alice', text: `⌛ ${lapsed}` }]); + expect(adapter.editCardUpdates.at(-1)!.request.status).toBe('lapsed'); + expect(await service.edits.decide(id, true, { userName: 'users/lead', displayName: 'Lead', admin: false }, null)).toMatchObject({ ok: false, message: 'That request lapsed at midnight.' }); + }); + + it('lapses a request someone tries to decide after midnight, before the tick does', async () => { + const { ask, repo, run, clock, service, adapter } = await setup(); + await ask(); + const id = (await repo.getEditRequestFor(run.id, 'users/alice'))!.id; + clock.set('2026-06-11T00:00'); + expect(await service.edits.decide(id, true, { userName: 'users/lead', displayName: 'Lead', admin: false }, null)).toMatchObject({ ok: false, message: 'That request lapsed at midnight.' }); + expect((await repo.getEditRequest(id))!.status).toBe('lapsed'); + expect(adapter.dms.filter((d) => d.kind === 'editApproved')).toEqual([]); + }); + + it('keeps going when a card cannot be sent or redrawn', async () => { + const { ask, repo, run, service, adapter } = await setup(); + const logs: string[] = []; + (service as any).log = (msg: string) => logs.push(msg); + adapter.failEditCards = true; + expect(status(await ask())).toMatchObject({ statusCode: 'OK' }); + const id = (await repo.getEditRequestFor(run.id, 'users/alice'))!.id; + expect((await service.edits.decide(id, true, { userName: 'users/lead', displayName: 'Lead', admin: false }, null)).ok).toBe(true); + expect(logs.some((l) => l.includes('edit request'))).toBe(true); + }); +}); + +describe('editing after approval', () => { + it('sends the person a way to edit, until midnight, and marks the posted answer (criteria 12–16)', async () => { + const { ask, router, adapter, repo, run, service, clock } = await setup(); + await ask(); + const id = (await repo.getEditRequestFor(run.id, 'users/alice'))!.id; + await router.handle(click(APPROVE_EDIT_FN, { editRequestId: String(id) }, lead)); + expect(adapter.dms.filter((d) => d.kind === 'editApproved')).toEqual([{ kind: 'editApproved', userName: 'users/alice', editRequest: expect.objectContaining({ status: 'approved', decidedBy: 'Lead' }) }]); + expect(adapter.cardUpdates.at(-1)!.state).toMatchObject({ edit: { status: 'approved', by: 'Lead', until: '23:59 Asia/Kolkata' } }); + + const form: any = await router.handle(click(OPEN_DIALOG_FN, { runId: String(run.id) })); + expect(dialogText(form)).toContain('"name":"q0"'); + const summaries = adapter.posts.filter((p) => p.kind === 'summary').length; + clock.set('2026-06-10T14:00'); + expect(await service.submit(run.id, 'users/alice', 'Alice', ANSWERS)).toEqual({ ok: true, late: false, edited: true }); + expect(await service.submit(run.id, 'users/alice', 'Alice', ANSWERS)).toEqual({ ok: true, late: false, edited: true }); + const update = adapter.posts.at(-1)!; + expect(update).toMatchObject({ kind: 'update', payload: { editApprovedBy: 'Lead' } }); + expect(submissionMessage(update.payload as any).cardsV2[0]!.card.header.subtitle).toBe('Edited after the deadline · approved by Lead'); + expect(adapter.posts.filter((p) => p.kind === 'summary').length).toBe(summaries); + + clock.set('2026-06-11T00:00'); + expect(await service.submit(run.id, 'users/alice', 'Alice', ANSWERS)).toEqual({ ok: false, reason: 'day_closed' }); + }); + + it('still refuses an edit after the deadline without approval', async () => { + const { service, run, router } = await setup(); + expect(await service.submit(run.id, 'users/alice', 'Alice', ANSWERS)).toEqual({ ok: false, reason: 'already_submitted' }); + const reply: any = await router.handle(click('submitStandup', { runId: String(run.id) }, alice, { q0: 'a', q1: 'b', q2: 'none', mood: 'good' })); + expect(status(reply)).toEqual({ statusCode: 'INVALID_ARGUMENT', userFacingMessage: 'The deadline has passed. Ask a manager to let you edit, from your prompt card.' }); + }); + + it('names the approver in the submission webhook (criterion 15)', async () => { + const bodies: any[] = []; + const webhookFetch = (async (_url: string, init: RequestInit) => { + bodies.push(JSON.parse(String(init.body))); + return new Response('ok'); + }) as unknown as typeof fetch; + const { ask, repo, run, service, standup } = await setup({ webhookFetch }); + await repo.updateStandup(standup.id, { webhookUrl: 'https://hooks.example.com/x' }); + await ask(); + const id = (await repo.getEditRequestFor(run.id, 'users/alice'))!.id; + await service.edits.decide(id, true, { userName: 'users/lead', displayName: 'Lead', admin: false }, null); + await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); + expect(bodies.filter((b) => b.event === 'submission').at(-1)).toMatchObject({ edited: true, editApprovedBy: 'Lead' }); + }); +}); + +describe('edit request cards', () => { + const standup = { id: 1, name: 'Engineering', timezone: 'Europe/London' } as any; + const view = (over: Record = {}) => + ({ + id: 7, + runId: 3, + standup, + date: '2026-10-15', + person: { userName: 'users/priya', displayName: 'Priya' }, + reason: 'Wrong ticket number', + status: 'pending', + askedAt: '2026-10-15T10:42:00.000Z', + answeredAt: '2026-10-15T08:12:00.000Z', + decidedAt: null, + decidedBy: null, + note: null, + until: '23:59 Europe/London', + ...over, + }) as any; + + it('shows the manager who, which day, the reason and the buttons, then the outcome', () => { + const pending = JSON.stringify(editRequestCard(view())); + expect(pending).toContain('"title":"Edit request · Engineering"'); + expect(pending).toContain('"subtitle":"Priya · Thu 15 Oct · answered at 09:12"'); + expect(pending).toContain('“Wrong ticket number” Approve and Priya can edit their answers until 23:59 Europe/London. Decline and Priya is told, with your note. Unanswered by then, it lapses.'); + expect(pending).toContain(APPROVE_EDIT_FN); + expect(pending).toContain(DECLINE_EDIT_FN); + const noReason = JSON.stringify(editRequestCard(view({ reason: null }))); + expect(noReason).toContain('"text":"Approve and Priya'); + const approved = JSON.stringify(editRequestCard(view({ status: 'approved', decidedBy: 'Asha', decidedAt: '2026-10-15T10:50:00.000Z' }))); + expect(approved).toContain('✅ Approved by Asha at 11:50. Priya can edit until 23:59 Europe/London.'); + expect(approved).not.toContain(APPROVE_EDIT_FN); + expect(JSON.stringify(editRequestCard(view({ status: 'declined', decidedBy: 'Asha', decidedAt: '2026-10-15T10:50:00.000Z', note: 'Too late' })))).toContain('❌ Declined by Asha at 11:50. “Too late”'); + expect(JSON.stringify(editRequestCard(view({ status: 'declined', decidedBy: 'Asha', decidedAt: '2026-10-15T10:50:00.000Z' })))).toContain('❌ Declined by Asha at 11:50.'); + expect(JSON.stringify(editRequestCard(view({ status: 'lapsed' })))).toContain('⌛ Lapsed: not decided by 23:59 Europe/London.'); + }); + + it('gives the person an Edit answers button once approved', () => { + const card = JSON.stringify(editApprovedCard(view({ status: 'approved', decidedBy: 'Asha' }))); + expect(card).toContain('✅ Asha approved your request. Edit your answers before 23:59 Europe/London.'); + expect(card).toContain(`"text":"Edit answers","onClick":{"action":{"function":"${OPEN_DIALOG_FN}","interaction":"OPEN_DIALOG","parameters":[{"key":"runId","value":"3"}]`); + }); + + it('asks why in the request dialog', () => { + const run = { id: 3, date: '2026-10-15' } as any; + const dialog = JSON.stringify(editRequestDialog(standup, run, 'https://x.example/chat', { error: 'Keep it to 200 characters.', reason: 'abc' })); + expect(dialog).toContain('Ask to edit · Engineering · Thu 15 Oct
Your managers decide. If they approve, you can edit until 23:59 Europe/London.'); + expect(dialog).toContain('"label":"Why? (optional)"'); + expect(dialog).toContain('"value":"abc"'); + expect(dialog).toContain('Sent to your managers with the request. Up to 200 characters.'); + expect(dialog).toContain('{"key":"fn","value":"submitEditRequest"}'); + }); + + it('shows the prompt card’s later states', () => { + const run = { id: 3, date: '2026-10-15' } as any; + const at = '2026-10-15T08:12:00.000Z'; + const card = (edit: any, late = false) => JSON.stringify(promptMessage({ ...standup, deadlineTime: '11:30' }, run, null, { kind: 'submitted', at, late, closed: true, edit })); + expect(card(null, true)).toContain('✅ Submitted at 09:12 (late). The deadline has passed.'); + expect(card({ status: 'pending', by: null, until: '23:59 Europe/London' })).toContain('Your request to edit is waiting for a manager.'); + const approved = card({ status: 'approved', by: 'Asha', until: '23:59 Europe/London' }); + expect(approved).toContain('Asha approved your request: you can edit your answers until 23:59 Europe/London.'); + expect(approved).toContain('"text":"Edit answers"'); + expect(card({ status: 'declined', by: 'Asha', until: '23:59 Europe/London' })).toContain('Asha declined your request to edit.'); + expect(card({ status: 'lapsed', by: null, until: '23:59 Europe/London' })).toContain('Your request to edit lapsed.'); + expect(card({ status: 'lapsed', by: null, until: '23:59 Europe/London' })).not.toContain('buttonList'); + }); + + it('marks a posted answer edited after the deadline', () => { + const base = { id: 1, displayName: 'Priya', answers: [], late: false, editedAt: 'x', editApprovedBy: 'Asha' } as any; + expect(submissionMessage(base).cardsV2[0]!.card.header.subtitle).toBe('Edited after the deadline · approved by Asha'); + expect(submissionMessage({ ...base, late: true }).cardsV2[0]!.card.header.subtitle).toBe('Submitted late · edited after the deadline · approved by Asha'); + }); +}); + +const SECRET = 'api-test-secret'; +const OPERATOR = 'dash-secret'; +const CSRF = { 'x-requested-with': 'asyncup', 'content-type': 'application/json' }; +let close: (() => void) | null = null; +afterEach(() => close?.()); + +async function startServer(withService = true) { + const stack = await setup(); + const app = createServer({ + router: stack.router, + scheduler: stack.scheduler, + adapter: stack.adapter, + blockers: stack.blockers, + repo: stack.repo, + settings: stack.settings, + dashboardToken: OPERATOR, + skipVerification: true, + secretKey: SECRET, + service: withService ? stack.service : undefined, + schedule: stack.schedule, + now: stack.clock.now, + }); + const server = app.listen(0); + close = () => server.close(); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const cookieFor = (sub: string, name = sub) => `asyncup_sess=${sealSession(SECRET, newSession({ sub, email: `${sub}@o.com`, name, admin: false }))}`; + const op = (path: string, init: RequestInit = {}) => fetch(`${url}/api/v1${path}`, { ...init, headers: { authorization: `Bearer ${OPERATOR}`, 'content-type': 'application/json' } }); + const as = (cookie: string, path: string, init: RequestInit = {}) => fetch(`${url}/api/v1${path}`, { ...init, headers: { cookie, ...CSRF } }); + return { ...stack, op, as, cookieFor }; +} + +describe('edit requests API', () => { + it('lists pending requests to people who can decide them, and decides them (criterion 8)', async () => { + const { op, as, cookieFor, ask, repo, run, adapter, standup } = await startServer(); + await ask(); + const id = (await repo.getEditRequestFor(run.id, 'users/alice'))!.id; + const other = await repo.createStandup({ tenantId: TENANT, spaceName: 'spaces/other', name: 'Other', timezone: 'UTC' }); + await repo.addAdmin(other.id, 'users/ops', 'Ops'); + + const all: any = await (await op('/edit-requests')).json(); + expect(all).toEqual({ + requests: [ + { + id, + standupId: standup.id, + standupName: 'Daily Standup', + date: '2026-06-10', + userName: 'users/alice', + displayName: 'Alice', + reason: 'Wrong ticket number under Today', + askedAt: expect.any(String), + answeredAt: expect.any(String), + until: '23:59 Asia/Kolkata', + }, + ], + }); + expect(((await (await as(cookieFor('lead', 'Lead'), '/edit-requests')).json()) as any).requests).toHaveLength(1); + expect(((await (await as(cookieFor('ops', 'Ops'), '/edit-requests')).json()) as any).requests).toEqual([]); + expect((await as(cookieFor('bob', 'Bob'), '/edit-requests')).status).toBe(403); + + expect((await as(cookieFor('ops', 'Ops'), `/edit-requests/${id}/approve`, { method: 'POST' })).status).toBe(403); + expect((await op('/edit-requests/999/approve', { method: 'POST' })).status).toBe(404); + expect((await op('/edit-requests/abc/approve', { method: 'POST' })).status).toBe(404); + let res = await as(cookieFor('lead', 'Lead'), `/edit-requests/${id}/decline`, { method: 'POST', body: JSON.stringify({ note: 'Not today' }) }); + expect(await res.json()).toEqual({ ok: true, message: '❌ Declined. Alice has been told.' }); + expect(adapter.dms.at(-1)!.text).toContain('“Not today”'); + res = await op(`/edit-requests/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ error: { code: 'refused', message: 'Lead already declined this request.' } }); + }); + + it('lets the operator approve, and answers 503 without the standup service', async () => { + const { op, ask, repo, run } = await startServer(); + await ask(); + const id = (await repo.getEditRequestFor(run.id, 'users/alice'))!.id; + expect(await (await op(`/edit-requests/${id}/approve`, { method: 'POST' })).json()).toEqual({ ok: true, message: '✅ Approved. Alice can edit until 23:59 Asia/Kolkata.' }); + expect((await repo.getEditRequest(id))!.decidedBy).toBe('Operator'); + close?.(); + const bare = await startServer(false); + expect((await bare.op('/edit-requests')).status).toBe(503); + expect((await bare.op('/edit-requests/1/approve', { method: 'POST' })).status).toBe(503); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 571b742..e8de797 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -327,7 +327,7 @@ describe('mcp: tools', () => { expect((await call(alice, 'submit_answers', { ...three, answers: ['Edited', 'Billing', 'none'] })).json.result).toBe('submitted'); clock.set('2026-06-10T11:31'); await scheduler.tick(); - expect((await call(alice, 'submit_answers', three)).error).toContain('closed'); + expect((await call(alice, 'submit_answers', three)).error).toBe('The deadline has passed. Ask a manager to let you edit, from your prompt card in Chat.'); // Someone removed from the roster after the run opened is not a participant of that run. await repo.upsertParticipant({ standupId: s.id, userName: 'users/erin', displayName: 'Erin' }); const erin = await connect((await mint(cookieFor('erin'), { name: 'e', scopes: ['submit'] })).secret); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index 4be1769..c6be965 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -7,8 +7,8 @@ import { POSTGRES_MIGRATIONS, Repo, SQLITE_MIGRATIONS } from '../src/db/repo.js' describe('migration 10 — AI removal', () => { it('keeps both dialect arrays aligned', () => { - expect(SQLITE_MIGRATIONS).toHaveLength(19); - expect(POSTGRES_MIGRATIONS).toHaveLength(19); + expect(SQLITE_MIGRATIONS).toHaveLength(20); + expect(POSTGRES_MIGRATIONS).toHaveLength(20); for (const migrations of [SQLITE_MIGRATIONS, POSTGRES_MIGRATIONS]) { expect(migrations[9]).toContain('DROP COLUMN ai_enabled'); expect(migrations[9]).toContain("'llmApiKey'"); @@ -46,7 +46,7 @@ describe('migration 10 — AI removal', () => { const columns = (check.query('PRAGMA table_info(standups)').all() as { name: string }[]).map((c) => c.name); expect(columns).not.toContain('ai_enabled'); expect(columns).toContain('digest_enabled'); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(19); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(20); check.close(); }, 30_000); }); @@ -90,7 +90,7 @@ describe('migration 11 — MCP tokens and activity', () => { await repo.close(); const check = new Database(dbPath, { readonly: true }); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(19); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(20); check.close(); }); }); @@ -131,7 +131,7 @@ describe('migration 12 — personal schedules', () => { await repo.close(); const check = new Database(dbPath, { readonly: true }); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(19); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(20); check.close(); }); }); @@ -256,3 +256,35 @@ describe('migration 19 — leave in Chat and Calendar', () => { await repo.close(); }); }); + +describe('migration 20 — asking to edit after the deadline', () => { + it('adds edit_requests and the approver on submissions in both dialects', () => { + for (const migrations of [SQLITE_MIGRATIONS, POSTGRES_MIGRATIONS]) { + expect(migrations[19]).toContain('CREATE TABLE edit_requests'); + expect(migrations[19]).toContain('UNIQUE (run_id, user_name)'); + expect(migrations[19]).toContain('ALTER TABLE submissions ADD COLUMN edit_approved_by TEXT'); + } + }); + + it('upgrades a v19 database, keeping submissions with no approver', async () => { + const dbPath = join(mkdtempSync(join(tmpdir(), 'asyncup-mig-')), 'v19.db'); + const raw = new Database(dbPath); + for (const migration of SQLITE_MIGRATIONS.slice(0, 19)) raw.exec(migration); + raw.exec('PRAGMA user_version = 19;'); + raw.close(); + + const repo = await Repo.sqlite(dbPath); + const standup = await repo.createStandup({ tenantId: 'default', spaceName: 'spaces/x', name: 'X', timezone: 'UTC' }); + const run = await repo.createRun(standup.id, '2026-06-10', 'k'); + const sub = await repo.createSubmission({ runId: run.id, userName: 'users/a', displayName: 'Asha', answers: [], mood: null, moodComment: null, late: false, submittedAt: '2026-06-10T09:00:00Z' }); + expect(sub.editApprovedBy).toBeNull(); + const request = await repo.createEditRequest({ runId: run.id, userName: 'users/a', displayName: 'Asha', reason: null, askedAt: '2026-06-10T12:00:00Z' }); + expect(request).toMatchObject({ status: 'pending', cardMessages: [], decidedBy: null }); + expect(await repo.decideEditRequest(request.id, 'approved', '2026-06-10T12:05:00Z', { userName: 'users/m', displayName: 'Mo' }, null)).toBe(true); + expect(await repo.decideEditRequest(request.id, 'declined', '2026-06-10T12:06:00Z', { userName: 'users/m', displayName: 'Mo' }, null)).toBe(false); + expect(await repo.listPendingEditRequests()).toEqual([]); + await repo.deleteStandup(standup.id); + expect(await repo.getEditRequest(request.id)).toBeNull(); + await repo.close(); + }); +}); diff --git a/web/src/lib/schedule.ts b/web/src/lib/schedule.ts index 1c8bfae..354a0a6 100644 --- a/web/src/lib/schedule.ts +++ b/web/src/lib/schedule.ts @@ -1,9 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api, type TimeOffPolicy } from './api'; -import type { Override as OverrideView, Schedule as ScheduleView } from '../../../src/api/spec/schemas.ts'; +import type { EditRequest as EditRequestView, Override as OverrideView, Schedule as ScheduleView } from '../../../src/api/spec/schemas.ts'; -export type { OverrideView, ScheduleView }; +export type { EditRequestView, OverrideView, ScheduleView }; /** Either the caller's own schedule or someone else's, by Chat user name. */ export type ScheduleTarget = 'me' | { userName: string }; @@ -96,6 +96,20 @@ export function useDecide() { }); } +/** Requests to edit answers after the deadline, newest first. */ +export function useEditRequests(enabled: boolean) { + return useQuery({ queryKey: ['edit-requests'], queryFn: () => api<{ requests: EditRequestView[] }>('/edit-requests'), select: (d) => d.requests, enabled }); +} + +export function useDecideEdit() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, approve, note }: { id: number; approve: boolean; note?: string }) => + api<{ ok: true; message: string }>(`/edit-requests/${id}/${approve ? 'approve' : 'decline'}`, { method: 'POST', body: note ? { note } : {} }), + onSuccess: () => client.invalidateQueries({ queryKey: ['edit-requests'] }), + }); +} + /** The stored week as a set of days, for the chip toggles ("" for ad hoc, null for follow). */ export function weekToDays(workingDays: string | null): string[] { return workingDays && workingDays !== 'adhoc' ? workingDays.split(',') : []; diff --git a/web/src/pages/console.test.tsx b/web/src/pages/console.test.tsx index f10f975..3f66c7d 100644 --- a/web/src/pages/console.test.tsx +++ b/web/src/pages/console.test.tsx @@ -396,6 +396,7 @@ describe('a granted Viewer', () => { expect(screen.queryByRole('button', { name: /Actions for/ })).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: /Requests/ })).not.toBeInTheDocument(); expect(calls).not.toContain('GET /api/v1/requests'); + expect(calls).not.toContain('GET /api/v1/edit-requests'); cleanup(); renderApp('/reports'); diff --git a/web/src/pages/schedule.test.tsx b/web/src/pages/schedule.test.tsx index 82ba30d..20b6e50 100644 --- a/web/src/pages/schedule.test.tsx +++ b/web/src/pages/schedule.test.tsx @@ -12,6 +12,9 @@ const REQUESTS = [ { id: 7, userName: 'users/priya', displayName: 'Priya Sharma', date: '2026-09-22', working: false, reason: 'sick', status: 'pending', label: 'Day off', setBy: 'Priya Sharma', channel: 'chat', decidedBy: null, decisionNote: null, createdAt: '2026-09-18T10:00:00Z' }, { id: 8, userName: 'users/priya', displayName: 'Priya Sharma', date: '2026-09-23', working: false, reason: 'sick', status: 'pending', label: 'Day off', setBy: 'Priya Sharma', channel: 'chat', decidedBy: null, decisionNote: null, createdAt: '2026-09-18T10:00:00Z' }, ]; +const EDITS = [ + { id: 4, standupId: 1, standupName: 'Engineering', date: '2026-09-18', userName: 'users/1', displayName: 'Asha Verma', reason: 'Wrong ticket number', askedAt: '2026-09-18T10:42:00Z', answeredAt: '2026-09-18T03:42:00Z', until: '23:59 Asia/Kolkata' }, +]; const PRIYA_SCHEDULE = { userName: 'users/priya', displayName: 'Priya Sharma', workingDays: 'mon,tue,wed,thu', workingDaysLabel: 'Mon–Thu', policies: [{ standupId: 1, name: 'Engineering', timeOffPolicy: 'approval' }], @@ -33,6 +36,9 @@ function server(over: Record = {}, m 'GET /api/v1/standups/1': { body: ENG }, 'GET /api/v1/people': { body: { people: PEOPLE } }, 'GET /api/v1/requests': { body: { requests: REQUESTS } }, + 'GET /api/v1/edit-requests': { body: { requests: EDITS } }, + 'POST /api/v1/edit-requests/4/approve': { body: record({ ok: true, message: '✅ Approved. Asha Verma can edit until 23:59 Asia/Kolkata.' }) }, + 'POST /api/v1/edit-requests/4/decline': { body: record({ ok: true, message: '❌ Declined. Asha Verma has been told.' }) }, 'GET /api/v1/people/users%2Fpriya/schedule': { body: PRIYA_SCHEDULE }, 'PATCH /api/v1/people/users%2Fpriya/schedule': { body: record({ ...PRIYA_SCHEDULE, workingDays: 'mon,tue,wed', workingDaysLabel: 'Mon–Wed' }) }, 'POST /api/v1/people/users%2Fpriya/overrides': { body: record({ ok: true, status: 'active', message: '🏖️ Sat 19 Sep marked off.' }) }, @@ -119,7 +125,7 @@ describe('team schedules', () => { }); it('lists pending requests and approves or declines them with a note', async () => { - const { bodies, calls } = server(); + const { bodies, calls } = server({ 'GET /api/v1/edit-requests': { body: { requests: [] } } }); renderApp('/team'); const user = userEvent.setup(); await user.click(await screen.findByRole('tab', { name: 'Requests · 1' })); @@ -133,20 +139,57 @@ describe('team schedules', () => { await waitFor(() => expect(calls).toContain('POST /api/v1/requests/7,8/approve')); }); + it('lists requests to edit answers beside time off, and decides them', async () => { + const { bodies, calls } = server(); + renderApp('/team'); + const user = userEvent.setup(); + await user.click(await screen.findByRole('tab', { name: 'Requests · 2' })); + const row = screen.getByText('Edit answers · Engineering · Fri 18 Sep').closest('.request') as HTMLElement; + expect(within(row).getByText('“Wrong ticket number” · answered at 09:12 · lapses at 23:59 Asia/Kolkata if unanswered')).toBeInTheDocument(); + await user.click(within(row).getByRole('button', { name: 'Decline…' })); + await user.click(within(row).getByRole('button', { name: 'Decline…' })); + expect(within(row).queryByLabelText('Reason for declining')).not.toBeInTheDocument(); + await user.click(within(row).getByRole('button', { name: 'Decline…' })); + await user.click(within(row).getByRole('button', { name: 'Send decline' })); + await waitFor(() => expect(calls).toContain('POST /api/v1/edit-requests/4/decline')); + expect(bodies.at(-1)).toEqual({}); + await user.click(within(row).getByRole('button', { name: 'Decline…' })); + await user.type(within(row).getByLabelText('Reason for declining'), 'Too late'); + await user.click(within(row).getByRole('button', { name: 'Send decline' })); + await waitFor(() => expect(bodies).toContainEqual({ note: 'Too late' })); + await user.click(within(row).getByRole('button', { name: 'Approve' })); + await waitFor(() => expect(calls).toContain('POST /api/v1/edit-requests/4/approve')); + }); + + it('shows edit requests to someone who configures a standup but does not manage schedules', async () => { + const configurer = { ...ADMIN, permissions: { workspace: ['standups.view', 'standups.configure', 'people.view'], standups: {} } }; + const { calls } = server({ 'GET /api/v1/edit-requests': { body: { requests: [{ ...EDITS[0], reason: null }] } } }, configurer); + renderApp('/team'); + const user = userEvent.setup(); + await user.click(await screen.findByRole('tab', { name: 'Requests · 1' })); + expect(screen.getByText('answered at 09:12 · lapses at 23:59 Asia/Kolkata if unanswered')).toBeInTheDocument(); + expect(calls).not.toContain('GET /api/v1/requests'); + }); + it('explains an empty queue', async () => { - server({ 'GET /api/v1/requests': { body: { requests: [] } } }); + server({ 'GET /api/v1/requests': { body: { requests: [] } }, 'GET /api/v1/edit-requests': { body: { requests: [] } } }); renderApp('/team'); const user = userEvent.setup(); await user.click(await screen.findByRole('tab', { name: 'Requests · 0' })); - expect(screen.getByText(/No time-off requests waiting/)).toBeInTheDocument(); + expect(screen.getByText(/No requests waiting/)).toBeInTheDocument(); }); it('reports a failed requests load', async () => { - server({ 'GET /api/v1/requests': { status: 500, body: { error: { code: 'boom', message: 'Database away.' } } } }); + server({ + 'GET /api/v1/requests': { status: 500, body: { error: { code: 'boom', message: 'Database away.' } } }, + 'GET /api/v1/edit-requests': { status: 500, body: { error: { code: 'boom', message: 'Database away.' } } }, + }); renderApp('/team'); const user = userEvent.setup(); await user.click(await screen.findByRole('tab', { name: 'Requests · 0' })); expect(await screen.findByText(/Could not load requests/)).toBeInTheDocument(); + expect(await screen.findByText(/Could not load edit requests/)).toBeInTheDocument(); + expect(screen.queryByText(/No requests waiting/)).not.toBeInTheDocument(); }); }); diff --git a/web/src/pages/team.tsx b/web/src/pages/team.tsx index 9bc940f..98c5cf3 100644 --- a/web/src/pages/team.tsx +++ b/web/src/pages/team.tsx @@ -3,7 +3,7 @@ import { ConfirmDialog } from '../components/confirm-dialog'; import { ScheduleDrawer } from '../components/schedule-drawer'; import { useAccess } from '../lib/access'; import { roleOf, useParticipantPatch, useParticipantRemove, usePeople, type PersonRow } from '../lib/people'; -import { dateSpan, groupRequests, useDecide, useRequests } from '../lib/schedule'; +import { dateSpan, groupRequests, useDecide, useDecideEdit, useEditRequests, useRequests } from '../lib/schedule'; type Filter = 'everyone' | 'managers' | 'away' | 'requests'; const FILTER_LABEL: Record = { everyone: 'Everyone', managers: 'Managers', away: 'Away', requests: 'Requests' }; @@ -20,9 +20,14 @@ export function TeamPage() { const [declining, setDeclining] = useState<{ key: string; note: string } | null>(null); const access = useAccess(); const decides = access.canSomewhere('schedules.manage'); + const decidesEdits = access.canSomewhere('standups.configure'); const requests = useRequests(decides); + const edits = useEditRequests(decidesEdits); const decide = useDecide(); + const decideEdit = useDecideEdit(); const groups = groupRequests(requests.data ?? []); + const requestCount = groups.length + (edits.data?.length ?? 0); + const requestsLoaded = (!decides || !!requests.data) && (!decidesEdits || !!edits.data); const matches = (p: PersonRow, f: Filter) => f === 'everyone' || (f === 'managers' ? roleOf(p) === 'Manager' : f === 'away' ? p.onVacation : false); const rows = (people.data ?? []).filter((p) => matches(p, filter)); const standupCount = new Set((people.data ?? []).flatMap((p) => p.standups.map((s) => s.id))).size; @@ -45,9 +50,9 @@ export function TeamPage() {
- {(['everyone', 'managers', 'away', ...(decides ? (['requests'] as const) : [])] as const).map((f) => ( + {(['everyone', 'managers', 'away', ...(decides || decidesEdits ? (['requests'] as const) : [])] as const).map((f) => ( ))}
@@ -55,24 +60,35 @@ export function TeamPage() { {filter === 'requests' ? (
{requests.isError ?
Could not load requests: {requests.error.message}
: null} - {requests.data && groups.length === 0 ?
No time-off requests waiting. Requests appear here when a standup’s time-off policy needs a manager’s approval.
: null} + {edits.isError ?
Could not load edit requests: {edits.error.message}
: null} + {requestsLoaded && requestCount === 0 ?
No requests waiting. Time-off requests appear here when a standup’s time-off policy needs a manager’s approval, and requests to edit answers when someone asks after the deadline.
: null} + {(edits.data ?? []).map((r) => ( + setDeclining(declining?.key === `edit-${r.id}` ? null : { key: `edit-${r.id}`, note: '' })} + onNote={(note) => setDeclining({ key: `edit-${r.id}`, note })} + onDecline={(note) => void act(async () => { await decideEdit.mutateAsync({ id: r.id, approve: false, note }); setDeclining(null); })} + onApprove={() => void act(() => decideEdit.mutateAsync({ id: r.id, approve: true }))} + /> + ))} {groups.map((g) => ( -
- - -
{g.person}
-
{dateSpan(g.dates)} · {g.dates.length} day{g.dates.length === 1 ? '' : 's'} {g.working ? 'working' : 'off'}
-
{g.reason ? `“${g.reason}” · ` : ''}requested via {g.channel} · lapses at the run’s deadline if unanswered
- {declining?.key === g.key ? ( -
- setDeclining({ key: g.key, note: e.target.value })} /> - -
- ) : null} -
- - -
+ setDeclining(declining?.key === g.key ? null : { key: g.key, note: '' })} + onNote={(note) => setDeclining({ key: g.key, note })} + onDecline={(note) => void act(async () => { await decide.mutateAsync({ ids: g.ids, approve: false, note }); setDeclining(null); })} + onApprove={() => void act(() => decide.mutateAsync({ ids: g.ids, approve: true }))} + /> ))}
) : ( @@ -154,3 +170,41 @@ export function TeamPage() { ); } + +/** "09:12" in the standup's zone, which `until` ("23:59 Europe/London") names. */ +function clock(iso: string, until: string): string { + return new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: until.split(' ')[1] }).format(new Date(iso)); +} + +/** One request waiting for a decision; Decline… opens a note sent to the person. */ +function RequestRow(props: { + name: string; + line: string; + caption: string; + declining: string | null; + busy: boolean; + onToggleDecline: () => void; + onNote: (note: string) => void; + onDecline: (note: string | undefined) => void; + onApprove: () => void; +}) { + return ( +
+ + {/* A basis lets the buttons wrap under the text on a phone instead of squeezing it. */} + +
{props.name}
+
{props.line}
+
{props.caption}
+ {props.declining !== null ? ( +
+ props.onNote(e.target.value)} /> + +
+ ) : null} +
+ + +
+ ); +} From b9a3055a6da0fbf337115da9bd13aee72680d6e1 Mon Sep 17 00:00:00 2001 From: Ashish Kumar Date: Fri, 16 Oct 2026 08:55:59 +0530 Subject: [PATCH 3/4] Render Chat's shorthand in answers and blockers (#107) *bold*, _italic_, ~strike~, __underline__, `code` and - lists show as formatting on posted answer and blocker cards and in the web app. Card text is escaped first, so other HTML in an answer shows as typed. Plain Chat messages keep Chat's own syntax without underline, spelling skips the markers, and stored text, the API and integrations are unchanged. Spec: specs/006-text-formatting.md --- Dockerfile | 2 + docs/guide/commands.md | 5 + docs/guide/user-guide.md | 4 + site/index.html | 2 +- specs/006-text-formatting.md | 106 ++++++++++++++++++++ src/adapters/gchat/cards.ts | 13 ++- src/core/blocker-service.ts | 9 +- src/core/format.ts | 150 +++++++++++++++++++++++++++++ src/core/insights.ts | 3 +- src/core/scheduler.ts | 3 +- src/core/spelling.ts | 3 + tests/format.test.ts | 129 +++++++++++++++++++++++++ web/src/lib/formatted.test.tsx | 25 +++++ web/src/lib/formatted.tsx | 38 ++++++++ web/src/pages/blockers.tsx | 3 +- web/src/pages/me.tsx | 3 +- web/src/pages/reports.tsx | 3 +- web/src/pages/standup/history.tsx | 3 +- web/src/pages/standup/overview.tsx | 3 +- web/src/styles.css | 3 + 20 files changed, 493 insertions(+), 17 deletions(-) create mode 100644 specs/006-text-formatting.md create mode 100644 src/core/format.ts create mode 100644 tests/format.test.ts create mode 100644 web/src/lib/formatted.test.tsx create mode 100644 web/src/lib/formatted.tsx diff --git a/Dockerfile b/Dockerfile index f608704..effcecb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ COPY package.json bun.lock ./ COPY web/package.json ./web/ RUN bun install --frozen-lockfile COPY web ./web +# The web app shares the answer-formatting parser with the server. +COPY src/core/format.ts ./src/core/format.ts RUN bun run web:build # Stage 2: the runtime image — server source plus the built web bundle. diff --git a/docs/guide/commands.md b/docs/guide/commands.md index 3877f79..89676db 100644 --- a/docs/guide/commands.md +++ b/docs/guide/commands.md @@ -119,6 +119,11 @@ Anyone can DM the bot directly: naming what's missing and your answers kept; nothing posts until all are filled. (Google Chat can't grey out Submit for apps yet.) Answer the blockers question with "none" when there are none. The skip reason works the same way. +- **Formatting:** `*bold*`, `_italic_`, `~strike~`, `__underline__`, + `` `code` `` and `- ` lists show as formatting on the posted card, in + blocker cards and in the web app. Exports, webhooks, the API and MCP keep + the text exactly as typed. Plain-text messages such as nudges and the + digest show Chat's own syntax, without underline. - **Pre-fill:** the "yesterday" answer is pre-filled from your previous "today" answer. - **Late submissions** after the deadline still post, flagged *late*, until diff --git a/docs/guide/user-guide.md b/docs/guide/user-guide.md index 7bac840..377bf96 100644 --- a/docs/guide/user-guide.md +++ b/docs/guide/user-guide.md @@ -20,6 +20,10 @@ in a Google Chat DM. Reveiwed → Reviewed*. Submit again and your answers post exactly as typed. Code in backticks, links, @mentions, IDs like `PROJ-1423`, words with digits or capitals, and names are left alone. + Answers take Chat's shorthand: `*bold*`, `_italic_`, `~strike~`, + `__underline__`, `` `code` ``, and `- ` at the start of a line for a + list. Each field's hint lists them. Markers only count right against + the text, so `snake_case` and `2*3*4` stay as typed. 2. Your answers post as your card under today's thread in the team space. Your mood never appears there: it's seen in the web app only. "Yesterday" comes pre-filled from your previous "today". diff --git a/site/index.html b/site/index.html index 1b12107..1d512f8 100644 --- a/site/index.html +++ b/site/index.html @@ -69,7 +69,7 @@

Daily standups that run themselves.
No meeting. No SaaS. Your data.

How it works

-
09:30

Everyone gets a card

One DM, one form: yesterday, today, blockers, and a private mood with an optional comment. Likely typos are pointed out before posting, checked on your own server. Edit until the deadline; the posted card updates in place. After it, a manager can approve a change.

+
09:30

Everyone gets a card

One DM, one form: yesterday, today, blockers, and a private mood with an optional comment. Chat’s *bold*, _italic_ and - lists work in answers. Likely typos are pointed out before posting, checked on your own server. Edit until the deadline; the posted card updates in place. After it, a manager can approve a change.

As they answer

Answers land in one thread

Each answer is a card under today’s thread in your space. Away people are never nagged or counted as missing.

11:30

The wrap-up tells the truth

How many submitted and exactly who didn’t. Blockers open as items you can tag, acknowledge and resolve.

diff --git a/specs/006-text-formatting.md b/specs/006-text-formatting.md new file mode 100644 index 0000000..25d1a91 --- /dev/null +++ b/specs/006-text-formatting.md @@ -0,0 +1,106 @@ +# 006: Text formatting in answers + +| | | +| --- | --- | +| Status | Done | +| Size | Feature (cards, web app, no schema change) | +| Figma | [Specs › 006 Text formatting](https://www.figma.com/design/OQfcL61rbaGakS2FjBKasI?node-id=166-79) | +| PR | [#107](https://github.com/asyncup-dev/asyncup/pull/107) | + +## Problem + +People already type Chat's shorthand in their answers, such as `*done*` +or a few `- ` lines, out of habit. AsyncUp posts it as literal +asterisks and dashes, so answers are harder to scan than the same +message sent in Chat. Separately, answer and blocker cards pass typed +text straight into card HTML today, so an answer containing `` or a +link tag is rendered as markup instead of shown as typed. + +## User stories + +- As someone answering, I want the shorthand I use in Chat to work in + my answers, so a list of tickets reads as a list. +- As someone reading the thread or the web app, I want emphasis and code + to show as formatting, not as stray symbols. +- As anyone integrating with AsyncUp, I want the text exactly as typed + in the API, exports and webhooks. + +## Acceptance criteria + +The shorthand + +1. `*bold*`, `_italic_`, `~strike~`, `__underline__` and `` `code` `` + are formatted. A line starting with `- ` is a list item; consecutive + items form one list. +2. A marker only counts when it sits right against the text, at a word + edge, on one line: `snake_case`, `2*3*4`, `file_name.ts`, `* not + bold *` and an unmatched `*` stay as typed. +3. Nothing inside backticks is formatted. +4. Any other HTML in an answer is shown as typed, never rendered. + +Where it shows + +5. The posted answer card renders the shorthand as card formatting. +6. Blocker cards (the owner's card and the tagged person's card) render + it in the blocker text. +7. Plain-text Chat messages that quote answers or blockers (nudges, + escalations, blocker updates, the weekly digest) keep Chat's own + syntax, which Chat renders itself. `__underline__` loses its markers + there, because Chat text messages can't underline. +8. The web app renders it in History, My standups, Blockers, Reports + and the standup overview, without inserting raw HTML. +9. The API, MCP, CSV export and webhooks return the text exactly as + typed. + +Writing it + +10. Each answer field in the Chat form shows the hint "Formatting: + \*bold\* \_italic\_ \~strike\~ \_\_underline\_\_ \`code\`, and - for a + list." The blockers field keeps "Write "none" if you have no + blockers" before it. +11. Spelling suggestions ignore the markers, so `__teh__` is still + checked and `*` is never flagged. + +## Non-goals + +- Numbered lists, nested lists, headings, links and block quotes. +- Code blocks across several lines. +- A formatting toolbar or preview. +- Formatting in mood comments, skip reasons or blocker updates. +- Changing stored answers. + +## Design impact + +| Area | Change | +| --- | --- | +| Web app | Answers and blocker text render formatting in History, My standups, Blockers, Reports and the standup overview | +| Chat cards and dialogs | Posted answer and blocker cards render formatting; the answer form's hints list the shorthand | +| API and OpenAPI | None. Text stays as typed | +| MCP | None | +| Database | None | +| Permissions | None | +| Docs | `user-guide.md` (formatting answers), `commands.md` (behaviour notes) | +| Website | `site/index.html`: a few words in "Everyone gets a card" | +| Google APIs and scopes | None | + +How it works: + +- One parser in `src/core/format.ts` turns text into a small tree: + text, bold, italic, strike, underline, code and list items. Two + renderers use it: card HTML (escaping everything first) and Chat + text (dropping `__` markers). The web app imports the same parser and + builds React elements from its tree (`web/src/lib/formatted.tsx`). +- Card HTML uses the tags Google documents for cards: ``, ``, + ``, ``, ``, `
  • ` and `
    ` (checked 16 Oct 2026, + developers.google.com/workspace/chat/format-messages, last updated + 9 Oct 2026). +- Spelling passes text with markers blanked out to Harper, so positions + still line up. + +## Open questions + +None. Decided with the maintainer on 16 Oct 2026: + +- The shorthand is Chat's own, plus `__underline__`. +- Blocker text gets the same formatting as answers. +- Stored text, the API and integrations keep the text as typed. diff --git a/src/adapters/gchat/cards.ts b/src/adapters/gchat/cards.ts index 5d61300..f98c66f 100644 --- a/src/adapters/gchat/cards.ts +++ b/src/adapters/gchat/cards.ts @@ -1,5 +1,6 @@ import type { PromptState } from '../../core/adapter.js'; import { dayLabel, untilLabel, type EditRequestView } from '../../core/edit-requests.js'; +import { toCardHtml } from '../../core/format.js'; import { describeDates, type TimeOffRequest } from '../../core/schedule.js'; import { DateTime } from 'luxon'; import { LIMITS } from '../../core/validation.js'; @@ -154,6 +155,8 @@ export function reminderMessage(standup: Standup, run: Run, endpoint: string | n }; } +const FORMAT_HINT = 'Formatting: *bold* _italic_ ~strike~ __underline__ `code`, and - for a list.'; + /** * Modal dialog built from the standup's question list. * Inputs are named q0..qN; prefill values align with the questions array. @@ -175,7 +178,7 @@ export function standupDialog( label: `${question} (required)`, type: 'MULTIPLE_LINE', value: prefill[i] || undefined, - hintText: isBlockerQuestion(question) ? 'Write "none" if you have no blockers' : undefined, + hintText: isBlockerQuestion(question) ? `Write "none" if you have no blockers. ${FORMAT_HINT}` : FORMAT_HINT, }, })), ]; @@ -274,7 +277,7 @@ export function tagBlockerDialog(blocker: Blocker, endpoint: string | null = nul { header: `Blocker #${blocker.id}`, widgets: [ - { textParagraph: { text: blocker.text } }, + { textParagraph: { text: toCardHtml(blocker.text) } }, { selectionInput: { name: 'people', @@ -319,7 +322,7 @@ export function blockerCard(standup: Standup, blocker: Blocker, note: string, en { decoratedText: { topLabel: `Reported by ${blocker.displayName} on ${blocker.openedDate}`, - text: blocker.text, + text: toCardHtml(blocker.text), wrapText: true, }, }, @@ -474,8 +477,8 @@ export function submissionMessage(submission: Submission) { isBlockerQuestion(a.question) && !isRealBlocker(a.answer) ? '✅ None' : isBlockerQuestion(a.question) - ? `⚠️ ${a.answer}` - : a.answer, + ? `⚠️ ${toCardHtml(a.answer)}` + : toCardHtml(a.answer), wrapText: true, }, })), diff --git a/src/core/blocker-service.ts b/src/core/blocker-service.ts index 2034c29..6546b4a 100644 --- a/src/core/blocker-service.ts +++ b/src/core/blocker-service.ts @@ -1,3 +1,4 @@ +import { toChatText } from './format.js'; import { DateTime } from 'luxon'; import type { ChatAdapter } from './adapter.js'; import type { Mention } from './commands.js'; @@ -58,7 +59,7 @@ export class BlockerService { await this.adapter.postText( standup.spaceName, - `🤝 ${taggedBy.displayName} tagged ${tagged.join(', ')} on blocker #${blocker.id}: "${blocker.text}" (${blocker.displayName})`, + `🤝 ${taggedBy.displayName} tagged ${tagged.join(', ')} on blocker #${blocker.id}: "${toChatText(blocker.text)}" (${blocker.displayName})`, blockerThreadKey(blocker), ); return `✅ Tagged ${tagged.join(', ')} on blocker #${blocker.id} — they got a DM. It now needs an explicit \`blocker ${blocker.id} resolve\`.`; @@ -72,7 +73,7 @@ export class BlockerService { if (!mine) return 'not_tagged'; if (mine.acknowledgedAt) return 'already_acked'; await this.repo.ackBlockerTag(blockerId, user.userName, this.now().toISO()!); - await this.notifyOwner(blocker, `✋ ${user.displayName} acknowledged your blocker: "${blocker.text}"`); + await this.notifyOwner(blocker, `✋ ${user.displayName} acknowledged your blocker: "${toChatText(blocker.text)}"`); return 'acked'; } @@ -93,7 +94,7 @@ export class BlockerService { await this.broadcast( standup, blocker, - `📝 Update on blocker #${blocker.id} ("${blocker.text}") from ${user.displayName}:\n${text}`, + `📝 Update on blocker #${blocker.id} ("${toChatText(blocker.text)}") from ${user.displayName}:\n${text}`, user.userName, ); return 'ok'; @@ -117,7 +118,7 @@ export class BlockerService { await this.broadcast( standup, blocker, - `✅ Blocker #${blocker.id} resolved by ${user.displayName}: "${blocker.text}" (open since ${blocker.openedDate})`, + `✅ Blocker #${blocker.id} resolved by ${user.displayName}: "${toChatText(blocker.text)}" (open since ${blocker.openedDate})`, user.userName, ); return 'resolved'; diff --git a/src/core/format.ts b/src/core/format.ts new file mode 100644 index 0000000..c41ddcd --- /dev/null +++ b/src/core/format.ts @@ -0,0 +1,150 @@ +/** + * Chat's shorthand in answers: *bold*, _italic_, ~strike~, `code` and + * "- " lists, plus __underline__. One parser, rendered as card HTML, as + * Chat text, or (in the web app) as React elements. Stored text never changes. + */ + +export type Inline = { type: 'text'; text: string } | { type: 'code'; text: string } | { type: 'bold' | 'italic' | 'strike' | 'underline'; children: Inline[] }; +export type Block = { type: 'lines'; lines: Inline[][] } | { type: 'list'; items: Inline[][] }; + +type Marker = '__' | '*' | '_' | '~'; +const KIND: Record = { '*': 'bold', _: 'italic', '~': 'strike', __: 'underline' }; +const SYMBOL: Record = { bold: '*', italic: '_', strike: '~' }; +const LIST_ITEM = /^(\s*)- (.*)$/; + +const isWord = (ch: string | undefined) => ch !== undefined && /[\p{L}\p{N}]/u.test(ch); +const isSpace = (ch: string | undefined) => ch === undefined || /\s/.test(ch); + +function markerAt(s: string, i: number): Marker | null { + if (s.startsWith('__', i)) return '__'; + const ch = s[i]!; + return ch === '*' || ch === '_' || ch === '~' ? ch : null; +} + +/** A marker opens at a word edge, right against the text that follows. */ +function opens(s: string, i: number, m: Marker): boolean { + const before = s[i - 1]; + const after = s[i + m.length]; + return (i === 0 || (!isWord(before) && before !== m[0])) && !isSpace(after) && after !== m[0]; +} + +/** Where the matching marker closes, skipping code spans; -1 when it never does. */ +function closeAt(s: string, from: number, m: Marker): number { + for (let k = from + 1; k < s.length; k++) { + if (s[k] === '`') { + const end = s.indexOf('`', k + 1); + if (end > k + 1) k = end; + continue; + } + const after = s[k + m.length]; + if (s.startsWith(m, k) && !isSpace(s[k - 1]) && !isWord(after) && after !== m[0]) return k; + } + return -1; +} + +/** Parses one line; `marks` collects where each formatting marker sits. */ +function inline(s: string, offset = 0, marks?: [number, number][]): Inline[] { + const out: Inline[] = []; + let text = ''; + const flush = () => { + if (text) out.push({ type: 'text', text }); + text = ''; + }; + for (let i = 0; i < s.length; ) { + if (s[i] === '`') { + const end = s.indexOf('`', i + 1); + if (end > i + 1) { + flush(); + out.push({ type: 'code', text: s.slice(i + 1, end) }); + i = end + 1; + continue; + } + } + const m = markerAt(s, i); + if (m && opens(s, i, m)) { + const end = closeAt(s, i + m.length, m); + if (end >= 0) { + flush(); + marks?.push([offset + i, m.length], [offset + end, m.length]); + out.push({ type: KIND[m], children: inline(s.slice(i + m.length, end), offset + i + m.length, marks) }); + i = end + m.length; + continue; + } + } + text += s[i]; + i++; + } + flush(); + return out; +} + +export function parseFormatted(text: string): Block[] { + // Text from a request body may not be a string at all. + text = typeof text === 'string' ? text : ''; + const blocks: Block[] = []; + for (const line of text.split(/\r?\n/)) { + const item = LIST_ITEM.exec(line); + const last = blocks[blocks.length - 1]; + if (item) { + if (last?.type === 'list') last.items.push(inline(item[2]!)); + else blocks.push({ type: 'list', items: [inline(item[2]!)] }); + } else if (last?.type === 'lines') last.lines.push(inline(line)); + else blocks.push({ type: 'lines', lines: [inline(line)] }); + } + return blocks; +} + +const escape = (text: string) => text.replace(/&/g, '&').replace(//g, '>'); +const TAG = { bold: 'b', italic: 'i', strike: 's', underline: 'u' } as const; + +function cardInline(nodes: Inline[]): string { + return nodes + .map((n) => { + if (n.type === 'text') return escape(n.text); + if (n.type === 'code') return `${escape(n.text)}`; + return `<${TAG[n.type]}>${cardInline(n.children)}`; + }) + .join(''); +} + +/** Card HTML: everything typed is escaped, then only the shorthand becomes tags. */ +export function toCardHtml(text: string): string { + return parseFormatted(text) + .map((b) => (b.type === 'list' ? `
      ${b.items.map((i) => `
    • ${cardInline(i)}
    • `).join('')}
    ` : b.lines.map(cardInline).join('
    '))) + .join(''); +} + +function chatInline(nodes: Inline[]): string { + return nodes + .map((n) => { + if (n.type === 'text') return n.text; + if (n.type === 'code') return `\`${n.text}\``; + // Chat text messages have no underline. + if (n.type === 'underline') return chatInline(n.children); + return `${SYMBOL[n.type]}${chatInline(n.children)}${SYMBOL[n.type]}`; + }) + .join(''); +} + +/** For plain Chat messages, which render Chat's own syntax themselves. */ +export function toChatText(text: string): string { + return parseFormatted(text) + .map((b) => (b.type === 'list' ? b.items.map((i) => `- ${chatInline(i)}`).join('\n') : b.lines.map(chatInline).join('\n'))) + .join('\n'); +} + +/** The text with formatting markers swapped for spaces, so a spell checker's positions still line up. */ +export function blankMarkers(text: string): string { + text = typeof text === 'string' ? text : ''; + const blank = new Set(); + let offset = 0; + for (const line of text.split('\n')) { + const item = LIST_ITEM.exec(line); + const marks: [number, number][] = []; + if (item) inline(item[2]!, offset + item[1]!.length + 2, marks); + else inline(line, offset, marks); + for (const [at, length] of marks) for (let k = at; k < at + length; k++) blank.add(k); + offset += line.length + 1; + } + return text.replace(/[\s\S]/g, (ch, k: number) => (blank.has(k) ? ' ' : ch)); +} diff --git a/src/core/insights.ts b/src/core/insights.ts index 97a2e17..9d555a8 100644 --- a/src/core/insights.ts +++ b/src/core/insights.ts @@ -1,3 +1,4 @@ +import { toChatText } from './format.js'; import { DateTime } from 'luxon'; import type { Repo } from '../db/repo.js'; import { MOOD_SCORE, standupDays, WEEKDAYS, type Standup, type WeeklyDigest } from './types.js'; @@ -131,7 +132,7 @@ export function digestText(digest: WeeklyDigest): string { if (digest.openBlockers.length > 0) { lines.push('*Still open:*'); for (const b of digest.openBlockers.slice(0, 10)) { - lines.push(` ⚠️ ${b.displayName}: ${b.text} (${b.ageDays}d)`); + lines.push(` ⚠️ ${b.displayName}: ${toChatText(b.text)} (${b.ageDays}d)`); } } return lines.join('\n'); diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index b342131..3120ab3 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -1,3 +1,4 @@ +import { toChatText } from './format.js'; import { DateTime } from 'luxon'; import type { ChatAdapter } from './adapter.js'; import type { LeavePresence } from './leave-presence.js'; @@ -327,7 +328,7 @@ export class Scheduler { const lines = stale.map((b) => { const age = Math.floor(today.diff(DateTime.fromISO(b.openedDate), 'days').days); - return `⚠️ ${b.displayName}: ${b.text} (open ${age}d)`; + return `⚠️ ${b.displayName}: ${toChatText(b.text)} (open ${age}d)`; }); await this.adapter.sendDm( standup.escalateUserName!, diff --git a/src/core/spelling.ts b/src/core/spelling.ts index 24c462b..848be11 100644 --- a/src/core/spelling.ts +++ b/src/core/spelling.ts @@ -1,4 +1,5 @@ import type { Repo } from '../db/repo.js'; +import { blankMarkers } from './format.js'; import type { SettingsService } from './settings.js'; export type SpellingDialect = 'en-GB' | 'en-US'; @@ -61,6 +62,8 @@ export class Spelling { async typos(texts: string[]): Promise { const s = await this.settings.get(); if (!s.spellingEnabled || !this.engine || texts.every((t) => !t.trim())) return []; + // Markers become spaces, so __teh__ is checked as teh and positions still line up. + texts = texts.map(blankMarkers); let lints: RawLint[]; try { lints = await this.engine.lint(texts, s.spellingDialect as SpellingDialect); diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..0d15a6b --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'bun:test'; +import { blockerCard, standupDialog, submissionMessage, tagBlockerDialog } from '../src/adapters/gchat/cards.js'; +import { blankMarkers, parseFormatted, toCardHtml, toChatText } from '../src/core/format.js'; +import { Spelling, type SpellEngine } from '../src/core/spelling.js'; +import { DEFAULT_QUESTIONS, type Blocker } from '../src/core/types.js'; +import { ANSWERS, makeStack, seedStandup } from './helpers.js'; + +describe('the shorthand', () => { + it('formats bold, italic, strike, underline and code (criterion 1)', () => { + expect(toCardHtml('*done* _soon_ ~maybe~ __must__ `orders-db`')).toBe('done soon maybe must orders-db'); + expect(toCardHtml('*bold with _italic_ inside*')).toBe('bold with italic inside'); + expect(toCardHtml('(*yes*), "_quoted_".')).toBe('(yes), "quoted".'); + }); + + it('turns lines starting with - into one list, and keeps other lines apart (criterion 1)', () => { + expect(toCardHtml('Today:\n- Finish the *cutover*\n- Tidy the runbook\nThen lunch')).toBe('Today:
    • Finish the cutover
    • Tidy the runbook
    Then lunch'); + expect(toCardHtml('one\ntwo')).toBe('one
    two'); + expect(toCardHtml(' - indented item')).toBe('
    • indented item
    '); + expect(parseFormatted('a\n\nb')).toEqual([{ type: 'lines', lines: [[{ type: 'text', text: 'a' }], [], [{ type: 'text', text: 'b' }]] }]); + }); + + it('only counts markers against the text, at word edges, on one line (criterion 2)', () => { + for (const plain of ['snake_case_name', '2*3*4', 'file_name.ts', '* not bold *', 'an unmatched * star', 'a ~ b', '_open only', 'trailing_', '-not a list', '*one\nline*', '__ spaced __', '**', '``']) { + expect(toCardHtml(plain)).toBe(plain.replace(/\n/g, '
    ')); + } + expect(toCardHtml('x*y* and *z*')).toBe('x*y* and z'); + expect(toCardHtml('*a*b*')).toBe('a*b'); + }); + + it('leaves text inside backticks alone (criterion 3)', () => { + expect(toCardHtml('`*not bold* and __init__`')).toBe('*not bold* and __init__'); + expect(toCardHtml('*see `a*b` here*')).toBe('see a*b here'); + }); + + it('shows any other HTML as typed (criterion 4)', () => { + expect(toCardHtml('hi & link')).toBe('<b>hi</b> & <a href="x">link</a>'); + expect(toCardHtml('`