From 919fa6ad5c7f37cbfc72dbbd240d15ee89d00fc7 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Thu, 9 Jul 2026 15:21:16 -0400 Subject: [PATCH 01/10] WIP --- components/review/Discussion.vue | 45 ++++++++++++++----- nuxt.config.ts | 30 ++++++++----- package-lock.json | 74 ++++++++++++++++++++----------- services/osm.ts | 13 ++++++ test/e2e/review.spec.ts | 75 +++++++++++++++++++++++++++++++ test/unit/services/osm.test.ts | 76 ++++++++++++++++++++++++++++++++ 6 files changed, 265 insertions(+), 48 deletions(-) diff --git a/components/review/Discussion.vue b/components/review/Discussion.vue index ca8789d..eb7aace 100644 --- a/components/review/Discussion.vue +++ b/components/review/Discussion.vue @@ -17,6 +17,7 @@ diff --git a/nuxt.config.ts b/nuxt.config.ts index 37c9941..b0b327f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -28,24 +28,32 @@ export default defineNuxtConfig({ ], sourcemap: { client: 'hidden' }, compatibilityDate: '2024-10-24', - + devServer: { + host: '0.0.0.0', + }, nitro: { - // deal with CORS issues during development + // Route every backend the SPA calls through the dev server so requests are + // same-origin — this avoids CORS when browsing under any hostname. Each + // client's base URL (VITE_* in .env) must be the matching relative prefix, + // e.g. VITE_API_URL=/api/v1/, VITE_OSM_URL=/osm/, VITE_TDEI_API_URL=/tdei/v1/. + // `changeOrigin` makes the upstream see its own Host (needed for TLS/vhosts). devProxy: { - // use these values when you want to use the existing dev backend - '/api': 'https://api.workspaces-dev.sidewalks.washington.edu/api/', - '/workspaces': 'https://osm.workspaces-dev.sidewalks.washington.edu/workspaces/', + '/api': { target: 'https://api.workspaces-dev.sidewalks.washington.edu/api/', changeOrigin: true }, + '/new-api': { target: 'https://new-api.workspaces-dev.sidewalks.washington.edu/api/', changeOrigin: true }, + '/osm': { target: 'https://osm.workspaces-dev.sidewalks.washington.edu/', changeOrigin: true }, + '/tdei': { target: 'https://api-dev.tdei.us/api/', changeOrigin: true }, + '/tdei-user': { target: 'https://portal-api-dev.tdei.us/api/', changeOrigin: true }, - // use these values when running the backend locally, e.g. the repo workspaces-backend - // '/api': 'http://localhost:8000/api/', - // '/workspaces': 'http://localhost:8000/workspaces/', + // Local backend (repo workspaces-backend) instead of the shared dev API: + // '/api': { target: 'http://localhost:8000/api/', changeOrigin: true }, + // '/osm': { target: 'http://localhost:8000/workspaces/', changeOrigin: true }, }, }, vite: { server: { - allowedHosts: [ - 'workspaces.local', - ], + // `true` disables Vite's Host-header check, so the dev server accepts + // requests for any hostname (e.g. tunnels, custom local domains). + allowedHosts: true, }, optimizeDeps: { // Pre-bundle these dependencies to avoid reloads during development: diff --git a/package-lock.json b/package-lock.json index b4a74da..2b6d9d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,6 +122,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -417,6 +418,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -624,36 +626,12 @@ "integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==", "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1429,6 +1407,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -2381,6 +2360,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.6.tgz", "integrity": "sha512-5VOwxUcoM/z6w4c75hQrikHpY+TzjTLZQ+QnuO7KajyGx0IJBLVy1lw25oy79leF+GgyjJJO1cHfUfWeuEDCzA==", "license": "MIT", + "peer": true, "dependencies": { "c12": "^3.3.4", "consola": "^3.4.2", @@ -2499,6 +2479,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.8.tgz", "integrity": "sha512-igfWuMF0x0Pmx/XwhPwH/bcXgbuwNnjUjqxCAsY6VQhmGKo0e9soJq3Q0ohj+rBkBfX6o2ysTP1/t2M82aK4qA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "^3.5.35", "defu": "^6.1.7", @@ -2892,6 +2873,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2940,6 +2922,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", @@ -2957,6 +2940,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -4398,6 +4382,7 @@ "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.61.0" }, @@ -4419,6 +4404,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -5778,6 +5764,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz", "integrity": "sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6013,6 +6000,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.1.tgz", "integrity": "sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6092,6 +6080,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.26.1.tgz", "integrity": "sha512-epxUhc5ecxsH39lzNejc2WxFPXAXWGs9g2ofKDrIaoSlZlfFHf89/sEGSz048a46E5Sb+fYCtzUvRUUx+aG4xw==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6171,6 +6160,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz", "integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -6185,6 +6175,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz", "integrity": "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", @@ -6403,7 +6394,8 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/node": { "version": "26.0.0", @@ -6411,6 +6403,7 @@ "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -6513,6 +6506,7 @@ "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", @@ -7392,6 +7386,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" @@ -7402,6 +7397,7 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.38", @@ -7551,6 +7547,7 @@ "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "js-beautify": "^1.14.9", "vue-component-type-helpers": "^3.0.0" @@ -7603,6 +7600,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8027,6 +8025,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -8207,6 +8206,7 @@ "resolved": "https://registry.npmjs.org/bootstrap-vue-next/-/bootstrap-vue-next-0.43.9.tgz", "integrity": "sha512-bhqdVR9eFgcVMcae0lvbYJBKDR8KwGtSfoBGZa2q01Eg1SMBVwM+rh8qKHwt+YIe9rwU6b0d0KO+sPJMOnyCPQ==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/bootstrap-vue-next" @@ -8272,6 +8272,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8606,7 +8607,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cjs-module-lexer": { "version": "2.2.0", @@ -9625,6 +9627,7 @@ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -9693,6 +9696,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11045,6 +11049,7 @@ "integrity": "sha512-bKrsQnFNpcjZG0UPsH7UMN7Oyp3AB42LXk2GuiQmu7l4QFxH7lsw5T1eWEtE2+vbIFrTC45sbNSB2pkB8MTfKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -12410,6 +12415,7 @@ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", @@ -13580,6 +13586,7 @@ "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.8.tgz", "integrity": "sha512-r/DGE4cNkEDclOw9tbJ18zqu+ix3me+7QCfumPdl5lBXGWgCajskzuq/HzDkHKfIZsn7ACVEjMLRNA2teh++bQ==", "license": "MIT", + "peer": true, "dependencies": { "@dxup/nuxt": "^0.4.1", "@nuxt/cli": "^3.35.2", @@ -13917,6 +13924,7 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -13981,6 +13989,7 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.133.0.tgz", "integrity": "sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==", "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "^0.133.0" }, @@ -14308,6 +14317,7 @@ "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -14377,6 +14387,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -15072,6 +15083,7 @@ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "license": "MIT", + "peer": true, "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", @@ -15437,6 +15449,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15724,6 +15737,7 @@ "integrity": "sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -16830,6 +16844,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17031,6 +17046,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, @@ -17322,6 +17338,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17641,6 +17658,7 @@ "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", @@ -17747,6 +17765,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/compiler-sfc": "3.5.38", @@ -17791,6 +17810,7 @@ "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", @@ -17830,6 +17850,7 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz", "integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", @@ -17943,6 +17964,7 @@ "integrity": "sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.5" diff --git a/services/osm.ts b/services/osm.ts index b6c1fa1..59d1097 100644 --- a/services/osm.ts +++ b/services/osm.ts @@ -469,6 +469,19 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { }); } + async postNoteComment( + workspaceId: WorkspaceId, + noteId: number, + message: string, + ): Promise { + const body = new FormData(); + body.append('text', message); + + await this._post(`notes/${noteId}/comment`, body, { + headers: { 'X-Workspace': String(workspaceId) }, + }); + } + async getNotes(workspaceId: WorkspaceId, includeClosed: boolean): Promise { const params = new URLSearchParams(); // Fetch the maximum number of notes: diff --git a/test/e2e/review.spec.ts b/test/e2e/review.spec.ts index 885f18e..657d55e 100644 --- a/test/e2e/review.spec.ts +++ b/test/e2e/review.spec.ts @@ -404,6 +404,81 @@ test.describe('workspace review', () => { // unreliable headless. The selection -> overlay wiring is asserted above. }); + // @test e2e: the discussion panel lists an item's comments and a newly posted + // comment appears immediately + test('a note discussion lists comments and shows a newly posted one', async ({ page }) => { + await seedAuthenticatedSession(page); + await stubReviewApis(page); + + // Accept the note comment POST (osmClient.postNoteComment) so the component's + // optimistic append runs. + await page.route('**/osm/api/0.6/notes/99/comment**', route => + route.fulfill({ status: 200, body: '' }) + ); + + await page.goto('/workspace/1/review'); + + // Select the note (deterministic like feedback: the overlay does not depend + // on the adiff/WebGL map). + const noteItem = sidebar(page) + .locator('.review-item', { hasText: 'Curb ramp is missing here' }); + await noteItem.click(); + + const overlay = page.locator('.review-overlay'); + await expect(overlay).toBeVisible(); + + // Open the discussion panel via the toolbar's chat toggle. app-icon renders + // an empty whose variant is a `md-` class, so match on that. + await overlay.locator('.review-toolbar button:has(.md-chat_bubble_outline)').click(); + + const discussion = page.locator('.review-discussion'); + await expect(discussion).toBeVisible(); + // The note's existing comment is listed. + await expect(discussion).toContainText('Curb ramp is missing here'); + + // Posting a comment reflects it immediately (regression: send used to clear + // the input without ever surfacing the new comment). + await discussion.locator('textarea').fill('Confirmed, still missing.'); + await discussion.locator('button[title="Send"]').click(); + + await expect(discussion).toContainText('Confirmed, still missing.'); + }); + + // @test e2e: if posting a comment fails, an error toast is shown and the draft + // is kept so the user can retry + test('a failed comment post shows an error toast and keeps the draft', async ({ page }) => { + await seedAuthenticatedSession(page); + await stubReviewApis(page); + + // Fail the note comment POST. + await page.route('**/osm/api/0.6/notes/99/comment**', route => + route.fulfill({ status: 500, body: 'boom' }) + ); + + await page.goto('/workspace/1/review'); + + await sidebar(page).locator('.review-item', { hasText: 'Curb ramp is missing here' }).click(); + + const overlay = page.locator('.review-overlay'); + await expect(overlay).toBeVisible(); + await overlay.locator('.review-toolbar button:has(.md-chat_bubble_outline)').click(); + + const discussion = page.locator('.review-discussion'); + await expect(discussion).toBeVisible(); + + await discussion.locator('textarea').fill('This should fail to post.'); + await discussion.locator('button[title="Send"]').click(); + + // An error toast appears (transient/animated, so assert its text, not a snapshot). + const errorToast = page.locator('.Toastify__toast--error'); + await expect(errorToast).toBeVisible(); + await expect(errorToast).toContainText(/failed to post/i); + + // The draft is retained (not cleared) and no comment was added to the list. + await expect(discussion.locator('textarea')).toHaveValue('This should fail to post.'); + await expect(discussion.locator('.message-list')).not.toContainText('This should fail to post.'); + }); + // @test e2e: validate that all the API calls used on this page match the Swagger spec test('new-API calls conform to the OpenAPI spec', async ({ page }) => { await seedAuthenticatedSession(page); diff --git a/test/unit/services/osm.test.ts b/test/unit/services/osm.test.ts index 979ed8f..697716f 100644 --- a/test/unit/services/osm.test.ts +++ b/test/unit/services/osm.test.ts @@ -70,3 +70,79 @@ describe('OsmApiClient.getOsmChange', () => { expect(element.timestamp.toISOString()).toBe('2026-07-01T00:00:00.000Z'); }); }); + +describe('OsmApiClient.getChangesetComments', () => { + it('requests the changeset with the discussion and returns its comments', async () => { + const urls: string[] = []; + server.use( + http.get(`${OSM_API_BASE}changeset/5.json`, ({ request }) => { + urls.push(request.url); + return HttpResponse.json({ + changeset: { + id: 5, + created_at: '2026-07-01T00:00:00Z', + closed_at: '2026-07-01T01:00:00Z', + comments: [ + { id: 1, text: 'looks good', user: 'alice', date: '2026-07-01T00:30:00Z' } + ] + } + }); + }) + ); + + const comments = await makeClient().getChangesetComments(1, 5); + + // The comments only come back when the changeset is fetched with the flag. + expect(urls.some(u => u.includes('include_discussion=true'))).toBe(true); + expect(comments).toHaveLength(1); + expect(comments[0]!.text).toBe('looks good'); + expect(comments[0]!.date).toBeInstanceOf(Date); + }); + + // Regression: the review discussion used to gate the fetch on the list + // payload's comments_count; getChangesetComments itself must simply return [] + // when the changeset carries no comments. + it('returns an empty array when the changeset has no comments', async () => { + server.use( + http.get(`${OSM_API_BASE}changeset/5.json`, () => + HttpResponse.json({ + changeset: { id: 5, created_at: '2026-07-01T00:00:00Z', closed_at: '2026-07-01T01:00:00Z' } + }) + ) + ); + + await expect(makeClient().getChangesetComments(1, 5)).resolves.toEqual([]); + }); +}); + +describe('OsmApiClient comment posting', () => { + it('posts a changeset comment with the message text', async () => { + const posted: Array = []; + server.use( + http.post(`${OSM_API_BASE}changeset/5/comment`, async ({ request }) => { + const form = await request.formData(); + posted.push(form.get('text') as string | null); + return new HttpResponse(null, { status: 200 }); + }) + ); + + await makeClient().postChangesetComment(1, 5, 'nice work'); + + expect(posted).toEqual(['nice work']); + }); + + it('posts a note comment to the notes endpoint with the message text', async () => { + const posted: Array = []; + server.use( + http.post(`${OSM_API_BASE}notes/9/comment`, async ({ request }) => { + const form = await request.formData(); + posted.push(form.get('text') as string | null); + return new HttpResponse(null, { status: 200 }); + }) + ); + + await makeClient().postNoteComment(1, 9, 'thanks for flagging'); + + expect(posted).toEqual(['thanks for flagging']); + }); +}); From 035a25088e2678df3a03cc5d25a48382ce629c6c Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Thu, 9 Jul 2026 15:37:56 -0400 Subject: [PATCH 02/10] Match Rapid format re: form fields --- services/osm.ts | 14 ++++++-------- test/unit/services/osm.test.ts | 25 +++++++++++++------------ 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/services/osm.ts b/services/osm.ts index 59d1097..0920bce 100644 --- a/services/osm.ts +++ b/services/osm.ts @@ -461,10 +461,9 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { changesetId: number, message: string, ): Promise { - const body = new FormData(); - body.append('text', message); - - await this._post(`changeset/${changesetId}/comment`, body, { + // The OSM API reads the comment `text` from the query string, not a request + // body — a multipart body is ignored (the backend then 401s the write). + await this._post(`changeset/${changesetId}/comment?text=${encodeURIComponent(message)}`, undefined, { headers: { 'X-Workspace': String(workspaceId) }, }); } @@ -474,10 +473,9 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { noteId: number, message: string, ): Promise { - const body = new FormData(); - body.append('text', message); - - await this._post(`notes/${noteId}/comment`, body, { + // As with changeset comments, the note comment `text` goes in the query + // string (matches the working `POST /api/0.6/notes?...&text=` shape). + await this._post(`notes/${noteId}/comment?text=${encodeURIComponent(message)}`, undefined, { headers: { 'X-Workspace': String(workspaceId) }, }); } diff --git a/test/unit/services/osm.test.ts b/test/unit/services/osm.test.ts index 697716f..f04c29b 100644 --- a/test/unit/services/osm.test.ts +++ b/test/unit/services/osm.test.ts @@ -116,33 +116,34 @@ describe('OsmApiClient.getChangesetComments', () => { }); describe('OsmApiClient comment posting', () => { - it('posts a changeset comment with the message text', async () => { - const posted: Array = []; + // Regression: the comment text must be sent in the query string, not a + // multipart body — the OSM API only reads params[:text] from the query, and + // this backend 401s a write whose text it can't find. + it('posts a changeset comment with the message text as a query parameter', async () => { + const urls: string[] = []; server.use( - http.post(`${OSM_API_BASE}changeset/5/comment`, async ({ request }) => { - const form = await request.formData(); - posted.push(form.get('text') as string | null); + http.post(`${OSM_API_BASE}changeset/5/comment`, ({ request }) => { + urls.push(request.url); return new HttpResponse(null, { status: 200 }); }) ); await makeClient().postChangesetComment(1, 5, 'nice work'); - expect(posted).toEqual(['nice work']); + expect(urls.some(u => u.includes('text=nice%20work'))).toBe(true); }); - it('posts a note comment to the notes endpoint with the message text', async () => { - const posted: Array = []; + it('posts a note comment to the notes endpoint with the message text as a query parameter', async () => { + const urls: string[] = []; server.use( - http.post(`${OSM_API_BASE}notes/9/comment`, async ({ request }) => { - const form = await request.formData(); - posted.push(form.get('text') as string | null); + http.post(`${OSM_API_BASE}notes/9/comment`, ({ request }) => { + urls.push(request.url); return new HttpResponse(null, { status: 200 }); }) ); await makeClient().postNoteComment(1, 9, 'thanks for flagging'); - expect(posted).toEqual(['thanks for flagging']); + expect(urls.some(u => u.includes('text=thanks%20for%20flagging'))).toBe(true); }); }); From da161c3c74ddb5764327a67279954eded7bd7320 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 17:09:30 -0400 Subject: [PATCH 03/10] Changeset comment feature + notes comment feature --- components/review/Discussion.vue | 34 ++++++++++++++++-- package-lock.json | 39 +++++++++++++++++++++ services/osm.ts | 60 +++++++++++++++++++++----------- test/unit/services/osm.test.ts | 38 ++++++++++++++++++++ 4 files changed, 149 insertions(+), 22 deletions(-) diff --git a/components/review/Discussion.vue b/components/review/Discussion.vue index ca8789d..6e81a28 100644 --- a/components/review/Discussion.vue +++ b/components/review/Discussion.vue @@ -22,7 +22,7 @@ import { osmClient } from '~/services/index'; import type ChatBox from '~/components/ChatBox.vue'; import type { ChatMessage } from '~/components/ChatBox.vue'; -import type { OsmNote } from '~/types/osm'; +import type { OsmChangeset, OsmNote } from '~/types/osm'; interface Props { item: ReviewListItem; @@ -62,7 +62,11 @@ async function refreshChangeset() { } function refreshNote() { - messages.value = (props.item.data as OsmNote).comments.map(comment => ({ + messages.value = noteComments(props.item.data as OsmNote); +} + +function noteComments(note: OsmNote): ChatMessage[] { + return note.comments.map(comment => ({ id: Symbol(), user: comment.user, date: comment.date, @@ -74,12 +78,38 @@ async function send(message: string) { if (props.item.isChangeset) { await sendChangeset(message); } + else if (props.item.isNote) { + await sendNote(message); + } chat.value?.clear(); } async function sendChangeset(message: string) { await osmClient.postChangesetComment(workspaceId, props.item.id, message); + + // Re-fetch the discussion so the newly posted comment appears. Fetch directly + // rather than via refreshChangeset(): its hasComments guard reads the + // changeset's cached comments_count, which is stale right after posting and + // would short-circuit to an empty list on a first comment. + messages.value = await osmClient.getChangesetComments(workspaceId, props.item.id); + + // Sync the shared item's cached count so the sidebar/toolbar badges update + // too — commentCount reads comments_count for changesets. props.item is the + // reactive review-list entry, so this mutation propagates to those badges. + (props.item.data as OsmChangeset).comments_count = messages.value.length; +} + +async function sendNote(message: string) { + // The comment endpoint returns the updated note, so refresh straight from it. + const note = await osmClient.postNoteComment(workspaceId, props.item.id, message); + + if (note) { + // Sync the shared item's comments so the count badges update too — + // commentCount reads comments.length for notes. + (props.item.data as OsmNote).comments = note.comments; + messages.value = noteComments(note); + } } diff --git a/package-lock.json b/package-lock.json index b4a74da..0b7e342 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2080,6 +2080,28 @@ } } }, + "node_modules/@nuxt/cli/node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@nuxt/cli/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@nuxt/devalue": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz", @@ -2660,6 +2682,23 @@ "vite": ">=6.0" } }, + "node_modules/@nuxt/test-utils/node_modules/crossws": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.9.tgz", + "integrity": "sha512-iWx+1OMSG2aOHpjyf9AESOzkwsVdS49cXM9dVrI2PDhxU5l2RIWE/KG56gk4BbAnsMoycvniJ9OnOxO9LRzHVA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "peerDependencies": { + "srvx": ">=0.11.5" + }, + "peerDependenciesMeta": { + "srvx": { + "optional": true + } + } + }, "node_modules/@nuxt/test-utils/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", diff --git a/services/osm.ts b/services/osm.ts index b6c1fa1..035c21c 100644 --- a/services/osm.ts +++ b/services/osm.ts @@ -1,5 +1,5 @@ import parseOsmChangeXml from '@osmcha/osmchange-parser'; -import type { FeatureCollection, Point } from 'geojson'; +import type { Feature, FeatureCollection, Point } from 'geojson'; import { BaseHttpClient, @@ -56,28 +56,26 @@ function formatFeatureIdPlaceholders(feature: Element) { } } -function notesGeoJsonToEntities(geoJson: FeatureCollection): OsmNote[] { - const notes = []; - - for (const feature of geoJson.features) { - const geometry = feature.geometry as Point; - const properties = feature.properties ?? { }; - - for (const comment of properties.comments) { - comment.date = new Date(comment.date); - } +function noteFeatureToEntity(feature: Feature): OsmNote { + const geometry = feature.geometry as Point; + const properties = feature.properties ?? { }; - notes.push({ - id: properties.id, - status: properties.status, - lat: geometry.coordinates[1] ?? 0, - lon: geometry.coordinates[0] ?? 0, - created_at: new Date(properties.date_created), - comments: properties.comments, - }); + for (const comment of properties.comments) { + comment.date = new Date(comment.date); } - return notes; + return { + id: properties.id, + status: properties.status, + lat: geometry.coordinates[1] ?? 0, + lon: geometry.coordinates[0] ?? 0, + created_at: new Date(properties.date_created), + comments: properties.comments, + }; +} + +function notesGeoJsonToEntities(geoJson: FeatureCollection): OsmNote[] { + return geoJson.features.map(noteFeatureToEntity); } function cleanOscForDemo(features: Element[]) { @@ -469,6 +467,28 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { }); } + async postNoteComment( + workspaceId: WorkspaceId, + noteId: number, + message: string, + ): Promise { + // OSM takes the note comment text as a query parameter and returns the + // updated note (including the new comment) as a single GeoJSON Feature. + const params = new URLSearchParams(); + params.append('text', message); + + const response = await this._post(`notes/${noteId}/comment.json?${params}`, undefined, { + headers: { + 'Accept': 'application/json', + 'X-Workspace': String(workspaceId), + }, + }); + + const feature = await response.json(); + + return feature ? noteFeatureToEntity(feature) : undefined; + } + async getNotes(workspaceId: WorkspaceId, includeClosed: boolean): Promise { const params = new URLSearchParams(); // Fetch the maximum number of notes: diff --git a/test/unit/services/osm.test.ts b/test/unit/services/osm.test.ts index 979ed8f..0328f68 100644 --- a/test/unit/services/osm.test.ts +++ b/test/unit/services/osm.test.ts @@ -70,3 +70,41 @@ describe('OsmApiClient.getOsmChange', () => { expect(element.timestamp.toISOString()).toBe('2026-07-01T00:00:00.000Z'); }); }); + +describe('OsmApiClient.postNoteComment', () => { + // Regression: the review Discussion panel fired NO request when commenting on + // a note — there was no client method and send() ignored notes entirely. This + // posts the text to the OSM note-comment endpoint and returns the updated note. + it('posts the comment text and parses the returned note', async () => { + let requestUrl: string | undefined; + + server.use( + http.post(`${OSM_API_BASE}notes/:id/comment.json`, ({ request }) => { + requestUrl = request.url; + return HttpResponse.json({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [-122.3, 47.6] }, + properties: { + id: 555, + status: 'open', + date_created: '2026-07-01T00:00:00Z', + comments: [ + { date: '2026-07-01T00:00:00Z', user: 'tester', text: 'the note', action: 'opened', html: '' }, + { date: '2026-07-02T00:00:00Z', user: 'tester', text: 'a reply', action: 'commented', html: '' } + ] + } + }); + }) + ); + + const note = await makeClient().postNoteComment(1, 555, 'a reply'); + + expect(requestUrl).toContain('notes/555/comment.json'); + expect(new URL(requestUrl!).searchParams.get('text')).toBe('a reply'); + expect(note?.id).toBe(555); + expect(note?.comments).toHaveLength(2); + // Comment dates are coerced from strings to Date objects. + expect(note?.comments[1]?.date).toBeInstanceOf(Date); + expect(note?.comments[1]?.text).toBe('a reply'); + }); +}); From 63b7c434efdadf5ed7e545d70db38cc067447eb1 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 17:52:35 -0400 Subject: [PATCH 04/10] Dev config --- .env.example | 17 ++++++++++++----- .gitignore | 1 + nuxt.config.ts | 10 ++++++---- services/osm.ts | 12 ------------ 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index ab50d81..0dbc20a 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,11 @@ # Point these at the TDEI dev environment. Tokens from one environment (dev, # stage, prod) are NOT interchangeable — all components must target the same # environment. -VITE_TDEI_API_URL=https://api-dev.tdei.us/api/v1/ -VITE_TDEI_USER_API_URL=https://portal-api-dev.tdei.us/api/v1/ +# Routed through the Nuxt devProxy (nuxt.config.ts). Direct URLs shown for reference: +# VITE_TDEI_API_URL=https://api-dev.tdei.us/api/v1/ +# VITE_TDEI_USER_API_URL=https://portal-api-dev.tdei.us/api/v1/ +VITE_TDEI_API_URL=/tdei/v1/ +VITE_TDEI_USER_API_URL=/tdei-user/v1/ # --- Workspaces Backend ------------------------------------------------------ # The workspaces-backend service, see: https://github.com/TaskarCenterAtUW/workspaces-backend @@ -21,9 +24,13 @@ VITE_TDEI_USER_API_URL=https://portal-api-dev.tdei.us/api/v1/ # VITE_API_URL=http://localhost:3000/api/v1/ # VITE_OSM_URL=http://localhost:3000/ -# Or point at the shared dev instance if you don't need to run it locally. -VITE_API_URL=https://api.workspaces-dev.sidewalks.washington.edu/api/v1/ -VITE_OSM_URL=https://osm.workspaces-dev.sidewalks.washington.edu/ +# Routed through the Nuxt devProxy (nuxt.config.ts). Direct URLs shown for reference: +# VITE_API_URL=https://api.workspaces-dev.sidewalks.washington.edu/api/v1/ +# VITE_OSM_URL=https://osm.workspaces-dev.sidewalks.washington.edu/ +# VITE_NEW_API_URL=https://new-api.workspaces-dev.sidewalks.washington.edu/api/v1/ +VITE_API_URL=/api/v1/ +VITE_OSM_URL=/osm/ +VITE_NEW_API_URL=/new-api/v1/ # --- Embedded Editors --------------------------------------------------------- # Rapid editor (OSW editing) and Pathways editor (GTFS Pathways editing). diff --git a/.gitignore b/.gitignore index f8a2fa4..c3d29bb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ logs /playwright-report/ /blob-report/ /playwright/.cache/ +/.claude diff --git a/nuxt.config.ts b/nuxt.config.ts index b0b327f..913ca14 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -27,10 +27,10 @@ export default defineNuxtConfig({ '~/assets/scss/main.scss', ], sourcemap: { client: 'hidden' }, - compatibilityDate: '2024-10-24', devServer: { host: '0.0.0.0', }, + compatibilityDate: '2024-10-24', nitro: { // Route every backend the SPA calls through the dev server so requests are // same-origin — this avoids CORS when browsing under any hostname. Each @@ -51,9 +51,11 @@ export default defineNuxtConfig({ }, vite: { server: { - // `true` disables Vite's Host-header check, so the dev server accepts - // requests for any hostname (e.g. tunnels, custom local domains). - allowedHosts: true, + allowedHosts: [ + '.local', + '.internal', + '.sidewalks.washington.edu', + ], }, optimizeDeps: { // Pre-bundle these dependencies to avoid reloads during development: diff --git a/services/osm.ts b/services/osm.ts index 9491718..e5552ec 100644 --- a/services/osm.ts +++ b/services/osm.ts @@ -466,18 +466,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { }); } - async postNoteComment( - workspaceId: WorkspaceId, - noteId: number, - message: string, - ): Promise { - // As with changeset comments, the note comment `text` goes in the query - // string (matches the working `POST /api/0.6/notes?...&text=` shape). - await this._post(`notes/${noteId}/comment?text=${encodeURIComponent(message)}`, undefined, { - headers: { 'X-Workspace': String(workspaceId) }, - }); - } - async postNoteComment( workspaceId: WorkspaceId, noteId: number, From 04e63f09818c74f4f40fa344b7e24aea09514c43 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 17:53:06 -0400 Subject: [PATCH 05/10] Lint --- components/review/Discussion.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/review/Discussion.vue b/components/review/Discussion.vue index 0c1d491..716b360 100644 --- a/components/review/Discussion.vue +++ b/components/review/Discussion.vue @@ -23,7 +23,7 @@ import { osmClient } from '~/services/index'; import type ChatBox from '~/components/ChatBox.vue'; import type { ChatMessage } from '~/components/ChatBox.vue'; -import type { OsmChangeset, OsmNote } from '~/types/osm'; +import type { OsmNote } from '~/types/osm'; interface Props { item: ReviewListItem; From 33577f4ae71b34dd6d5b3729745bea9637a69567 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 17:54:35 -0400 Subject: [PATCH 06/10] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 954d78f..ad10470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: # Runs `nuxt prepare` via the postinstall hook, which generates the .nuxt # types + eslint typegen that both `lint` and `typecheck` rely on. - name: Install dependencies - run: npm ci + run: npm install && npm ci # The quality steps below use `if: ${{ !cancelled() }}` so every check runs # and reports its own result even when an earlier one fails. From 675b5281070fc0c12374ff065bfb261d6711defe Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 17:57:18 -0400 Subject: [PATCH 07/10] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad10470..fcabf28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: # Runs `nuxt prepare` via the postinstall hook, which generates the .nuxt # types + eslint typegen that both `lint` and `typecheck` rely on. - name: Install dependencies - run: npm install && npm ci + run: npm install # The quality steps below use `if: ${{ !cancelled() }}` so every check runs # and reports its own result even when an earlier one fails. From bd59a826af2ccc798377d39b1dfad25ebda85dd1 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 17:57:58 -0400 Subject: [PATCH 08/10] Update osm.test.ts --- test/unit/services/osm.test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/unit/services/osm.test.ts b/test/unit/services/osm.test.ts index f04c29b..472a4e4 100644 --- a/test/unit/services/osm.test.ts +++ b/test/unit/services/osm.test.ts @@ -136,14 +136,28 @@ describe('OsmApiClient comment posting', () => { it('posts a note comment to the notes endpoint with the message text as a query parameter', async () => { const urls: string[] = []; server.use( - http.post(`${OSM_API_BASE}notes/9/comment`, ({ request }) => { + // postNoteComment hits the `.json` endpoint and parses the returned + // GeoJSON Feature, so the stub must respond with a valid note feature. + http.post(`${OSM_API_BASE}notes/9/comment.json`, ({ request }) => { urls.push(request.url); - return new HttpResponse(null, { status: 200 }); + return HttpResponse.json({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [-122.3, 47.6] }, + properties: { + id: 9, + status: 'open', + date_created: '2026-07-01T00:00:00Z', + comments: [ + { text: 'thanks for flagging', user: 'tester', date: '2026-07-01T00:30:00Z' } + ] + } + }); }) ); await makeClient().postNoteComment(1, 9, 'thanks for flagging'); - expect(urls.some(u => u.includes('text=thanks%20for%20flagging'))).toBe(true); + // URLSearchParams encodes the space as `+`, not `%20`. + expect(urls.some(u => u.includes('text=thanks+for+flagging'))).toBe(true); }); }); From 2525ac87b06a7771bbd97a1ce42e1f195326b57c Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 17:59:12 -0400 Subject: [PATCH 09/10] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c3d29bb..66d93ae 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ logs /blob-report/ /playwright/.cache/ /.claude +/.claude From 03e45aa6e55d24192c5f4b6d86a0f61ffc061a26 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 18:15:58 -0400 Subject: [PATCH 10/10] Update review.spec.ts --- test/e2e/review.spec.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/test/e2e/review.spec.ts b/test/e2e/review.spec.ts index 657d55e..7a3ff54 100644 --- a/test/e2e/review.spec.ts +++ b/test/e2e/review.spec.ts @@ -411,9 +411,25 @@ test.describe('workspace review', () => { await stubReviewApis(page); // Accept the note comment POST (osmClient.postNoteComment) so the component's - // optimistic append runs. + // optimistic append runs. The endpoint returns the updated note as a GeoJSON + // Feature, which postNoteComment parses — an empty body would throw and skip + // the optimistic append. await page.route('**/osm/api/0.6/notes/99/comment**', route => - route.fulfill({ status: 200, body: '' }) + route.fulfill({ + json: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-122.335, 47.605] }, + properties: { + id: 99, + status: 'open', + date_created: '2026-06-12T08:00:00.000Z', + comments: [ + { action: 'opened', date: '2026-06-12T08:00:00.000Z', user: 'note_author', uid: 11, text: 'Curb ramp is missing here' }, + { action: 'commented', date: '2026-07-10T00:00:00.000Z', user: 'Tester', uid: 42, text: 'Confirmed, still missing.' } + ] + } + } + }) ); await page.goto('/workspace/1/review');