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/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 954d78f..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 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.
diff --git a/.gitignore b/.gitignore
index f8a2fa4..66d93ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,5 @@ logs
/playwright-report/
/blob-report/
/playwright/.cache/
+/.claude
+/.claude
diff --git a/components/review/Discussion.vue b/components/review/Discussion.vue
index ca8789d..716b360 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..913ca14 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -27,24 +27,34 @@ export default defineNuxtConfig({
'~/assets/scss/main.scss',
],
sourcemap: { client: 'hidden' },
+ devServer: {
+ host: '0.0.0.0',
+ },
compatibilityDate: '2024-10-24',
-
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',
+ '.local',
+ '.internal',
+ '.sidewalks.washington.edu',
],
},
optimizeDeps: {
diff --git a/package-lock.json b/package-lock.json
index b4a74da..be5620e 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"
@@ -2080,6 +2059,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",
@@ -2381,6 +2382,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 +2501,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",
@@ -2660,6 +2663,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",
@@ -2892,6 +2912,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 +2961,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 +2979,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 +4421,7 @@
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"playwright": "1.61.0"
},
@@ -4419,6 +4443,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 +5803,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 +6039,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 +6119,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 +6199,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 +6214,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 +6433,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 +6442,7 @@
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~8.3.0"
}
@@ -6513,6 +6545,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 +7425,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 +7436,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 +7586,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 +7639,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 +8064,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 +8245,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 +8311,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -8606,7 +8646,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 +9666,7 @@
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -9693,6 +9735,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 +11088,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 +12454,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 +13625,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 +13963,7 @@
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -13981,6 +14028,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 +14356,7 @@
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"playwright-core": "cli.js"
},
@@ -14377,6 +14426,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -15072,6 +15122,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 +15488,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 +15776,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 +16883,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17031,6 +17085,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.4"
},
@@ -17322,6 +17377,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 +17697,7 @@
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
@@ -17747,6 +17804,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 +17849,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 +17889,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 +18003,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..e5552ec 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[]) {
@@ -461,14 +459,35 @@ 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) },
});
}
+ 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/e2e/review.spec.ts b/test/e2e/review.spec.ts
index 885f18e..7a3ff54 100644
--- a/test/e2e/review.spec.ts
+++ b/test/e2e/review.spec.ts
@@ -404,6 +404,97 @@ 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. 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({
+ 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');
+
+ // 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..472a4e4 100644
--- a/test/unit/services/osm.test.ts
+++ b/test/unit/services/osm.test.ts
@@ -70,3 +70,94 @@ 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', () => {
+ // 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`, ({ request }) => {
+ urls.push(request.url);
+ return new HttpResponse(null, { status: 200 });
+ })
+ );
+
+ await makeClient().postChangesetComment(1, 5, '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 as a query parameter', async () => {
+ const urls: string[] = [];
+ server.use(
+ // 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 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');
+
+ // URLSearchParams encodes the space as `+`, not `%20`.
+ expect(urls.some(u => u.includes('text=thanks+for+flagging'))).toBe(true);
+ });
+});