From d8892bb05cc360b88c6f483903e7e57ea654a809 Mon Sep 17 00:00:00 2001 From: Yamonwan Justin DAH-KENANGNON Date: Wed, 21 Oct 2026 19:28:42 +0100 Subject: [PATCH 01/15] fix(deps): patch dependencies and tutorial install commands Signed-off-by: Yamonwan Justin DAH-KENANGNON --- p1-task-manager/package.json | 2 +- p1-task-manager/pnpm-lock.yaml | 28 ++-- p2-tenantrag/package.json | 1 + p2-tenantrag/pnpm-lock.yaml | 21 ++- .../docs/tutorials.md | 2 +- p3-mcp-capability-governance/package.json | 9 +- p3-mcp-capability-governance/pnpm-lock.yaml | 97 +++++------ p4-editorial-publishing/package.json | 1 + p4-editorial-publishing/pnpm-lock.yaml | 157 +++++++++--------- p5-dataguard/docs/tutorials.md | 2 +- p5-dataguard/package.json | 4 +- p5-dataguard/pnpm-lock.yaml | 44 ++--- 12 files changed, 186 insertions(+), 182 deletions(-) diff --git a/p1-task-manager/package.json b/p1-task-manager/package.json index 99431a0..cffec55 100644 --- a/p1-task-manager/package.json +++ b/p1-task-manager/package.json @@ -50,7 +50,7 @@ "tsx": "4.23.12", "typescript": "6.0.3", "typescript-eslint": "8.68.0", - "vite": "8.2.2", + "vite": "8.2.4", "vitest": "4.1.11" }, "pnpm": { diff --git a/p1-task-manager/pnpm-lock.yaml b/p1-task-manager/pnpm-lock.yaml index c5b61ce..b624cc8 100644 --- a/p1-task-manager/pnpm-lock.yaml +++ b/p1-task-manager/pnpm-lock.yaml @@ -68,7 +68,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 6.1.1(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) eslint: specifier: 10.11.0 version: 10.11.0 @@ -88,11 +88,11 @@ importers: specifier: 8.68.0 version: 8.68.0(eslint@10.11.0)(typescript@6.0.3) vite: - specifier: 8.2.2 - version: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) + specifier: 8.2.4 + version: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) packages: @@ -1421,8 +1421,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.2.4: + resolution: {integrity: sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2001,10 +2001,10 @@ snapshots: '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) '@vitest/expect@4.1.11': dependencies: @@ -2015,13 +2015,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -2778,7 +2778,7 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0): + vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -2792,10 +2792,10 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 - vitest@4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -2812,7 +2812,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.0 diff --git a/p2-tenantrag/package.json b/p2-tenantrag/package.json index 275083c..d0a7581 100644 --- a/p2-tenantrag/package.json +++ b/p2-tenantrag/package.json @@ -40,6 +40,7 @@ "tsx": "4.23.12", "typescript": "6.0.3", "typescript-eslint": "8.68.0", + "vite": "8.2.4", "vitest": "4.1.11" } } diff --git a/p2-tenantrag/pnpm-lock.yaml b/p2-tenantrag/pnpm-lock.yaml index b4983a7..fdc8e28 100644 --- a/p2-tenantrag/pnpm-lock.yaml +++ b/p2-tenantrag/pnpm-lock.yaml @@ -54,9 +54,12 @@ importers: typescript-eslint: specifier: 8.68.0 version: 8.68.0(eslint@10.11.0)(typescript@6.0.3) + vite: + specifier: 8.2.4 + version: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@24.19.0)(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)) + version: 4.1.11(@types/node@24.19.0)(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)) packages: @@ -1179,8 +1182,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.2.4: + resolution: {integrity: sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1683,13 +1686,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12))': + '@vitest/mocker@4.1.11(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12) '@vitest/pretty-format@4.1.11': dependencies: @@ -2282,7 +2285,7 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12): + vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -2295,10 +2298,10 @@ snapshots: fsevents: 2.3.3 tsx: 4.23.12 - vitest@4.1.11(@types/node@24.19.0)(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)): + vitest@4.1.11(@types/node@24.19.0)(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)) + '@vitest/mocker': 4.1.11(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -2315,7 +2318,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.0 diff --git a/p3-mcp-capability-governance/docs/tutorials.md b/p3-mcp-capability-governance/docs/tutorials.md index 06832b4..82979db 100644 --- a/p3-mcp-capability-governance/docs/tutorials.md +++ b/p3-mcp-capability-governance/docs/tutorials.md @@ -397,7 +397,7 @@ The shared builder files are: From P3, install the build dependencies and update the MCP client to match the completed app: ```bash -pnpm add --save-exact @modelcontextprotocol/client@2.2.0 +pnpm add --save-exact @modelcontextprotocol/client@2.3.0 @modelcontextprotocol/express@2.0.2 @modelcontextprotocol/server@2.3.0 hono@4.13.11 pnpm add --save-dev --save-exact fflate@0.8.3 @cedar-policy/cedar-wasm@4.12.0 @types/node@24.19.0 node ../shared/policy-store.mjs ``` diff --git a/p3-mcp-capability-governance/package.json b/p3-mcp-capability-governance/package.json index 9ec4f96..3107fe2 100644 --- a/p3-mcp-capability-governance/package.json +++ b/p3-mcp-capability-governance/package.json @@ -22,12 +22,12 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@modelcontextprotocol/client": "2.2.0", - "@modelcontextprotocol/express": "2.0.0", + "@modelcontextprotocol/client": "2.3.0", + "@modelcontextprotocol/express": "2.0.2", "@modelcontextprotocol/node": "2.0.0", - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.3.0", "express": "5.2.1", - "hono": "4.13.7", + "hono": "4.13.11", "jose": "6.2.10", "zod": "4.6.5" }, @@ -42,6 +42,7 @@ "tsx": "4.23.12", "typescript": "6.0.3", "typescript-eslint": "8.68.0", + "vite": "8.2.4", "vitest": "4.1.11" } } diff --git a/p3-mcp-capability-governance/pnpm-lock.yaml b/p3-mcp-capability-governance/pnpm-lock.yaml index 73e9d94..ee15d65 100644 --- a/p3-mcp-capability-governance/pnpm-lock.yaml +++ b/p3-mcp-capability-governance/pnpm-lock.yaml @@ -9,23 +9,23 @@ importers: .: dependencies: '@modelcontextprotocol/client': - specifier: 2.2.0 - version: 2.2.0 + specifier: 2.3.0 + version: 2.3.0 '@modelcontextprotocol/express': - specifier: 2.0.0 - version: 2.0.0(@modelcontextprotocol/server@2.0.0)(express@5.2.1) + specifier: 2.0.2 + version: 2.0.2(@modelcontextprotocol/server@2.3.0)(express@5.2.1) '@modelcontextprotocol/node': specifier: 2.0.0 - version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7) + version: 2.0.0(@modelcontextprotocol/server@2.3.0)(hono@4.13.11) '@modelcontextprotocol/server': - specifier: 2.0.0 - version: 2.0.0 + specifier: 2.3.0 + version: 2.3.0 express: specifier: 5.2.1 version: 5.2.1 hono: - specifier: 4.13.7 - version: 4.13.7 + specifier: 4.13.11 + version: 4.13.11 jose: specifier: 6.2.10 version: 6.2.10 @@ -63,9 +63,12 @@ importers: typescript-eslint: specifier: 8.68.0 version: 8.68.0(eslint@10.11.0)(typescript@6.0.3) + vite: + specifier: 8.2.4 + version: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@24.19.0)(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3)) + version: 4.1.11(@types/node@24.19.0)(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3)) packages: @@ -311,23 +314,19 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@modelcontextprotocol/client@2.2.0': - resolution: {integrity: sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==} - engines: {node: '>=20'} - - '@modelcontextprotocol/core@2.0.0': - resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + '@modelcontextprotocol/client@2.3.0': + resolution: {integrity: sha512-pbHtGj6EeFCHngFE8gtNPaKrsj8Mb1IMw45BLcaPML/m2WTxI7pyYmeNQvaux88Lw8BSO5r7nyRbUyIkICmGrg==} engines: {node: '>=20'} - '@modelcontextprotocol/core@2.2.0': - resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} + '@modelcontextprotocol/core@2.3.0': + resolution: {integrity: sha512-09BHFaNVBe5pB2AhzCfLj0cqmsKLERvZQ4FblzP90UMGLaXVqdZPByFjURwsUYCmxonUW2yAwgTTvle+8O2/8w==} engines: {node: '>=20'} - '@modelcontextprotocol/express@2.0.0': - resolution: {integrity: sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ==} + '@modelcontextprotocol/express@2.0.2': + resolution: {integrity: sha512-lUvfV7gT/tgP8nLRs0ajUtgL+MlMNWWHAls5c9cf4PyBexq3EgmhWWrS4irQOC9OoKuUsHHcNjUw9urhHz542g==} engines: {node: '>=20'} peerDependencies: - '@modelcontextprotocol/server': ^2.0.0 + '@modelcontextprotocol/server': ^2.3.0 express: ^4.18.0 || ^5.0.0 '@modelcontextprotocol/node@2.0.0': @@ -340,8 +339,8 @@ packages: hono: optional: true - '@modelcontextprotocol/server@2.0.0': - resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + '@modelcontextprotocol/server@2.3.0': + resolution: {integrity: sha512-+6b0LdsQLmHsvS7J6sQJ4weTpzUn61fU1JKS+321/+IyDYoHW5CR2Tgqcs6GgAAQvlesCo6yX3xttgmx+XGtJw==} engines: {node: '>=20'} '@oxc-project/types@0.151.0': @@ -864,8 +863,8 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.11: + resolution: {integrity: sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg==} engines: {node: '>=16.9.0'} hookified@1.15.1: @@ -1277,8 +1276,8 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.2.4: + resolution: {integrity: sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1518,9 +1517,9 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@hono/node-server@1.19.17(hono@4.13.7)': + '@hono/node-server@1.19.17(hono@4.13.11)': dependencies: - hono: 4.13.7 + hono: 4.13.11 '@humanfs/core@0.19.2': dependencies: @@ -1548,9 +1547,9 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@modelcontextprotocol/client@2.2.0': + '@modelcontextprotocol/client@2.3.0': dependencies: - '@modelcontextprotocol/core': 2.2.0 + '@modelcontextprotocol/core': 2.3.0 cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.1 @@ -1558,30 +1557,26 @@ snapshots: pkce-challenge: 5.0.1 zod: 4.6.5 - '@modelcontextprotocol/core@2.0.0': - dependencies: - zod: 4.6.5 - - '@modelcontextprotocol/core@2.2.0': + '@modelcontextprotocol/core@2.3.0': dependencies: zod: 4.6.5 - '@modelcontextprotocol/express@2.0.0(@modelcontextprotocol/server@2.0.0)(express@5.2.1)': + '@modelcontextprotocol/express@2.0.2(@modelcontextprotocol/server@2.3.0)(express@5.2.1)': dependencies: - '@modelcontextprotocol/server': 2.0.0 + '@modelcontextprotocol/server': 2.3.0 cors: 2.8.6 express: 5.2.1 - '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7)': + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.3.0)(hono@4.13.11)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.7) - '@modelcontextprotocol/server': 2.0.0 + '@hono/node-server': 1.19.17(hono@4.13.11) + '@modelcontextprotocol/server': 2.3.0 optionalDependencies: - hono: 4.13.7 + hono: 4.13.11 - '@modelcontextprotocol/server@2.0.0': + '@modelcontextprotocol/server@2.3.0': dependencies: - '@modelcontextprotocol/core': 2.0.0 + '@modelcontextprotocol/core': 2.3.0 zod: 4.6.5 '@oxc-project/types@0.151.0': {} @@ -1789,13 +1784,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3))': + '@vitest/mocker@4.1.11(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3) '@vitest/pretty-format@4.1.11': dependencies: @@ -2163,7 +2158,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.7: {} + hono@4.13.11: {} hookified@1.15.1: {} @@ -2543,7 +2538,7 @@ snapshots: vary@1.1.2: {} - vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3): + vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -2557,10 +2552,10 @@ snapshots: tsx: 4.23.12 yaml: 2.8.3 - vitest@4.1.11(@types/node@24.19.0)(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3)): + vitest@4.1.11(@types/node@24.19.0)(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3)) + '@vitest/mocker': 4.1.11(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -2577,7 +2572,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.0 diff --git a/p4-editorial-publishing/package.json b/p4-editorial-publishing/package.json index 16ef992..af2e3e0 100644 --- a/p4-editorial-publishing/package.json +++ b/p4-editorial-publishing/package.json @@ -41,6 +41,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "typescript": "6.0.3", + "vite": "8.3.3", "vitest": "4.1.11" }, "pnpm": { diff --git a/p4-editorial-publishing/pnpm-lock.yaml b/p4-editorial-publishing/pnpm-lock.yaml index 7b31b57..acf2590 100644 --- a/p4-editorial-publishing/pnpm-lock.yaml +++ b/p4-editorial-publishing/pnpm-lock.yaml @@ -57,9 +57,12 @@ importers: typescript: specifier: 6.0.3 version: 6.0.3 + vite: + specifier: 8.3.3 + version: 8.3.3(@types/node@24.19.0) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@24.19.0)(vite@8.3.0(@types/node@24.19.0)) + version: 4.1.11(@types/node@24.19.0)(vite@8.3.3(@types/node@24.19.0)) packages: @@ -326,100 +329,100 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/types@0.151.0': - resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} '@playwright/test@1.63.0': resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} engines: {node: '>=20'} hasBin: true - '@rolldown/binding-android-arm-eabi@1.2.10': - resolution: {integrity: sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg==} + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.10': - resolution: {integrity: sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA==} + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.10': - resolution: {integrity: sha512-UbEfXq/AqGNgRTV3ik+X/iR6mUxu2QdYAadwRxJWquUGnW6gDqdP1FtLtFXRow7RJx0ssRwi80XAPr4r+4DtsA==} + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.10': - resolution: {integrity: sha512-7f5h17q5KZVx/ji1vb8OTq31ch1O2I7K8NPIr44GkyWTApXMIsmhWqZfgpOH10xeauqghDAvGlZktasCkcF6Eg==} + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.10': - resolution: {integrity: sha512-ynOk/eEYhC6ZB2xCGvKrEOwE58oBy9LnrAqtkrDF9Fz1VTaNdGZTsV0VarJdhPwb+sOJTGjCLwcuyRJZ1dnMcQ==} + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.10': - resolution: {integrity: sha512-ERrAs185meZZhGan7a4l3RiiJK1ArSDlHdST++uvSxe+FDbR4TwUPahT/cbZJvaG6fIpDpF78surN+tX708Y4Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.10': - resolution: {integrity: sha512-KN7OHKD0J3jy1UzBwZWPxpwhODf9IARUIJcrH+yLYKOcmegZ8luEUM38lDP1bDVj40yP6PsSzCqOJF76vljFnQ==} + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.2.10': - resolution: {integrity: sha512-8l9wP8O+wa8zD6iw6egSfzVtu7oZVfH3hlUsMM4MwbLMhxleqeoXbZzjddyK3YyNlwLhqznq3tF7PkNJ8T/V2w==} + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.2.10': - resolution: {integrity: sha512-SeXNKeQzA5kLhz/J0CH6ZP0/HJ3v1xm/0YbiYpE0kK7emfRC2OIGGIaE14xzkISEGv2aYuUSpiLiU5Gbq+OI0A==} + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.2.10': - resolution: {integrity: sha512-mtht0nR+y8/hart4175Ll15w7lY8dg7CtQ+j2FDNTsDRspOWTK/2V3l0aj9sIj7XmvqxT8Yli/wq22e7feTTWg==} + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.2.10': - resolution: {integrity: sha512-FSM94nGd55NYo48usCyM/nHfUKRnqc9+b0vJNuKV0oCCpIp/OGims7rO1Nv/DkFkt0S/s2rxsJ2kkS8J3HcpeA==} + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.2.10': - resolution: {integrity: sha512-C3YxNB16myRLs7o+B+6PnQ6jBsdIS4+AE4Ah8glVGhDpEv9AOvxhZ/1duAb4B0UGczEK/lBbccksd8VI+p6zfw==} + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.2.10': - resolution: {integrity: sha512-571TlE/F1eeTjjdjYAMMMPs1Mfv3MtX6s3+ZKVU6HiUjZ5Njc6c/qzNy/8K3zALTZnaw3JQVYrHxvNfjm43KAg==} + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.10': - resolution: {integrity: sha512-QXW+ZWaiqs2c7Fi++D/SsW07LTPcUrncxcskJGfGNBoaLik1IU6fJymz4HsqwEO0u5Iq11yTO0B/mc4cPk7jrQ==} + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.10': - resolution: {integrity: sha512-5FQFGgah17YeMtG1Yd5a+rMxQpTksyNXxRtKz06FVTaQw3RKYUJQbUoKk0/5jrXBpDo+7makNP7UHA2LQyH64A==} + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -698,8 +701,8 @@ packages: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} - rolldown@1.2.10: - resolution: {integrity: sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog==} + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -772,8 +775,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - vite@8.3.0: - resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + vite@8.3.3: + resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1045,55 +1048,55 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.8': optional: true - '@oxc-project/types@0.151.0': {} + '@oxc-project/types@0.155.0': {} '@playwright/test@1.63.0': dependencies: playwright: 1.63.0 - '@rolldown/binding-android-arm-eabi@1.2.10': + '@rolldown/binding-android-arm-eabi@1.2.15': optional: true - '@rolldown/binding-android-arm64@1.2.10': + '@rolldown/binding-android-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-arm64@1.2.10': + '@rolldown/binding-darwin-arm64@1.2.15': optional: true - '@rolldown/binding-darwin-x64@1.2.10': + '@rolldown/binding-darwin-x64@1.2.15': optional: true - '@rolldown/binding-freebsd-x64@1.2.10': + '@rolldown/binding-freebsd-x64@1.2.15': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.10': + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.10': + '@rolldown/binding-linux-arm64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.10': + '@rolldown/binding-linux-arm64-musl@1.2.15': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.10': + '@rolldown/binding-linux-ppc64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.10': + '@rolldown/binding-linux-s390x-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.10': + '@rolldown/binding-linux-x64-gnu@1.2.15': optional: true - '@rolldown/binding-linux-x64-musl@1.2.10': + '@rolldown/binding-linux-x64-musl@1.2.15': optional: true - '@rolldown/binding-openharmony-arm64@1.2.10': + '@rolldown/binding-openharmony-arm64@1.2.15': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.10': + '@rolldown/binding-win32-arm64-msvc@1.2.15': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.10': + '@rolldown/binding-win32-x64-msvc@1.2.15': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1138,13 +1141,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@24.19.0))': + '@vitest/mocker@4.1.11(vite@8.3.3(@types/node@24.19.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.0(@types/node@24.19.0) + vite: 8.3.3(@types/node@24.19.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -1332,26 +1335,26 @@ snapshots: react@19.3.0: {} - rolldown@1.2.10: + rolldown@1.2.15: dependencies: - '@oxc-project/types': 0.151.0 + '@oxc-project/types': 0.155.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.10 - '@rolldown/binding-android-arm64': 1.2.10 - '@rolldown/binding-darwin-arm64': 1.2.10 - '@rolldown/binding-darwin-x64': 1.2.10 - '@rolldown/binding-freebsd-x64': 1.2.10 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.10 - '@rolldown/binding-linux-arm64-gnu': 1.2.10 - '@rolldown/binding-linux-arm64-musl': 1.2.10 - '@rolldown/binding-linux-ppc64-gnu': 1.2.10 - '@rolldown/binding-linux-s390x-gnu': 1.2.10 - '@rolldown/binding-linux-x64-gnu': 1.2.10 - '@rolldown/binding-linux-x64-musl': 1.2.10 - '@rolldown/binding-openharmony-arm64': 1.2.10 - '@rolldown/binding-win32-arm64-msvc': 1.2.10 - '@rolldown/binding-win32-x64-msvc': 1.2.10 + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 scheduler@0.28.0: {} @@ -1422,21 +1425,21 @@ snapshots: undici-types@7.24.6: {} - vite@8.3.0(@types/node@24.19.0): + vite@8.3.3(@types/node@24.19.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.28 - rolldown: 1.2.10 + rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.19.0 fsevents: 2.3.3 - vitest@4.1.11(@types/node@24.19.0)(vite@8.3.0(@types/node@24.19.0)): + vitest@4.1.11(@types/node@24.19.0)(vite@8.3.3(@types/node@24.19.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@24.19.0)) + '@vitest/mocker': 4.1.11(vite@8.3.3(@types/node@24.19.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1453,7 +1456,7 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.3.0(@types/node@24.19.0) + vite: 8.3.3(@types/node@24.19.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.0 diff --git a/p5-dataguard/docs/tutorials.md b/p5-dataguard/docs/tutorials.md index dcdf628..45ecf9d 100644 --- a/p5-dataguard/docs/tutorials.md +++ b/p5-dataguard/docs/tutorials.md @@ -360,7 +360,7 @@ flowchart TD From P5, install Cedarling and match the completed app's dependencies: ```bash -pnpm add --save-exact @janssenproject/cedarling_wasm@0.0.468 fflate@0.8.3 hono@4.13.7 +pnpm add --save-exact @janssenproject/cedarling_wasm@0.0.468 fflate@0.8.3 hono@4.13.11 pnpm add --save-dev --save-exact @cedar-policy/cedar-wasm@4.12.0 @types/node@24.19.0 ``` diff --git a/p5-dataguard/package.json b/p5-dataguard/package.json index 9e548ff..dc47fc1 100644 --- a/p5-dataguard/package.json +++ b/p5-dataguard/package.json @@ -26,7 +26,7 @@ "fflate": "0.8.3", "@hono/node-server": "2.1.1", "better-sqlite3": "13.0.3", - "hono": "4.13.7", + "hono": "4.13.11", "openid-client": "6.8.8", "react": "19.3.0", "react-dom": "19.3.0", @@ -42,7 +42,7 @@ "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", "typescript": "6.0.3", - "vite": "8.2.2", + "vite": "8.2.4", "vitest": "4.1.11" }, "pnpm": { diff --git a/p5-dataguard/pnpm-lock.yaml b/p5-dataguard/pnpm-lock.yaml index a1d0b64..0130de5 100644 --- a/p5-dataguard/pnpm-lock.yaml +++ b/p5-dataguard/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@hono/node-server': specifier: 2.1.1 - version: 2.1.1(hono@4.13.7) + version: 2.1.1(hono@4.13.11) '@janssenproject/cedarling_wasm': specifier: 0.0.468 version: 0.0.468 @@ -21,8 +21,8 @@ importers: specifier: 0.8.3 version: 0.8.3 hono: - specifier: 4.13.7 - version: 4.13.7 + specifier: 4.13.11 + version: 4.13.11 openid-client: specifier: 6.8.8 version: 6.8.8 @@ -59,16 +59,16 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@24.19.0)) + version: 6.1.1(vite@8.2.4(@types/node@24.19.0)) typescript: specifier: 6.0.3 version: 6.0.3 vite: - specifier: 8.2.2 - version: 8.2.2(@types/node@24.19.0) + specifier: 8.2.4 + version: 8.2.4(@types/node@24.19.0) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@24.19.0)(vite@8.2.2(@types/node@24.19.0)) + version: 4.1.11(@types/node@24.19.0)(vite@8.2.4(@types/node@24.19.0)) packages: @@ -362,8 +362,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.11: + resolution: {integrity: sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg==} engines: {node: '>=16.9.0'} jose@6.2.12: @@ -538,8 +538,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.2.4: + resolution: {integrity: sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -669,9 +669,9 @@ snapshots: '@cedar-policy/cedar-wasm@4.12.0': {} - '@hono/node-server@2.1.1(hono@4.13.7)': + '@hono/node-server@2.1.1(hono@4.13.11)': dependencies: - hono: 4.13.7 + hono: 4.13.11 '@janssenproject/cedarling_wasm@0.0.468': {} @@ -757,10 +757,10 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.19.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.4(@types/node@24.19.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@24.19.0) + vite: 8.2.4(@types/node@24.19.0) '@vitest/expect@4.1.11': dependencies: @@ -771,13 +771,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.19.0))': + '@vitest/mocker@4.1.11(vite@8.2.4(@types/node@24.19.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.19.0) + vite: 8.2.4(@types/node@24.19.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -834,7 +834,7 @@ snapshots: fsevents@2.3.3: optional: true - hono@4.13.7: {} + hono@4.13.11: {} jose@6.2.12: {} @@ -975,7 +975,7 @@ snapshots: undici-types@7.24.6: {} - vite@8.2.2(@types/node@24.19.0): + vite@8.2.4(@types/node@24.19.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -986,10 +986,10 @@ snapshots: '@types/node': 24.19.0 fsevents: 2.3.3 - vitest@4.1.11(@types/node@24.19.0)(vite@8.2.2(@types/node@24.19.0)): + vitest@4.1.11(@types/node@24.19.0)(vite@8.2.4(@types/node@24.19.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.19.0)) + '@vitest/mocker': 4.1.11(vite@8.2.4(@types/node@24.19.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1006,7 +1006,7 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.19.0) + vite: 8.2.4(@types/node@24.19.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.0 From cf09305d5e11940672126d944b76fc1f833bf538 Mon Sep 17 00:00:00 2001 From: Yamonwan Justin DAH-KENANGNON Date: Wed, 21 Oct 2026 19:29:35 +0100 Subject: [PATCH 02/15] fix(ui): open tutorial resource links in new tabs Signed-off-by: Yamonwan Justin DAH-KENANGNON --- p1-task-manager/src/web/App.tsx | 34 ++++++++++++++++++---- p4-editorial-publishing/app/components.tsx | 34 ++++++++++++++++++---- p5-dataguard/src/web/App.tsx | 34 ++++++++++++++++++---- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/p1-task-manager/src/web/App.tsx b/p1-task-manager/src/web/App.tsx index 2c39232..9e23279 100644 --- a/p1-task-manager/src/web/App.tsx +++ b/p1-task-manager/src/web/App.tsx @@ -107,13 +107,37 @@ function ProgramFooter() { return ( ); diff --git a/p4-editorial-publishing/app/components.tsx b/p4-editorial-publishing/app/components.tsx index e2fa425..9392710 100644 --- a/p4-editorial-publishing/app/components.tsx +++ b/p4-editorial-publishing/app/components.tsx @@ -54,13 +54,37 @@ export function ProgramFooter() { return ( ); diff --git a/p5-dataguard/src/web/App.tsx b/p5-dataguard/src/web/App.tsx index c74bf27..f766b10 100644 --- a/p5-dataguard/src/web/App.tsx +++ b/p5-dataguard/src/web/App.tsx @@ -108,13 +108,37 @@ function Footer() { return ( ); From 49dabb9a60e7a98722840b169826900bbf5bcd8a Mon Sep 17 00:00:00 2001 From: Yamonwan Justin DAH-KENANGNON Date: Wed, 21 Oct 2026 19:30:23 +0100 Subject: [PATCH 03/15] fix(shared): align identity and baseline test support Signed-off-by: Yamonwan Justin DAH-KENANGNON --- shared/browser-test-stack.mjs | 5 +- shared/identity-provider/src/accounts.ts | 3 + shared/identity-provider/src/config.ts | 1 + .../identity-provider/test/accounts.test.ts | 3 + shared/identity-provider/test/config.test.ts | 12 ++++ .../test/docker-bootstrap.test.mjs | 71 +++++++++++++++++++ 6 files changed, 94 insertions(+), 1 deletion(-) diff --git a/shared/browser-test-stack.mjs b/shared/browser-test-stack.mjs index 365d064..d013f39 100644 --- a/shared/browser-test-stack.mjs +++ b/shared/browser-test-stack.mjs @@ -27,6 +27,9 @@ export default async function startBrowserTestStack() { String(project.port), ], P5: ["src/server/main.ts"], + P6: ["dist/server/main.js"], + P7: ["dist/server/main.js"], + P15: ["dist/server/main.js"], }[selector]; if (!applicationArgs) throw new Error(`No browser test stack for ${selector}`); @@ -96,7 +99,7 @@ export default async function startBrowserTestStack() { cwd: root, env: { ...process.env }, health: { - url: `http://127.0.0.1:${project.port}/health`, + url: `http://127.0.0.1:${project.port}/${["P6", "P7"].includes(selector) ? "healthz" : "health"}`, validate: selector === "P1" ? async (response) => diff --git a/shared/identity-provider/src/accounts.ts b/shared/identity-provider/src/accounts.ts index 7b7db0c..c128230 100644 --- a/shared/identity-provider/src/accounts.ts +++ b/shared/identity-provider/src/accounts.ts @@ -38,8 +38,11 @@ export const tutorialAccounts = [ { id: "lin", name: "Lin", email: "lin@tutorial.test" }, { id: "nia", name: "Nia", email: "nia@tutorial.test" }, { id: "ben", name: "Ben", email: "ben@tutorial.test" }, + { id: "aya", name: "Dr. Aya", email: "aya@tutorial.test" }, { id: "cora", name: "Cora", email: "cora@tutorial.test" }, { id: "talia", name: "Talia", email: "talia@tutorial.test" }, + { id: "rene", name: "René", email: "rene@tutorial.test" }, + { id: "ava", name: "Ava", email: "ava@tutorial.test" }, { id: "grace", name: "Grace", email: "grace@tutorial.test" }, { id: "dina", name: "Dina", email: "dina@tutorial.test" }, { id: "amara", name: "Amara", email: "amara@tutorial.test" }, diff --git a/shared/identity-provider/src/config.ts b/shared/identity-provider/src/config.ts index b36b37f..30a5cb1 100644 --- a/shared/identity-provider/src/config.ts +++ b/shared/identity-provider/src/config.ts @@ -68,6 +68,7 @@ export const p6InspectionScopes = [ "workorder.create", "workorder.delete", "inspection.submit", + "inspection.review", "workorder.reassign", ] as const; export const p7DocumentScopes = [ diff --git a/shared/identity-provider/test/accounts.test.ts b/shared/identity-provider/test/accounts.test.ts index 747be1e..b883563 100644 --- a/shared/identity-provider/test/accounts.test.ts +++ b/shared/identity-provider/test/accounts.test.ts @@ -45,8 +45,11 @@ describe("tutorial accounts", () => { "lin", "nia", "ben", + "aya", "cora", "talia", + "rene", + "ava", "grace", "dina", "amara", diff --git a/shared/identity-provider/test/config.test.ts b/shared/identity-provider/test/config.test.ts index 509b58a..b114481 100644 --- a/shared/identity-provider/test/config.test.ts +++ b/shared/identity-provider/test/config.test.ts @@ -3,6 +3,18 @@ import { loadConfig, deviceCodeGrantType } from "../src/config.js"; import { projects, projectSettings, workloads } from "../src/projects.js"; describe("project-local identity registration", () => { + it("registers both P6 inspection submission and supervisor review scopes", () => { + const config = loadConfig({ + IDP_PROJECT: "P6", + P6_CLIENT_SECRET: "test-client-secret".repeat(3), + }); + expect( + config.applications.get("p6-field-inspection")?.resources.get("api") + ?.scopes, + ).toEqual( + expect.arrayContaining(["inspection.submit", "inspection.review"]), + ); + }); it.each(projects)( "registers only $id with its own issuer", ({ number, id }) => { diff --git a/shared/identity-provider/test/docker-bootstrap.test.mjs b/shared/identity-provider/test/docker-bootstrap.test.mjs index 2187258..8ac2fb9 100644 --- a/shared/identity-provider/test/docker-bootstrap.test.mjs +++ b/shared/identity-provider/test/docker-bootstrap.test.mjs @@ -54,6 +54,12 @@ for (const project of projects) { ...process.env, BOOTSTRAP_IDENTITY_DIR: identity, BOOTSTRAP_APP_DIR: app, + ...(project === "P8" + ? { + BOOTSTRAP_ISSUER: "http://localhost:18008", + BOOTSTRAP_BASE_URL: "http://localhost:17008", + } + : {}), ...Object.fromEntries( Object.entries(p10Directories).map(([name, directory]) => [ `BOOTSTRAP_P10_${name.toUpperCase()}_DIR`, @@ -70,6 +76,11 @@ for (const project of projects) { const registration = parseEnv( readFileSync(resolve(identity, ".env"), "utf8"), ); + const number = Number(project.slice(1)); + const issuer = `http://localhost:${18000 + number}`; + const origin = `http://localhost:${17000 + number}`; + assert.equal(registration.IDP_ISSUER, issuer); + assert.equal(registration.IDP_PORT, String(18000 + number)); if (project === "P10") { const files = { console: "console.env", @@ -86,6 +97,9 @@ for (const project of projects) { ]), ); const api = parseEnv(before.api); + assert.equal(api.P10_ISSUER, issuer); + assert.equal(api.P10_API_RESOURCE, `${origin}/api`); + assert.equal(parseEnv(before.console).P10_BASE_URL, origin); assert.equal( Object.keys(api).some((key) => key.endsWith("_CLIENT_SECRET")), false, @@ -97,6 +111,8 @@ for (const project of projects) { ["auditor", "P10_INVENTORY_AUDITOR"], ]) { const agent = parseEnv(before[name]); + assert.equal(agent.P10_ISSUER, issuer); + assert.equal(agent.P10_API_RESOURCE, `${origin}/api`); assert.equal( agent[`${prefix}_CLIENT_SECRET`], registration[`${prefix}_CLIENT_SECRET`], @@ -132,6 +148,22 @@ for (const project of projects) { const appFile = resolve(app, "app.env"); const before = readFileSync(appFile, "utf8"); const config = parseEnv(before); + assert.equal(config[`${project}_ISSUER`], issuer); + if (!["P2", "P3"].includes(project)) { + assert.equal( + config[`${project}_REDIRECT_URI`], + `${origin}/auth/callback`, + ); + assert.equal(config[`${project}_POST_LOGOUT_REDIRECT_URI`], origin); + } + if (project === "P8") { + assert.equal(config.P8_ISSUER, "http://localhost:18008"); + assert.equal(registration.IDP_ISSUER, config.P8_ISSUER); + assert.equal( + config.P8_REDIRECT_URI, + "http://localhost:17008/auth/callback", + ); + } for (const [name, value] of Object.entries(config)) { if (name.endsWith("_CLIENT_SECRET")) assert.equal(value, registration[name]); @@ -158,3 +190,42 @@ for (const project of projects) { } }); } + +test("P8 rejects an incompatible saved issuer without changing credentials or configuration", () => { + const root = mkdtempSync(resolve(tmpdir(), "p8-bootstrap-mismatch-")); + const identity = resolve(root, "identity"); + const app = resolve(root, "app"); + mkdirSync(identity); + mkdirSync(app); + const run = (issuer) => + spawnSync( + process.execPath, + [resolve("scripts/docker-bootstrap.mjs"), "P8"], + { + env: { + ...process.env, + BOOTSTRAP_IDENTITY_DIR: identity, + BOOTSTRAP_APP_DIR: app, + BOOTSTRAP_ISSUER: issuer, + BOOTSTRAP_BASE_URL: "http://localhost:17008", + }, + encoding: "utf8", + }, + ); + try { + assert.equal(run("http://localhost:4000").status, 0); + const identityBefore = readFileSync(resolve(identity, ".env"), "utf8"); + const appBefore = readFileSync(resolve(app, "app.env"), "utf8"); + assert.equal(parseEnv(appBefore).P8_ISSUER, "http://localhost:4000"); + const result = run("http://localhost:18008"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Persisted IDP_ISSUER differs/); + assert.equal( + readFileSync(resolve(identity, ".env"), "utf8"), + identityBefore, + ); + assert.equal(readFileSync(resolve(app, "app.env"), "utf8"), appBefore); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 993d443e37f7789fe112beefdef2832b614c96b6 Mon Sep 17 00:00:00 2001 From: Yamonwan Justin DAH-KENANGNON Date: Wed, 21 Oct 2026 19:30:50 +0100 Subject: [PATCH 04/15] fix(p6): stabilize the offline inspection baseline Signed-off-by: Yamonwan Justin DAH-KENANGNON --- p6-field-inspection/README.md | 148 +- p6-field-inspection/e2e/offline-draft.e2e.ts | 414 +++- p6-field-inspection/e2e/readiness.e2e.ts | 323 +++ p6-field-inspection/e2e/refinement.e2e.ts | 192 ++ p6-field-inspection/e2e/workspace.e2e.ts | 306 +++ p6-field-inspection/package.json | 4 +- p6-field-inspection/playwright.config.ts | 10 +- p6-field-inspection/pnpm-lock.yaml | 28 +- p6-field-inspection/scripts/dev.mjs | 23 +- p6-field-inspection/scripts/reset.ts | 2 +- p6-field-inspection/scripts/setup.ts | 4 +- p6-field-inspection/src/server/app.ts | 212 +- .../src/server/authorization.ts | 15 +- p6-field-inspection/src/server/database.ts | 285 ++- .../src/shared/capabilities.ts | 1 + p6-field-inspection/src/shared/catalog.ts | 17 +- p6-field-inspection/src/shared/notes.ts | 10 + p6-field-inspection/src/shared/types.ts | 36 +- p6-field-inspection/src/web/App.tsx | 1741 +++++++++++++---- .../src/web/ChecklistSummary.tsx | 30 + .../src/web/CreationDialog.tsx | 143 ++ p6-field-inspection/src/web/DetailTabs.tsx | 61 + .../src/web/InspectionHistory.tsx | 57 + p6-field-inspection/src/web/SavedDrafts.tsx | 59 + .../src/web/TechnicalDetails.tsx | 82 + p6-field-inspection/src/web/api.ts | 80 +- p6-field-inspection/src/web/authorization.ts | 2 +- p6-field-inspection/src/web/drafts.ts | 101 +- p6-field-inspection/src/web/styles.css | 531 ++++- p6-field-inspection/test/app.test.ts | 312 ++- .../test/authorization.test.ts | 55 + .../test/browser-stack.test.ts | 145 ++ p6-field-inspection/test/build.test.ts | 63 + p6-field-inspection/test/database.test.ts | 180 +- p6-field-inspection/test/drafts.test.ts | 104 + p6-field-inspection/test/notes.test.ts | 13 + p6-field-inspection/test/setup.test.ts | 61 + p6-field-inspection/test/static.test.ts | 168 ++ 38 files changed, 5404 insertions(+), 614 deletions(-) create mode 100644 p6-field-inspection/e2e/readiness.e2e.ts create mode 100644 p6-field-inspection/e2e/refinement.e2e.ts create mode 100644 p6-field-inspection/e2e/workspace.e2e.ts create mode 100644 p6-field-inspection/src/shared/notes.ts create mode 100644 p6-field-inspection/src/web/ChecklistSummary.tsx create mode 100644 p6-field-inspection/src/web/CreationDialog.tsx create mode 100644 p6-field-inspection/src/web/DetailTabs.tsx create mode 100644 p6-field-inspection/src/web/InspectionHistory.tsx create mode 100644 p6-field-inspection/src/web/SavedDrafts.tsx create mode 100644 p6-field-inspection/src/web/TechnicalDetails.tsx create mode 100644 p6-field-inspection/test/authorization.test.ts create mode 100644 p6-field-inspection/test/browser-stack.test.ts create mode 100644 p6-field-inspection/test/build.test.ts create mode 100644 p6-field-inspection/test/drafts.test.ts create mode 100644 p6-field-inspection/test/notes.test.ts create mode 100644 p6-field-inspection/test/setup.test.ts create mode 100644 p6-field-inspection/test/static.test.ts diff --git a/p6-field-inspection/README.md b/p6-field-inspection/README.md index 9708fae..c2e4263 100644 --- a/p6-field-inspection/README.md +++ b/p6-field-inspection/README.md @@ -1,112 +1,126 @@ # P6 - Reauthorizing Offline Field Inspections with Cedarling -P6 is a field-service inspection console showing how Cedarling reauthorizes an -offline action when it returns to a trusted Node.js server. The browser may use -cached policy context to guide a technician, while the server remains the -authority for every work-order read, creation, deletion, reassignment, and -inspection commit. +P6 lets technicians inspect equipment, save answers offline, and submit them +when they reconnect. A supervisor reviews the inspection, requests corrections, +or completes the work order. -The marked capabilities currently use fake permissive decisions; the Cedarling -tutorial replaces those seams with browser preflight and authoritative server -decisions. +This learning baseline authenticates users and validates requests. Business +permissions return `FAKE ALLOW`; Cedarling will enforce assignments and +supervisor authority. ## Architecture -```text -Elena / Malik / Rowan ── sign in ──→ Tutorial IdP - │ - └── work-order request ──→ React field console - │ local draft + advisory preflight - ▼ - Browser Cedarling - │ reconnect - ▼ - Node.js API (PEP) - │ current principal + assignment + version - ▼ - Server Cedarling - │ │ - DENY ALLOW → conditional SQLite effect +```mermaid +flowchart TD + Browser["React console"] -->|Sign in| IdP["Tutorial IdP"] + Browser --> BrowserCheck["Browser FAKE ALLOW"] + BrowserCheck --> Drafts["IndexedDB drafts"] + Drafts -->|Reconnect and submit| API["Fastify API"] + Browser -->|Work-order operations| API + API --> Facts["Current SQLite facts"] + Facts --> Fake["FAKE ALLOW"] + Fake --> Effect["Conditional database write or response"] ``` ## Prerequisites -- Docker Desktop or Docker Engine with Compose, or -- Node.js 24.21 or newer within 24.x and pnpm 10. - -The commands work from PowerShell, macOS terminals, and Ubuntu shells. +- Docker Desktop or Docker Engine with Compose. +- Node.js 24.21 or newer within 24.x. +- pnpm 10.17.1. ## Run -Start the application and its own IdP: +From this project directory, start with Docker: ```bash docker compose up --build ``` -Open . The issuer is . Stop the stack with `Ctrl+C`, then `docker compose down`. +Open . The tutorial IdP is . +Stop with `Ctrl+C`, then `docker compose down`. -For native development, run from this project directory: +For native development: ```bash pnpm --dir ../shared/identity-provider install --frozen-lockfile -pnpm --dir ../shared/identity-provider build pnpm install --frozen-lockfile -pnpm run setup pnpm dev ``` -`pnpm dev` starts this project’s IdP and application together. +`pnpm dev` prepares configuration and starts the IdP and application. +After a browser-source rebuild, refresh the page. -For `pnpm build` followed by `pnpm start`, first run `node --env-file=.local/idp/.env ../shared/identity-provider/dist/main.js` in another terminal in this project directory. +## Exercise -Use `pnpm dev -- --reset` only when you want to restore the tutorial fixtures. +Choose an account. Use the prefilled username, or enter its lowercase first +name, and any non-empty password such as `cedarling-is-awesome`. -## Exercise +- Elena inspects Cooling pump 17 at North Plant. +- Malik is a technician who can take over Elena's work order. +- Rowan supervises assignments and inspection reviews. + +Use separate browser profiles or a private window for Elena and Rowan; +ordinary tabs share a session. Keep Elena's page open while offline. + +1. As Elena, open **Cooling pump 17** and set that browser's network to + **Offline** in developer tools. Answer the three checks, add notes for any + failure, then choose **Queue inspection**. +2. Sign in as Rowan in the online browser. Use **Manage order** to reassign + the pump to Malik. +3. Restore Elena's connection. Her queued inspection is sent automatically; + choose **Retry sending** if needed. It reaches **Awaiting review** even + though Elena is no longer assigned. +4. As Rowan, refresh the order and review it. Failed checks require + **Request corrections** and review notes. **Complete work order** requires + all checks to pass. + +**Refresh work order** loads current assignments and history and renews the +five-minute offline queue window for an open order or one needing corrections. +After expiry, local saving remains available; reconnect and refresh before +queuing new work. Refresh and review the record after a version conflict. -The business workflow is an inspection queue used during unreliable field -connectivity: +As Elena, also try opening Malik's order, using **Add work order**, reassigning +an order, reviewing a submission, or deleting an uninspected order. These actions +succeed despite her technician role; lifecycle and version checks still apply. -- **Elena** — technician initially assigned to Cooling pump 17 at North Plant. -- **Malik** — technician assigned to Backup generator 04 and the reassignment - target for Elena's work order. -- **Rowan** — field supervisor responsible for changing technician assignments. +## Data and reset -The queue starts with six work orders split between Elena and Malik. Use the -compact add control to create another assigned order, and delete an open order -from its detail panel. The current interface exposes those two supervisor -operations to every persona so their authorization boundaries remain easy to -exercise. +Restarts preserve your changes. For native data, stop the app, run +`pnpm reset`, then `pnpm dev`. Reset restores work orders and clears inspection +history and sessions. -Use two independent browser profiles. As Elena, open Cooling pump 17, go -offline, complete the checklist, and queue the inspection. As Rowan in the -second profile, reassign that work order to Malik. Reconnect Elena and retry the -queued submission. +For the default Docker setup: -The current decision seams permit five protected outcomes too broadly: Elena -can read Malik's work order; create, delete, and reassign work orders reserved -for Rowan; and complete a queued inspection after Rowan assigns the work to -Malik. Cedarling will restrict browser affordances and independently recheck -the current principal, assignment, status, and versions at the server before -any effect. +```bash +docker compose down +docker volume rm p6-field-inspection_p6-data +docker compose up --build +``` + +This removes application data and preserves IdP credentials. Custom Compose +project names or data mounts require their corresponding data target. + +Browser drafts remain on the device. Remove them separately with +**Discard saved draft** or **Saved drafts on this device**. ## Commands -| Command | Purpose | -| ---------------- | --------------------------------------------------------- | -| `pnpm run setup` | Create validated native configuration and SQLite fixtures | -| `pnpm dev` | Build dependencies and run the watched native stack | -| `pnpm start` | Run the built application | -| `pnpm reset` | Restore deterministic field-inspection fixtures | -| `pnpm test:e2e` | Exercise the browser login and offline draft lifecycle | -| `pnpm check` | Run formatting, lint, types, tests, build, and browser test | +| Command | Purpose | +| --- | --- | +| `pnpm run setup` | Prepare configuration and inspection fixtures | +| `pnpm start` | Run the built app; requires a running IdP | +| `pnpm test:e2e` | Exercise the browser inspection workflow | ## Verify -Install Chromium once before the complete local check: +Stop this project's running stack before checking local changes. `pnpm check` +runs formatting, lint, type checks, tests, build, and browser checks using +disposable data. Install Chromium once: ```bash pnpm exec playwright install chromium pnpm check -pnpm audit --audit-level low ``` + +On Linux, add `--with-deps` to the Chromium installation command if system +libraries are missing. diff --git a/p6-field-inspection/e2e/offline-draft.e2e.ts b/p6-field-inspection/e2e/offline-draft.e2e.ts index 7da8031..039df9f 100644 --- a/p6-field-inspection/e2e/offline-draft.e2e.ts +++ b/p6-field-inspection/e2e/offline-draft.e2e.ts @@ -11,7 +11,7 @@ async function signIn( await page.getByRole("textbox", { name: "and password" }).fill("tutorial"); await page.getByRole("button", { name: "Sign-in" }).click(); await page.getByRole("button", { name: "Continue" }).click(); - await expect(page).toHaveURL(/localhost:17006/u); + await expect(page).toHaveURL("http://localhost:17006/"); } async function signInProfile( @@ -24,7 +24,7 @@ async function signInProfile( return page; } -test("reauthorizes Elena's queued inspection after Rowan reassigns it", async ({ +test("baseline accepts Elena's queued inspection after Rowan reassigns it", async ({ browser, page, }) => { @@ -36,10 +36,17 @@ test("reauthorizes Elena's queued inspection after Rowan reassigns it", async ({ await page.context().setOffline(true); await expect(page.getByText("Offline", { exact: true })).toBeVisible(); - await page.getByRole("checkbox", { name: "Safety guard secured" }).check(); - await page.getByRole("checkbox", { name: "Fluid level checked" }).check(); await page - .getByRole("checkbox", { name: "Operating temperature recorded" }) + .getByRole("group", { name: "Equipment condition", exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); + await page + .getByRole("group", { name: "Safety checks", exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); + await page + .getByRole("group", { name: "Functional check", exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) .check(); await page .getByRole("textbox", { name: "Inspection notes" }) @@ -49,7 +56,10 @@ test("reauthorizes Elena's queued inspection after Rowan reassigns it", async ({ page.getByText("Inspection queued until reconnect."), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Retry synchronization" }), + page.getByRole("button", { name: "Retry sending" }), + ).toBeDisabled(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), ).toBeDisabled(); await expect .poll(() => @@ -86,30 +96,46 @@ test("reauthorizes Elena's queued inspection after Rowan reassigns it", async ({ await expect( supervisor.getByRole("heading", { name: "Cooling pump 17" }), ).toBeVisible(); + await supervisor.getByRole("tab", { name: "Manage order" }).click(); await expect( supervisor.getByRole("button", { name: "Reassign" }), ).toBeEnabled(); await supervisor.getByRole("button", { name: "Reassign" }).click(); await expect(supervisor.getByText("Work order reassigned.")).toBeVisible(); + await supervisor.getByRole("tab", { name: "Activity" }).click(); + await expect( + supervisor + .getByRole("region", { name: "Assignment history" }) + .getByText("Elena Rossi → Malik Johnson"), + ).toBeVisible(); } finally { await supervisorContext.close(); } await page.context().setOffline(false); await expect( - page.getByText("Inspection synchronized and work order completed."), + page.getByText("Inspection submitted. It is awaiting supervisor review."), ).toBeVisible(); await expect( page.getByRole("heading", { name: "Cooling pump 17" }), - ).toHaveCount(0); + ).toBeVisible(); await expect( page.getByRole("button", { name: "Submit inspection" }), - ).toBeEnabled(); - await expect( - page.getByRole("button", { name: /Cooling pump 17.*completed/u }), - ).toBeVisible(); + ).toHaveCount(0); + const reviewerContext = await browser.newContext(); + try { + const reviewer = await signInProfile(reviewerContext, "Rowan Lee", "rowan"); + await reviewer.getByRole("button", { name: /Cooling pump 17/u }).click(); + await expect(reviewer.getByText("Offline pump inspection")).toBeVisible(); + await reviewer.getByRole("button", { name: "Complete work order" }).click(); + await expect( + reviewer.getByText("Review recorded. Work order completed."), + ).toBeVisible(); + } finally { + await reviewerContext.close(); + } - await page.getByRole("button", { name: /Cooling pump 17/u }).click(); + await page.reload(); await expect(page.getByText("Inspection completed")).toBeVisible(); await expect(page.getByText(/This work order is read-only/u)).toBeVisible(); await expect( @@ -123,8 +149,19 @@ test("preserves an offline draft when its work order was deleted", async ({ }) => { await signIn(page, "Elena Rossi", "elena"); await page.getByRole("button", { name: /Air compressor 09/u }).click(); + await expect( + page.getByRole("heading", { name: "Air compressor 09" }), + ).toBeVisible(); await page.context().setOffline(true); - await page.getByRole("checkbox", { name: "Safety guard secured" }).check(); + for (const name of [ + "Equipment condition", + "Safety checks", + "Functional check", + ]) + await page + .getByRole("group", { name, exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); await page .getByRole("textbox", { name: "Inspection notes" }) .fill("Preserve these offline observations"); @@ -140,6 +177,7 @@ test("preserves an offline draft when its work order was deleted", async ({ await supervisor .getByRole("button", { name: /Air compressor 09/u }) .click(); + await supervisor.getByRole("tab", { name: "Manage order" }).click(); await supervisor.getByRole("button", { name: "Delete order" }).click(); await supervisor.getByRole("button", { name: "Confirm delete" }).click(); await expect( @@ -186,6 +224,206 @@ test("distinguishes session-service failure from signed out", async ({ ).toHaveCount(0); }); +test("explains expiry while keeping an offline draft editable", async ({ + page, +}) => { + await signIn(page, "Elena Rossi", "elena"); + await page + .getByRole("button", { name: /Fire suppression panel 12/u }) + .click(); + await expect( + page.getByRole("heading", { name: "Fire suppression panel 12" }), + ).toBeVisible(); + const notes = page.getByRole("textbox", { name: "Inspection notes" }); + for (const name of [ + "Equipment condition", + "Safety checks", + "Functional check", + ]) + await page + .getByRole("group", { name, exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); + await notes.fill("Keep this local observation"); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect( + page.getByText("Draft saved on this device.", { exact: true }), + ).toBeVisible(); + await page.clock.install(); + await page.context().setOffline(true); + await page.clock.pauseAt(new Date(Date.now() + 1000)); + // Simulate expiry while the display timer is paused (for example, a sleeping tab). + await page.clock.setSystemTime(new Date(Date.now() + 301_000)); + await expect( + page.getByRole("button", { name: "Queue inspection" }), + ).toBeEnabled(); + await page.getByRole("button", { name: "Queue inspection" }).click(); + await expect( + page.getByText("Refresh the work order before queuing this inspection.", { + exact: true, + }), + ).toBeVisible(); + await expect( + page.getByText(/Offline queue permission expired. Reconnect/u), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Queue inspection" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Refresh work order" }), + ).toBeDisabled(); + await expect(notes).toHaveValue("Keep this local observation"); + await notes.fill("Edited after expiry"); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect( + page.getByText("Draft saved on this device.", { exact: true }), + ).toBeVisible(); + await expect(notes).toHaveValue("Edited after expiry"); + await expect(page.getByRole("button", { name: "Retry sending" })).toHaveCount( + 0, + ); +}); + +test("retains a failed queued request and sends it after envelope expiry", async ({ + page, +}) => { + await signIn(page, "Elena Rossi", "elena"); + await page.getByRole("button", { name: /Conveyor motor 08/u }).click(); + for (const name of [ + "Equipment condition", + "Safety checks", + "Functional check", + ]) + await page + .getByRole("group", { name, exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); + await page.context().setOffline(true); + await page.getByRole("button", { name: "Queue inspection" }).click(); + await expect( + page.getByText("Inspection queued until reconnect."), + ).toBeVisible(); + await page.clock.install(); + await page.clock.fastForward(301_000); + await page.route("**/api/work-orders/wo-conveyor-08/inspections", (route) => + route.abort(), + ); + await page.context().setOffline(false); + await expect( + page.getByText(/The request could not be completed/u), + ).toBeVisible(); + await page.getByRole("tab", { name: "Activity", exact: true }).click(); + await page.getByText("Technical details", { exact: true }).click(); + await expect( + page.getByText("Connection failed; outcome unknown"), + ).toBeVisible(); + await expect(page.getByText("No decision received")).toBeVisible(); + await page.getByRole("tab", { name: "Inspection", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Retry sending" }), + ).toBeEnabled(); + await page.unroute("**/api/work-orders/wo-conveyor-08/inspections"); + await page.getByRole("button", { name: "Retry sending" }).click(); + await expect( + page.getByText("Inspection submitted. It is awaiting supervisor review."), + ).toBeVisible(); + await page.getByRole("tab", { name: "Activity", exact: true }).click(); + await expect( + page.locator(".technical-details").getByText("FAKE ALLOW", { exact: true }), + ).toHaveCount(2); + await expect(page.getByText("HTTP 200", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: /Ventilation unit 03/u }).click(); + await expect( + page.getByRole("heading", { name: "Last action", exact: true }), + ).toHaveCount(0); +}); + +test("shows an empty queue on mobile and translates an unavailable work order", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await signIn(page, "Elena Rossi", "elena"); + await page.route("**/api/work-orders/wo-ventilation-03", (route) => + route.fulfill({ status: 404, json: { error: "not_found" } }), + ); + await page.getByRole("button", { name: /Ventilation unit 03/u }).click(); + await page.getByRole("button", { name: "Back to work orders" }).click(); + await expect(page.getByText("This work order is unavailable.")).toBeVisible(); + await page.route("**/api/work-orders", (route) => + route.fulfill({ + json: { workOrders: [], createAllowed: true, nextOffset: null }, + }), + ); + await page.reload(); + await expect( + page.getByText( + "No work orders in the queue. Add one to begin an inspection.", + ), + ).toBeVisible(); +}); + +test("requires explicit removal of incompatible drafts and confirms unsaved sign out", async ({ + page, +}) => { + await signIn(page, "Elena Rossi", "elena"); + await expect(page.locator(".detail-heading")).toBeVisible(); + await page.evaluate(async () => { + const request = indexedDB.open("p6-field-inspection", 1); + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const tx = db.transaction("drafts", "readwrite"); + tx.objectStore("drafts").put({ + key: "user-elena:wo-ventilation-03", + principalId: "user-elena", + workOrderId: "wo-ventilation-03", + formatVersion: 2, + checklist: { safetyGuardSecured: true }, + notes: "Notes from the earlier checklist", + status: "queued", + }); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + db.close(); + }); + await page.reload(); + await page.getByRole("button", { name: /Ventilation unit 03/u }).click(); + await expect( + page.getByText("Notes from the earlier checklist"), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Submit inspection", exact: true }), + ).toBeDisabled(); + page.once("dialog", (dialog) => dialog.dismiss()); + await page.getByRole("button", { name: "Discard old draft" }).click(); + await expect( + page.getByText("Notes from the earlier checklist"), + ).toBeVisible(); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Discard old draft" }).click(); + await expect( + page.getByRole("button", { name: "Submit inspection", exact: true }), + ).toBeEnabled(); + await page + .getByRole("textbox", { name: "Inspection notes" }) + .fill("Unsaved observation"); + await page.locator('summary[aria-label="Open account menu"]').click(); + await expect( + page.getByRole("button", { name: "Change account" }), + ).toBeVisible(); + page.once("dialog", (dialog) => dialog.dismiss()); + await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), + ).toHaveValue("Unsaved observation"); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await expect(page.getByRole("link", { name: /Elena Rossi/u })).toBeVisible(); +}); + test("adds and deletes an open work order", async ({ page }) => { await signIn(page, "Rowan Lee", "rowan"); await page.getByRole("button", { name: "Add work order" }).click(); @@ -201,9 +439,157 @@ test("adds and deletes an open work order", async ({ page }) => { await expect( page.getByRole("heading", { name: "Hydraulic press 05" }), ).toBeVisible(); + await page.getByRole("tab", { name: "Manage order" }).click(); await page.getByRole("button", { name: "Delete order" }).click(); await page.getByRole("button", { name: "Confirm delete" }).click(); await expect( page.getByRole("button", { name: /Hydraulic press 05/u }), ).toHaveCount(0); }); + +test("requires explicit checks, corrections, and review before completing a work order", async ({ + page, +}) => { + await signIn(page, "Malik Johnson", "malik"); + await page.getByRole("button", { name: /Backup generator 04/u }).click(); + await page.getByRole("button", { name: "Submit inspection" }).click(); + await expect( + page.getByText("Choose Pass or Fail for every check."), + ).toBeVisible(); + await page + .getByRole("group", { name: "Equipment condition", exact: true }) + .getByRole("radio", { name: "Fail", exact: true }) + .check(); + for (const name of ["Safety checks", "Functional check"]) + await page + .getByRole("group", { name, exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); + await page.getByRole("button", { name: "Submit inspection" }).click(); + await expect( + page.getByText("Describe the failed checks in the inspection notes."), + ).toBeVisible(); + await page + .getByRole("textbox", { name: "Inspection notes" }) + .fill("Guard needs fastening."); + await page.getByRole("button", { name: "Submit inspection" }).dblclick(); + await expect( + page.getByRole("button", { name: "Complete work order" }), + ).toBeDisabled(); + // The baseline permits this role gap, but still enforces the review lifecycle. + await page + .getByRole("textbox", { name: "Review notes" }) + .fill("Fasten the guard and recheck it."); + await page.getByRole("button", { name: "Request corrections" }).click(); + for (const name of [ + "Equipment condition", + "Safety checks", + "Functional check", + ]) + await page + .getByRole("group", { name, exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); + await page + .getByRole("textbox", { name: "Inspection notes" }) + .fill("Fastening complete, all checks passed."); + await page.getByRole("button", { name: "Submit inspection" }).click(); + await page.getByRole("button", { name: "Complete work order" }).click(); + await page.reload(); + await expect( + page.getByText("Inspection completed", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Fastening complete, all checks passed."), + ).toBeVisible(); + await page.getByRole("tab", { name: "Activity" }).click(); + await expect( + page.locator("#panel-activity .inspection-history details"), + ).toHaveCount(2); +}); + +test("keeps mobile navigation and scrolling inside the application", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await signIn(page, "Elena Rossi", "elena"); + await page.getByRole("button", { name: /Ventilation unit 03/u }).click(); + await expect( + page.getByRole("heading", { name: "Ventilation unit 03" }), + ).toBeFocused(); + await expect( + page.getByRole("heading", { name: "Work orders", exact: true }), + ).toBeHidden(); + const dimensions = await page.evaluate(() => ({ + height: document.documentElement.scrollHeight, + width: document.documentElement.scrollWidth, + footer: document.querySelector("footer")?.getBoundingClientRect().bottom, + })); + expect(dimensions).toEqual({ height: 844, width: 390, footer: 844 }); + await page.getByRole("button", { name: "Back to work orders" }).click(); + await expect( + page.getByRole("heading", { name: "Work orders", exact: true }), + ).toBeFocused(); +}); + +for (const viewport of [ + { width: 320, height: 568 }, + { width: 844, height: 390 }, +]) { + test(`keeps inspection controls reachable on small screens ${viewport.width}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await signIn(page, "Elena Rossi", "elena"); + await page.getByRole("button", { name: /Ventilation unit 03/u }).click(); + await expect + .poll(() => + page + .locator(".brand-lockup img") + .evaluate((image) => (image as HTMLImageElement).currentSrc), + ) + .toContain("cedarling-mark"); + await expect( + page.getByRole("heading", { name: "Ventilation unit 03" }), + ).toBeFocused(); + for (const control of [ + page + .getByRole("group", { name: "Equipment condition", exact: true }) + .getByText("Pass", { exact: true }), + page.getByRole("button", { name: "Submit inspection", exact: true }), + page.getByRole("button", { name: "Refresh work order", exact: true }), + ]) { + await expect(async () => { + await control.scrollIntoViewIfNeeded(); + const bounds = await control.boundingBox(); + if (!bounds) + throw new Error("Inspection control has no visible bounds"); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width); + expect(bounds.height).toBeGreaterThanOrEqual(44); + const footer = await page.locator("footer").boundingBox(); + if (!footer) throw new Error("Footer has no visible bounds"); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(footer.y); + }).toPass({ timeout: 5_000 }); + await control.click({ trial: true }); + } + await page.getByRole("tab", { name: "Manage order" }).click(); + await page + .getByRole("button", { name: "Delete order", exact: true }) + .click({ trial: true }); + expect( + await page + .locator(".workspace") + .evaluate((element) => element.scrollWidth - element.clientWidth), + ).toBe(0); + await page.getByRole("button", { name: "Back to work orders" }).click(); + await expect( + page.getByRole("heading", { name: "Work orders", exact: true }), + ).toBeFocused(); + await page.locator('summary[aria-label="Open account menu"]').click(); + await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await expect( + page.getByRole("link", { name: /Elena Rossi/u }), + ).toBeVisible(); + }); +} diff --git a/p6-field-inspection/e2e/readiness.e2e.ts b/p6-field-inspection/e2e/readiness.e2e.ts new file mode 100644 index 0000000..19cab09 --- /dev/null +++ b/p6-field-inspection/e2e/readiness.e2e.ts @@ -0,0 +1,323 @@ +import { expect, type Page, test } from "@playwright/test"; +import type { WorkOrder } from "../src/shared/types.ts"; + +async function signIn(page: Page) { + await page.goto("/"); + await page.getByRole("link", { name: /Elena Rossi/u }).click(); + await page.getByRole("textbox", { name: "Enter any login" }).fill("elena"); + await page.getByRole("textbox", { name: "and password" }).fill("tutorial"); + await page.getByRole("button", { name: "Sign-in" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: /Ventilation unit 03/u }).click(); + await expect( + page.getByRole("heading", { name: "Ventilation unit 03" }), + ).toBeVisible(); +} + +async function restoreStorage(page: Page) { + await page.evaluate(() => { + ( + window as Window & { restoreDraftStorage: () => void } + ).restoreDraftStorage(); + }); +} + +for (const failure of ["open", "read"] as const) { + test(`loads server records when draft ${failure} fails and recovers without losing edits`, async ({ + page, + }) => { + await page.addInitScript((method) => { + if (sessionStorage.getItem("p6-storage-test-restored") === "yes") return; + const originalOpen = IDBFactory.prototype.open; + const originalGet = IDBObjectStore.prototype.get; + Object.assign(window, { + restoreDraftStorage: () => { + IDBFactory.prototype.open = originalOpen; + IDBObjectStore.prototype.get = originalGet; + sessionStorage.setItem("p6-storage-test-restored", "yes"); + }, + }); + const fail = () => { + throw new DOMException("Storage blocked", "SecurityError"); + }; + if (method === "open") IDBFactory.prototype.open = fail; + else IDBObjectStore.prototype.get = fail; + }, failure); + await signIn(page); + if (failure === "read") { + await page.evaluate(async () => { + const request = indexedDB.open("p6-field-inspection", 1); + await new Promise((resolve, reject) => { + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction("drafts", "readwrite"); + transaction.objectStore("drafts").put({ + key: "user-elena:wo-ventilation-03", + principalId: "user-elena", + workOrderId: "wo-ventilation-03", + formatVersion: 2, + checklist: { oldCheck: true }, + notes: "An older checklist note", + }); + transaction.oncomplete = () => { + database.close(); + resolve(); + }; + transaction.onabort = () => { + database.close(); + reject(transaction.error); + }; + }; + }); + }); + } + const warning = page.getByRole("region", { + name: "Browser storage unavailable", + }); + await expect(warning).toBeVisible(); + await expect(page.getByRole("tab")).toHaveCount(3); + await expect( + page.getByRole("button", { name: "Add work order" }), + ).toBeEnabled(); + await expect( + page.getByRole("button", { name: "Save draft", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Submit inspection", exact: true }), + ).toBeDisabled(); + const notes = page.getByRole("textbox", { name: "Inspection notes" }); + await notes.fill("Keep my unsaved inspection"); + await page + .getByRole("group", { name: "Safety checks", exact: true }) + .getByRole("radio", { name: "Fail", exact: true }) + .check(); + await page.getByRole("tab", { name: "Manage order" }).click(); + await expect( + page.getByRole("button", { name: "Reassign", exact: true }), + ).toBeEnabled(); + await page.getByRole("tab", { name: "Inspection", exact: true }).click(); + await page.getByRole("button", { name: "Refresh queue" }).click(); + await expect(warning).toBeVisible(); + await restoreStorage(page); + await page.getByRole("button", { name: "Retry storage" }).click(); + await expect(warning).toHaveCount(0); + await expect(notes).toHaveValue("Keep my unsaved inspection"); + await expect( + page + .getByRole("group", { name: "Safety checks", exact: true }) + .getByRole("radio", { name: "Fail", exact: true }), + ).toBeChecked(); + await expect( + page.getByText("Unsaved changes", { exact: true }), + ).toBeVisible(); + if (failure === "read") { + await expect(page.getByText("An older checklist note")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Save draft", exact: true }), + ).toBeDisabled(); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Discard old draft" }).click(); + await expect(notes).toHaveValue("Keep my unsaved inspection"); + } + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect( + page.getByText("Draft saved on this device.", { exact: true }), + ).toBeVisible(); + await page.reload(); + await expect(notes).toHaveValue("Keep my unsaved inspection"); + }); +} + +test("retrying storage restores a saved queue without sending it", async ({ + page, +}) => { + await signIn(page); + await page.evaluate(async () => { + const session = await fetch("/api/session").then((response) => + response.json(), + ); + const order = await fetch("/api/work-orders/wo-ventilation-03").then( + (response) => response.json(), + ); + const request = indexedDB.open("p6-field-inspection", 1); + await new Promise((resolve, reject) => { + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction("drafts", "readwrite"); + transaction.objectStore("drafts").put({ + key: `${session.user.id}:${order.id}`, + principalId: session.user.id, + workOrderId: order.id, + expectedWorkOrderVersion: order.workOrderVersion, + checklist: { + equipmentConditionPassed: true, + safetyChecksPassed: true, + functionalCheckPassed: true, + }, + formatVersion: 3, + notes: "Saved queue to recover", + idempotencyKey: crypto.randomUUID(), + createdAt: new Date().toISOString(), + status: "queued", + }); + transaction.oncomplete = () => { + db.close(); + resolve(); + }; + transaction.onabort = () => { + db.close(); + reject(transaction.error); + }; + }; + }); + }); + await page.addInitScript(() => { + const original = IDBFactory.prototype.open; + Object.assign(window, { + restoreDraftStorage: () => { + IDBFactory.prototype.open = original; + }, + }); + IDBFactory.prototype.open = () => { + throw new DOMException("Blocked", "SecurityError"); + }; + }); + let submissions = 0; + await page.route("**/api/work-orders/*/inspections", async (route) => { + submissions += 1; + await route.fulfill({ status: 503, json: { error: "unavailable" } }); + }); + await page.reload(); + await expect( + page.getByRole("button", { name: "Retry storage" }), + ).toBeVisible(); + await restoreStorage(page); + await page.getByRole("button", { name: "Retry storage" }).click(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), + ).toHaveValue("Saved queue to recover"); + await expect( + page.getByRole("button", { name: "Retry sending" }), + ).toBeEnabled(); + await page.getByRole("tab", { name: "Activity", exact: true }).click(); + expect(submissions).toBe(0); +}); + +test("loads additional pages without changing selection, filters, or unsaved notes", async ({ + page, +}) => { + await signIn(page); + const records = await page.evaluate( + async () => + (await fetch("/api/work-orders").then((response) => response.json())) + .workOrders as WorkOrder[], + ); + const selected = records.find((order) => order.id === "wo-ventilation-03"); + if (!selected) throw new Error("Missing ventilation fixture"); + const later = { ...selected, id: "wo-later", equipment: "Later work order" }; + const offsets: string[] = []; + await page.route("**/api/work-orders?*", async (route) => { + const offset = + new URL(route.request().url()).searchParams.get("offset") ?? "0"; + offsets.push(offset); + await route.fulfill({ + json: + offset === "20" + ? { workOrders: [], createAllowed: true, nextOffset: 40 } + : { + workOrders: [selected, later], + createAllowed: true, + nextOffset: null, + }, + }); + }); + await page.route("**/api/work-orders", (route) => + route.fulfill({ + json: { workOrders: records, createAllowed: true, nextOffset: 20 }, + }), + ); + const notes = page.getByRole("textbox", { name: "Inspection notes" }); + await notes.fill("Keep across queue pages"); + await page.getByRole("button", { name: "Refresh queue" }).click(); + await page.getByRole("searchbox").fill("Ventilation"); + await page.getByRole("button", { name: "Load more" }).click(); + await expect.poll(() => offsets.length).toBe(1); + await expect(page.getByRole("button", { name: "Load more" })).toBeEnabled(); + await page.getByRole("button", { name: "Load more" }).click(); + await expect(page.getByRole("button", { name: "Load more" })).toHaveCount(0); + expect(offsets).toEqual(["20", "40"]); + await expect(page.getByRole("searchbox")).toHaveValue("Ventilation"); + await expect(notes).toHaveValue("Keep across queue pages"); + await expect( + page.getByRole("button", { name: /Ventilation unit 03/u }), + ).toHaveCount(1); + await page.getByRole("searchbox").clear(); + await expect( + page.getByRole("button", { name: /Later work order/u }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /Ventilation unit 03/u }), + ).toHaveAttribute("aria-current", "true"); +}); + +test("discards an old load-more response after a queue refresh", async ({ + page, +}) => { + await signIn(page); + const records = await page.evaluate( + async () => + (await fetch("/api/work-orders").then((response) => response.json())) + .workOrders as WorkOrder[], + ); + const selected = records.find((order) => order.id === "wo-ventilation-03"); + if (!selected) throw new Error("Missing ventilation fixture"); + let release: () => void = () => {}; + const delayed = new Promise((resolve) => { + release = resolve; + }); + let pageRequests = 0; + await page.route("**/api/work-orders?offset=20", async (route) => { + pageRequests += 1; + await delayed; + await route.fulfill({ + json: { + workOrders: [{ ...selected, id: "wo-stale", equipment: "Stale page" }], + createAllowed: true, + nextOffset: null, + }, + }); + }); + await page.route("**/api/work-orders", (route) => + route.fulfill({ + json: { workOrders: records, createAllowed: true, nextOffset: 20 }, + }), + ); + await page + .getByRole("textbox", { name: "Inspection notes" }) + .fill("Keep through refresh"); + await page.getByRole("button", { name: "Refresh queue" }).click(); + await page.getByRole("button", { name: "Load more" }).click(); + await expect.poll(() => pageRequests).toBe(1); + await expect(page.getByRole("button", { name: "Load more" })).toBeDisabled(); + await page.getByRole("button", { name: "Refresh queue" }).click(); + await expect(page.getByRole("button", { name: "Load more" })).toBeEnabled(); + const finished = page.waitForResponse("**/api/work-orders?offset=20"); + release(); + await finished; + // Wait for the browser to process the response before inspecting the queue. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await expect(page.getByRole("button", { name: /Stale page/u })).toHaveCount( + 0, + ); + await expect(page.getByRole("button", { name: "Load more" })).toBeEnabled(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), + ).toHaveValue("Keep through refresh"); +}); diff --git a/p6-field-inspection/e2e/refinement.e2e.ts b/p6-field-inspection/e2e/refinement.e2e.ts new file mode 100644 index 0000000..c1ec350 --- /dev/null +++ b/p6-field-inspection/e2e/refinement.e2e.ts @@ -0,0 +1,192 @@ +import { expect, type Page, test } from "@playwright/test"; +import type { WorkOrderDetail } from "../src/shared/types.ts"; + +const workOrderPath = "**/api/work-orders/wo-ventilation-03"; + +async function signIn(page: Page) { + await page.goto("/"); + await page.getByRole("link", { name: /Elena Rossi/u }).click(); + await page.getByRole("textbox", { name: "Enter any login" }).fill("elena"); + await page + .getByRole("textbox", { name: "and password" }) + .fill("cedarling-is-awesome"); + await page.getByRole("button", { name: "Sign-in" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: /Ventilation unit 03/u }).click(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), + ).toBeVisible(); +} + +test("refresh renews an expired queue window, reports unchanged data and ignores duplicate clicks", async ({ + page, +}) => { + let reads = 0; + let release = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + await page.route(workOrderPath, async (route) => { + reads += 1; + const response = await route.fetch(); + const record: WorkOrderDetail = await response.json(); + if (reads !== 1) await pending; + await route.fulfill({ + response, + json: + reads === 1 + ? { + ...record, + envelope: { + ...record.envelope, + expiresAt: new Date(0).toISOString(), + }, + } + : record, + }); + }); + await signIn(page); + await expect( + page.getByRole("button", { name: "Submit inspection", exact: true }), + ).toBeDisabled(); + await page + .getByRole("button", { name: "Refresh work order", exact: true }) + .evaluate((button: HTMLButtonElement) => { + button.click(); + button.click(); + }); + try { + await expect( + page.getByRole("button", { name: "Refreshing…", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), + ).toBeDisabled(); + await expect.poll(() => reads).toBe(2); + } finally { + release(); + } + await expect( + page.getByText("Work order is up to date.", { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Submit inspection", exact: true }), + ).toBeEnabled(); + expect(reads).toBe(2); + await expect( + page.getByText("Technical details", { exact: true }), + ).toBeHidden(); + await page.getByRole("tab", { name: "Activity", exact: true }).click(); + const technical = page.locator(".technical-details"); + await expect(technical).not.toHaveAttribute("open", ""); + await technical.locator("summary").click(); + await expect(technical).toContainText("Work v1 · Assignment v1"); +}); + +test("a failed refresh keeps unsaved notes and a canceled refresh sends no request", async ({ + page, +}) => { + await signIn(page); + const notes = page.getByRole("textbox", { name: "Inspection notes" }); + await notes.fill("Keep this observation"); + let attempts = 0; + await page.route(workOrderPath, (route) => { + attempts += 1; + return route.fulfill({ status: 503, json: { error: "unavailable" } }); + }); + page.once("dialog", (dialog) => dialog.dismiss()); + await page + .getByRole("button", { name: "Refresh work order", exact: true }) + .click(); + expect(attempts).toBe(0); + await expect(notes).toHaveValue("Keep this observation"); + page.once("dialog", (dialog) => dialog.accept()); + await page + .getByRole("button", { name: "Refresh work order", exact: true }) + .click(); + await expect( + page.getByText(/The request could not be completed/u), + ).toBeVisible(); + await expect(notes).toHaveValue("Keep this observation"); + await expect( + page.getByText("Work order is up to date.", { exact: true }), + ).toHaveCount(0); + await page.unroute(workOrderPath); + page.once("dialog", (dialog) => dialog.accept()); + await page + .getByRole("button", { name: "Refresh work order", exact: true }) + .click(); + await expect( + page.getByText("Work order is up to date.", { exact: true }), + ).toBeVisible(); + await expect(notes).toHaveValue(""); +}); + +test("refresh shows changed server facts while retaining a saved draft's expected version", async ({ + page, +}) => { + await signIn(page); + const notes = page.getByRole("textbox", { name: "Inspection notes" }); + await notes.fill("Saved before the record changed"); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect( + page.getByText("Draft saved on this device.", { exact: true }), + ).toBeVisible(); + await page.route(workOrderPath, async (route) => { + const response = await route.fetch(); + const record: WorkOrderDetail = await response.json(); + await route.fulfill({ + response, + json: { + ...record, + workOrderVersion: 3, + assignmentEpoch: 2, + assigneeId: "user-elena", + assigneeName: "Elena Rossi", + status: "changes_requested", + }, + }); + }); + await page + .getByRole("button", { name: "Refresh work order", exact: true }) + .click(); + await expect( + page.getByText("Work order updated.", { exact: true }), + ).toBeVisible(); + await expect(page.locator(".record-context")).toContainText( + "Assigned to Elena Rossi", + ); + await expect(notes).toHaveValue("Saved before the record changed"); + await expect(page.locator(".version-conflict")).toContainText( + "This draft uses work v1. The current record is v3.", + ); + await page.getByRole("tab", { name: "Activity", exact: true }).click(); + await page.getByText("Technical details", { exact: true }).click(); + await expect(page.locator(".technical-details")).toContainText( + "Work v3 · Assignment v2", + ); + await page.reload(); + await expect(page.locator(".version-conflict")).toContainText( + "This draft uses work v1.", + ); +}); + +test("change account confirms unsaved changes and ends the session", async ({ + page, +}) => { + await signIn(page); + const notes = page.getByRole("textbox", { name: "Inspection notes" }); + await notes.fill("Before account change"); + await page.locator('summary[aria-label="Open account menu"]').click(); + page.once("dialog", (dialog) => dialog.dismiss()); + await page + .getByRole("button", { name: "Change account", exact: true }) + .click(); + await expect(notes).toHaveValue("Before account change"); + page.once("dialog", (dialog) => dialog.accept()); + await page + .getByRole("button", { name: "Change account", exact: true }) + .click(); + await expect(page.getByRole("link", { name: /Elena Rossi/u })).toBeVisible(); + expect((await page.request.get("/api/session")).status()).toBe(401); +}); diff --git a/p6-field-inspection/e2e/workspace.e2e.ts b/p6-field-inspection/e2e/workspace.e2e.ts new file mode 100644 index 0000000..852fae8 --- /dev/null +++ b/p6-field-inspection/e2e/workspace.e2e.ts @@ -0,0 +1,306 @@ +import { expect, type Page, test } from "@playwright/test"; + +async function signIn(page: Page) { + await page.goto("/"); + await page.getByRole("link", { name: /Elena Rossi/u }).click(); + await page.getByRole("textbox", { name: "Enter any login" }).fill("elena"); + await page.getByRole("textbox", { name: "and password" }).fill("tutorial"); + await page.getByRole("button", { name: "Sign-in" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: /Ventilation unit 03/u }).click(); +} + +test("keeps a denied submission recoverable after read access is lost and the page reloads", async ({ + page, +}) => { + await signIn(page); + let denied = false; + await page.route("**/api/work-orders**", async (route) => { + const path = new URL(route.request().url()).pathname; + if (route.request().method() === "POST" && path.endsWith("/inspections")) { + denied = true; + await route.fulfill({ status: 403, json: { error: "forbidden" } }); + } else if (denied && path === "/api/work-orders") { + await route.fulfill({ + json: { workOrders: [], createAllowed: true, nextOffset: null }, + }); + } else if (denied) { + await route.fulfill({ status: 404, json: { error: "not_found" } }); + } else await route.continue(); + }); + for (const name of [ + "Equipment condition", + "Safety checks", + "Functional check", + ]) + await page + .getByRole("group", { name, exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); + await page + .getByRole("textbox", { name: "Inspection notes" }) + .fill("Recover my denied inspection"); + await page + .getByRole("button", { name: "Submit inspection", exact: true }) + .click(); + await expect.poll(() => denied).toBe(true); + await expect( + page.getByText("Your account cannot perform this action."), + ).toBeVisible(); + await page.reload(); + await page + .getByText(/Saved drafts on this device/u) + .first() + .click(); + await expect( + page.getByText("Recover my denied inspection").first(), + ).toBeVisible(); +}); + +test("keeps inspection inputs through tabs, filters, and keyboard navigation", async ({ + page, +}) => { + await signIn(page); + const notes = page.getByRole("textbox", { name: "Inspection notes" }); + await notes.fill("Check the mounting after lunch"); + await page + .getByRole("group", { name: "Safety checks", exact: true }) + .getByRole("radio", { name: "Fail", exact: true }) + .check(); + await expect(page.getByText("1 of 3 checks answered")).toBeVisible(); + await page.getByRole("tab", { name: "Activity", exact: true }).click(); + await page.keyboard.press("ArrowRight"); + await expect(page.getByRole("tab", { name: "Manage order" })).toBeFocused(); + await expect(page.getByRole("tab", { name: "Manage order" })).toHaveAttribute( + "aria-selected", + "true", + ); + await page.keyboard.press("Home"); + await expect(notes).toHaveValue("Check the mounting after lunch"); + await page + .getByRole("searchbox", { name: "Search work orders" }) + .fill("not-a-matching-order"); + await expect(page.getByText(/No matching work orders/u)).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Ventilation unit 03" }), + ).toBeVisible(); + await expect(notes).toHaveValue("Check the mounting after lunch"); + await page.getByRole("searchbox").clear(); + await page + .getByRole("combobox", { name: "Technician", exact: true }) + .selectOption("user-malik"); + await expect(notes).toHaveValue("Check the mounting after lunch"); + await page + .getByRole("combobox", { name: "Technician", exact: true }) + .selectOption(""); + await expect( + page.getByRole("button", { name: /Ventilation unit 03/u }), + ).toHaveAttribute("aria-current", "true"); +}); + +test("creation dialog contains focus, confirms discard, and leaves the inspection untouched", async ({ + page, +}) => { + await signIn(page); + await page + .getByRole("textbox", { name: "Inspection notes" }) + .fill("Unsaved site observation"); + const opener = page.getByRole("button", { name: "Add work order" }); + await opener.click(); + const dialog = page.getByRole("dialog", { name: "New work order" }); + await expect( + dialog.getByRole("textbox", { name: "Equipment", exact: true }), + ).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await page.keyboard.press("Tab"); + await dialog + .getByRole("textbox", { name: "Equipment", exact: true }) + .fill("Unfinished order"); + page.once("dialog", (prompt) => prompt.dismiss()); + await page.keyboard.press("Escape"); + await expect(dialog).toBeVisible(); + page.once("dialog", (prompt) => prompt.accept()); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toHaveCount(0); + await expect(opener).toBeFocused(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), + ).toHaveValue("Unsaved site observation"); +}); + +test("creation errors keep entries and a double click creates only one work order", async ({ + page, +}) => { + await signIn(page); + let attempts = 0; + await page.route("**/api/work-orders", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + attempts += 1; + if (attempts === 1) + await route.fulfill({ status: 503, json: { error: "unavailable" } }); + else await route.continue(); + }); + await page.getByRole("button", { name: "Add work order" }).click(); + const dialog = page.getByRole("dialog"); + await dialog + .getByRole("textbox", { name: "Equipment", exact: true }) + .fill("Dialog test fan"); + await dialog + .getByRole("textbox", { name: "Site", exact: true }) + .fill("Test bay"); + await dialog.getByRole("button", { name: "Add to queue" }).click(); + await expect(dialog.getByRole("alert")).toContainText( + "could not be completed", + ); + await expect( + dialog.getByRole("textbox", { name: "Equipment", exact: true }), + ).toHaveValue("Dialog test fan"); + await dialog.getByRole("button", { name: "Add to queue" }).dblclick(); + await expect( + page.getByRole("heading", { name: "Dialog test fan" }), + ).toBeFocused(); + expect(attempts).toBe(2); + await expect( + page.getByRole("button", { name: /Dialog test fan/u }), + ).toHaveCount(1); +}); + +test("storage failure preserves unsaved inputs and does not claim a successful save", async ({ + page, +}) => { + await signIn(page); + await page + .getByRole("textbox", { name: "Inspection notes" }) + .fill("Keep this observation"); + await page.evaluate(() => { + const original = IDBObjectStore.prototype.put; + Object.assign(window, { + restoreDraftStorage: () => { + IDBObjectStore.prototype.put = original; + }, + }); + IDBObjectStore.prototype.put = () => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }; + }); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect( + page.getByText(/Browser storage could not be accessed/u), + ).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), + ).toHaveValue("Keep this observation"); + await expect( + page.getByText("Unsaved changes", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Draft saved on this device.", { exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Save draft", exact: true }), + ).toBeDisabled(); + await page.evaluate(() => { + ( + window as Window & { restoreDraftStorage: () => void } + ).restoreDraftStorage(); + }); + await page.getByRole("button", { name: "Retry storage" }).click(); + await expect( + page.getByRole("textbox", { name: "Inspection notes" }), + ).toHaveValue("Keep this observation"); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect( + page.getByText("Draft saved on this device.", { exact: true }), + ).toBeVisible(); +}); + +test("bounds multibyte inspection notes without truncating the text", async ({ + page, +}) => { + await signIn(page); + const notes = page.getByRole("textbox", { name: "Inspection notes" }); + await notes.fill("é".repeat(501)); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect( + page.getByText(/Notes must fit within 1,000 UTF-8 bytes/u), + ).toBeVisible(); + await expect(notes).toHaveValue("é".repeat(501)); + await expect(notes).toHaveAttribute("aria-invalid", "true"); + await notes.fill("é".repeat(500)); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect( + page.getByText("Draft saved on this device.", { exact: true }), + ).toBeVisible(); +}); + +test("review notes survive tab changes and use the same byte limit", async ({ + page, +}) => { + await signIn(page); + await page.getByRole("button", { name: "Add work order" }).click(); + await page + .getByRole("dialog") + .getByRole("textbox", { name: "Equipment", exact: true }) + .fill("Review test fan"); + await page + .getByRole("textbox", { name: "Site", exact: true }) + .fill("Test bay"); + await page.getByRole("button", { name: "Add to queue" }).click(); + for (const name of [ + "Equipment condition", + "Safety checks", + "Functional check", + ]) + await page + .getByRole("group", { name, exact: true }) + .getByRole("radio", { name: "Pass", exact: true }) + .check(); + await page + .getByRole("button", { name: "Submit inspection", exact: true }) + .click(); + const notes = page.getByRole("textbox", { name: "Review notes" }); + await notes.fill("🛠".repeat(251)); + await page.getByRole("tab", { name: "Activity", exact: true }).click(); + await page.getByRole("tab", { name: "Manage order" }).click(); + await page.getByRole("tab", { name: "Inspection", exact: true }).click(); + await expect(notes).toHaveValue("🛠".repeat(251)); + await page.getByRole("button", { name: "Complete work order" }).click(); + await expect( + page.getByText(/Notes must fit within 1,000 UTF-8 bytes/u), + ).toBeVisible(); + await notes.fill("Reviewed the inspection\nReady for service."); + page.once("dialog", (dialog) => dialog.dismiss()); + await page + .getByRole("button", { name: "Refresh work order", exact: true }) + .click(); + await expect(notes).toHaveValue( + "Reviewed the inspection\nReady for service.", + ); + await page.getByRole("button", { name: "Complete work order" }).click(); + await expect( + page.getByText("Review recorded. Work order completed."), + ).toBeVisible(); + const history = page.getByRole("region", { name: "Inspection history" }); + await expect( + history + .getByRole("list", { name: "Inspection results" }) + .getByRole("listitem"), + ).toHaveText([ + "Equipment conditionPass", + "Safety checksPass", + "Functional checkPass", + ]); + await expect(history.locator("em")).toHaveText( + "Reviewed the inspection\nReady for service.", + ); + await page.getByRole("tab", { name: "Activity", exact: true }).click(); + await page.getByText("Technical details", { exact: true }).click(); + await expect(page.locator(".technical-details")).toContainText( + "inspection.review", + ); + await expect( + page.locator(".technical-details").getByText("FAKE ALLOW", { exact: true }), + ).toHaveCount(2); + await expect(page.locator(".technical-details")).toContainText("HTTP 200"); +}); diff --git a/p6-field-inspection/package.json b/p6-field-inspection/package.json index af0da02..73bab37 100644 --- a/p6-field-inspection/package.json +++ b/p6-field-inspection/package.json @@ -12,7 +12,7 @@ "dev": "node scripts/dev.mjs", "start": "node --env-file=.env dist/server/main.js", "reset": "node --env-file=.env scripts/reset.ts", - "build": "vite build && tsc -p tsconfig.server.json", + "build": "vite build && tsc -b tsconfig.server.json --force", "typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.scripts.json && tsc --noEmit -p tsconfig.web.json", "format": "biome check --write .", "format:check": "biome format .", @@ -40,7 +40,7 @@ "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", "typescript": "6.0.3", - "vite": "8.2.2", + "vite": "8.2.4", "vitest": "4.1.11" }, "pnpm": { diff --git a/p6-field-inspection/playwright.config.ts b/p6-field-inspection/playwright.config.ts index 4baa0df..14a37e7 100644 --- a/p6-field-inspection/playwright.config.ts +++ b/p6-field-inspection/playwright.config.ts @@ -3,9 +3,12 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", testMatch: "**/*.e2e.ts", + globalSetup: "../shared/browser-test-stack.mjs", timeout: 45_000, expect: { timeout: 8_000 }, fullyParallel: false, + // Browser scenarios share a disposable fixture database. + workers: 1, retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? "github" : "line", use: { @@ -13,11 +16,4 @@ export default defineConfig({ browserName: "chromium", trace: "retain-on-failure", }, - webServer: { - command: "pnpm dev -- --reset", - gracefulShutdown: { signal: "SIGINT", timeout: 10_000 }, - url: "http://127.0.0.1:17006/healthz", - reuseExistingServer: false, - timeout: 120_000, - }, }); diff --git a/p6-field-inspection/pnpm-lock.yaml b/p6-field-inspection/pnpm-lock.yaml index 03509ed..0b6a2b9 100644 --- a/p6-field-inspection/pnpm-lock.yaml +++ b/p6-field-inspection/pnpm-lock.yaml @@ -53,16 +53,16 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@24.19.0)) + version: 6.1.1(vite@8.2.4(@types/node@24.19.0)) typescript: specifier: 6.0.3 version: 6.0.3 vite: - specifier: 8.2.2 - version: 8.2.2(@types/node@24.19.0) + specifier: 8.2.4 + version: 8.2.4(@types/node@24.19.0) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@24.19.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.19.0)) + version: 4.1.11(@types/node@24.19.0)(jsdom@30.0.1)(vite@8.2.4(@types/node@24.19.0)) packages: @@ -901,8 +901,8 @@ packages: resolution: {integrity: sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==} engines: {node: '>=22.19.0'} - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.2.4: + resolution: {integrity: sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1248,10 +1248,10 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.19.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.4(@types/node@24.19.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@24.19.0) + vite: 8.2.4(@types/node@24.19.0) '@vitest/expect@4.1.11': dependencies: @@ -1262,13 +1262,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.19.0))': + '@vitest/mocker@4.1.11(vite@8.2.4(@types/node@24.19.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.19.0) + vite: 8.2.4(@types/node@24.19.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -1770,7 +1770,7 @@ snapshots: undici@8.11.0: optional: true - vite@8.2.2(@types/node@24.19.0): + vite@8.2.4(@types/node@24.19.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1781,10 +1781,10 @@ snapshots: '@types/node': 24.19.0 fsevents: 2.3.3 - vitest@4.1.11(@types/node@24.19.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.19.0)): + vitest@4.1.11(@types/node@24.19.0)(jsdom@30.0.1)(vite@8.2.4(@types/node@24.19.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.19.0)) + '@vitest/mocker': 4.1.11(vite@8.2.4(@types/node@24.19.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1801,7 +1801,7 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.19.0) + vite: 8.2.4(@types/node@24.19.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.0 diff --git a/p6-field-inspection/scripts/dev.mjs b/p6-field-inspection/scripts/dev.mjs index 1e9e789..90a04a1 100644 --- a/p6-field-inspection/scripts/dev.mjs +++ b/p6-field-inspection/scripts/dev.mjs @@ -23,17 +23,12 @@ try { args: ["run", "build"], cwd: identityRoot, }, - { name: "P6 setup", command: pnpm, args: ["run", "setup"], cwd: root }, - ...(resetFixtures - ? [ - { - name: "P6 fixture reset", - command: pnpm, - args: ["run", "reset"], - cwd: root, - }, - ] - : []), + { + name: "P6 setup", + command: pnpm, + args: ["run", "setup", ...(resetFixtures ? ["--reset"] : [])], + cwd: root, + }, { name: "P6 build", command: pnpm, args: ["run", "build"], cwd: root }, ], services, @@ -60,6 +55,12 @@ async function services() { validate: issuerHealth(issuer), }, }, + { + name: "P6 web build watcher", + command: pnpm, + args: ["exec", "vite", "build", "--watch", "--emptyOutDir=false"], + cwd: root, + }, { name: "P6 application", command: node, diff --git a/p6-field-inspection/scripts/reset.ts b/p6-field-inspection/scripts/reset.ts index 37ce5f1..07f7f51 100644 --- a/p6-field-inspection/scripts/reset.ts +++ b/p6-field-inspection/scripts/reset.ts @@ -7,7 +7,7 @@ prepareDataDirectory(config.dataDirectory); const database = new AppDatabase( resolve(config.dataDirectory, "inspection.sqlite"), config.issuer, + { reset: true }, ); -database.reset(config.issuer); database.close(); console.info("P6 field-inspection fixtures restored"); diff --git a/p6-field-inspection/scripts/setup.ts b/p6-field-inspection/scripts/setup.ts index 100dca9..6051059 100644 --- a/p6-field-inspection/scripts/setup.ts +++ b/p6-field-inspection/scripts/setup.ts @@ -26,6 +26,8 @@ const current = readProjectEnvironment(target); const merged = mergeProjectEnvironment(current.text, { managed: { P6_BASE_URL: baseUrl, + P6_PORT: + new URL(baseUrl).port || (baseUrl.startsWith("https:") ? "443" : "80"), P6_ISSUER: url("IDP_ISSUER"), P6_API_RESOURCE: url("P6_API_RESOURCE"), P6_CLIENT_ID: required("P6_CLIENT_ID"), @@ -33,7 +35,6 @@ const merged = mergeProjectEnvironment(current.text, { }, defaults: { P6_HOST: "127.0.0.1", - P6_PORT: new URL(baseUrl).port || "80", P6_DATA_DIR: ".local/p6-data", }, }); @@ -42,6 +43,7 @@ prepareDataDirectory(config.dataDirectory); const database = new AppDatabase( resolve(config.dataDirectory, "inspection.sqlite"), config.issuer, + { reset: process.argv.includes("--reset") }, ); database.close(); writePrivateEnvironment(target, merged.text); diff --git a/p6-field-inspection/src/server/app.ts b/p6-field-inspection/src/server/app.ts index b1ba91e..ad5daf4 100644 --- a/p6-field-inspection/src/server/app.ts +++ b/p6-field-inspection/src/server/app.ts @@ -2,11 +2,18 @@ import { resolve } from "node:path"; import cookie from "@fastify/cookie"; import helmet from "@fastify/helmet"; import staticPlugin from "@fastify/static"; -import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify"; -import { capabilities } from "../shared/capabilities.ts"; +import Fastify, { + type FastifyInstance, + type FastifyReply, + type FastifyRequest, +} from "fastify"; +import { type Capability, capabilities } from "../shared/capabilities.ts"; import { checklistKeys } from "../shared/catalog.ts"; +import { validNotes } from "../shared/notes.ts"; import type { AuthorizationEnvelope, + Inspection, + InspectionReview, InspectionSubmission, Reassignment, Session, @@ -30,12 +37,34 @@ type Dependencies = Readonly<{ oidc: OidcRuntime; authorization: AuthorizationPort; serveWeb?: boolean; + webRoot?: string; }>; export async function createApp( dependencies: Dependencies, ): Promise { const app = Fastify({ logger: false, bodyLimit: 16 * 1024 }); + app.addHook("onRequest", async (request, reply) => { + reply.header("x-request-id", request.id); + if (/^\/(api|auth)(?:\/|\?|$)/u.test(request.url)) { + reply.header("cache-control", "private, no-store"); + } + }); + const decisionHeaders = ( + reply: FastifyReply, + capability: Capability, + allowed: boolean, + ) => { + reply.header("x-authorization-capability", capability); + reply.header( + "x-authorization-decision", + allowed + ? dependencies.authorization.mode === "fake" + ? "FAKE ALLOW" + : "ALLOW" + : "DENY", + ); + }; await app.register(cookie); await app.register(helmet, { contentSecurityPolicy: { @@ -69,10 +98,14 @@ export async function createApp( domain?.code ?? (status < 500 ? "invalid_request" : "service_unavailable"); if (status >= 500) { - console.error("P6 request failed", { - requestId: request.id, - error: error instanceof Error ? error.name : "UnknownError", - }); + console.error( + JSON.stringify({ + event: "request.failed", + application: "P6 server", + requestId: request.id, + error: error instanceof Error ? error.name : "UnknownError", + }), + ); } void reply.status(status).send({ error: code, requestId: request.id }); }); @@ -143,30 +176,46 @@ export async function createApp( }; }); - app.get("/api/work-orders", async (request) => { - const session = requireSession(request, dependencies.database); - const createAllowed = await dependencies.authorization.authorize({ - requestId: request.id, - capability: capabilities.create, - principalId: session.user.id, - resourceId: "work-orders", - }); - const candidates = dependencies.database.listWorkOrders(20); - const workOrders: WorkOrder[] = []; - for (const candidate of candidates) { + app.get<{ Querystring: { offset?: string } }>( + "/api/work-orders", + async (request) => { + const session = requireSession(request, dependencies.database); + const rawOffset = request.query.offset ?? "0"; + const offset = Number(rawOffset); if ( - await dependencies.authorization.authorize({ - requestId: request.id, - capability: capabilities.read, - principalId: session.user.id, - resourceId: candidate.id, - }) - ) { - workOrders.push(candidate); + typeof rawOffset !== "string" || + !/^(0|[1-9]\d*)$/u.test(rawOffset) || + !Number.isSafeInteger(offset) || + offset > Number.MAX_SAFE_INTEGER - 20 + ) + throw new DomainError("invalid_offset", 400); + const createAllowed = await dependencies.authorization.authorize({ + requestId: request.id, + capability: capabilities.create, + principalId: session.user.id, + resourceId: "work-orders", + }); + const candidates = dependencies.database.listWorkOrders(20, offset); + const workOrders: WorkOrder[] = []; + for (const candidate of candidates) { + if ( + await dependencies.authorization.authorize({ + requestId: request.id, + capability: capabilities.read, + principalId: session.user.id, + resourceId: candidate.id, + }) + ) { + workOrders.push(candidate); + } } - } - return { workOrders, createAllowed }; - }); + return { + workOrders, + createAllowed, + nextOffset: candidates.length === 20 ? offset + 20 : null, + }; + }, + ); app.post("/api/work-orders", async (request, reply) => { const session = requireMutationSession(request, dependencies); @@ -177,6 +226,7 @@ export async function createApp( principalId: session.user.id, resourceId: "work-orders", }); + decisionHeaders(reply, capabilities.create, allowed); if (!allowed) throw new DomainError("forbidden", 403); return reply.status(201).send({ workOrder: dependencies.database.createWorkOrder(input), @@ -205,13 +255,15 @@ export async function createApp( workOrder, dependencies.authorization, ), + dependencies.database.inspections(id), + dependencies.database.assignmentHistory(id), ); }, ); app.post<{ Params: { id: string } }>( "/api/work-orders/:id/inspections", - async (request) => { + async (request, reply) => { const session = requireMutationSession(request, dependencies); const id = identifier(request.params.id); const input = inspectionSubmission(request.body); @@ -223,6 +275,7 @@ export async function createApp( principalId: session.user.id, resourceId: workOrder.id, }); + decisionHeaders(reply, capabilities.submit, allowed); if (!allowed) throw new DomainError("forbidden", 403); return dependencies.database.submit({ principalId: session.user.id, @@ -247,15 +300,40 @@ export async function createApp( principalId: session.user.id, resourceId: workOrder.id, }); + decisionHeaders(reply, capabilities.delete, allowed); if (!allowed) throw new DomainError("forbidden", 403); dependencies.database.deleteWorkOrder({ workOrderId: id, ...input }); return reply.status(204).send(); }, ); + app.post<{ Params: { id: string } }>( + "/api/work-orders/:id/review", + async (request, reply) => { + const session = requireMutationSession(request, dependencies); + const id = identifier(request.params.id); + const input = inspectionReview(request.body); + if (!dependencies.database.findWorkOrder(id)) + throw new DomainError("not_found", 404); + const allowed = await dependencies.authorization.authorize({ + requestId: request.id, + capability: capabilities.review, + principalId: session.user.id, + resourceId: id, + }); + decisionHeaders(reply, capabilities.review, allowed); + if (!allowed) throw new DomainError("forbidden", 403); + return dependencies.database.review({ + ...input, + principalId: session.user.id, + workOrderId: id, + }); + }, + ); + app.post<{ Params: { id: string } }>( "/api/work-orders/:id/reassignment", - async (request) => { + async (request, reply) => { const session = requireMutationSession(request, dependencies); const id = identifier(request.params.id); const input = reassignment(request.body); @@ -267,9 +345,11 @@ export async function createApp( principalId: session.user.id, resourceId: workOrder.id, }); + decisionHeaders(reply, capabilities.reassign, allowed); if (!allowed) throw new DomainError("forbidden", 403); return { workOrder: dependencies.database.reassign({ + principalId: session.user.id, workOrderId: id, ...input, }), @@ -279,8 +359,10 @@ export async function createApp( if (dependencies.serveWeb !== false) { await app.register(staticPlugin, { - root: resolve("dist/web"), - wildcard: false, + root: resolve(dependencies.webRoot ?? "dist/web"), + // Rebuilds create new hashed asset names after the server has started. + wildcard: true, + dotfiles: "ignore", }); } app.setNotFoundHandler((request, reply) => @@ -336,6 +418,7 @@ async function envelope( const relevant = [ capabilities.read, capabilities.submit, + capabilities.review, capabilities.reassign, capabilities.delete, ].map((capability) => ({ @@ -349,9 +432,13 @@ async function envelope( ceiling: { [capabilities.read]: decisions[capabilities.read] === true, [capabilities.submit]: - workOrder.status === "open" && decisions[capabilities.submit] === true, + ["open", "changes_requested"].includes(workOrder.status) && + decisions[capabilities.submit] === true, + [capabilities.review]: + workOrder.status === "awaiting_review" && + decisions[capabilities.review] === true, [capabilities.reassign]: - workOrder.status === "open" && + ["open", "changes_requested"].includes(workOrder.status) && decisions[capabilities.reassign] === true, [capabilities.delete]: workOrder.status === "open" && decisions[capabilities.delete] === true, @@ -363,10 +450,14 @@ async function envelope( function detail( workOrder: WorkOrder, authorizationEnvelope: AuthorizationEnvelope, + inspections: readonly Inspection[], + assignmentHistory: WorkOrderDetail["assignmentHistory"], ): WorkOrderDetail { return { ...workOrder, envelope: authorizationEnvelope, + inspections, + assignmentHistory, }; } @@ -416,12 +507,14 @@ function inspectionSubmission(value: unknown): InspectionSubmission { if (checklistKeys.some((key) => typeof checklist[key] !== "boolean")) { throw new DomainError("invalid_checklist", 400); } - if ( - typeof body.notes !== "string" || - new TextEncoder().encode(body.notes).byteLength > 1_000 - ) { + if (!validNotes(body.notes)) { throw new DomainError("invalid_notes", 400); } + if ( + checklistKeys.some((key) => checklist[key] === false) && + !body.notes.trim() + ) + throw new DomainError("failure_notes_required", 400); if ( typeof body.idempotencyKey !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test( @@ -434,15 +527,50 @@ function inspectionSubmission(value: unknown): InspectionSubmission { idempotencyKey: body.idempotencyKey, expectedWorkOrderVersion: positiveInteger(body.expectedWorkOrderVersion), checklist: { - safetyGuardSecured: checklist.safetyGuardSecured as boolean, - fluidLevelChecked: checklist.fluidLevelChecked as boolean, - operatingTemperatureRecorded: - checklist.operatingTemperatureRecorded as boolean, + equipmentConditionPassed: checklist.equipmentConditionPassed as boolean, + safetyChecksPassed: checklist.safetyChecksPassed as boolean, + functionalCheckPassed: checklist.functionalCheckPassed as boolean, }, notes: body.notes, }; } +function inspectionReview(value: unknown): InspectionReview { + const body = record(value); + exactKeys(body, [ + "inspectionId", + "expectedWorkOrderVersion", + "idempotencyKey", + "outcome", + "notes", + ]); + if ( + typeof body.inspectionId !== "string" || + !/^inspection_[A-Za-z0-9_-]{1,64}$/u.test(body.inspectionId) || + !["complete", "request_corrections"].includes(String(body.outcome)) + ) + throw new DomainError("invalid_review", 400); + if ( + !validNotes(body.notes) || + (body.outcome === "request_corrections" && !body.notes.trim()) + ) + throw new DomainError("invalid_review_notes", 400); + if ( + typeof body.idempotencyKey !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test( + body.idempotencyKey, + ) + ) + throw new DomainError("invalid_idempotency_key", 400); + return { + inspectionId: body.inspectionId, + expectedWorkOrderVersion: positiveInteger(body.expectedWorkOrderVersion), + idempotencyKey: body.idempotencyKey, + outcome: body.outcome as InspectionReview["outcome"], + notes: body.notes, + }; +} + function reassignment(value: unknown): Reassignment { const body = record(value); exactKeys(body, ["expectedAssignmentEpoch", "technicianId"]); diff --git a/p6-field-inspection/src/server/authorization.ts b/p6-field-inspection/src/server/authorization.ts index 84a68c9..e3f69da 100644 --- a/p6-field-inspection/src/server/authorization.ts +++ b/p6-field-inspection/src/server/authorization.ts @@ -8,6 +8,7 @@ export type AuthorizationRequest = Readonly<{ }>; export interface AuthorizationPort { + readonly mode?: "fake"; authorize(request: AuthorizationRequest): Promise; authorizeMany( requests: readonly AuthorizationRequest[], @@ -16,6 +17,7 @@ export interface AuthorizationPort { export function fakeAuthorization(): AuthorizationPort { return { + mode: "fake", async authorize(request) { console.info( `P6 server | FAKE ALLOW | ${request.capability} | ${request.principalId} -> ${request.resourceId} | ${request.requestId}`, @@ -23,14 +25,11 @@ export function fakeAuthorization(): AuthorizationPort { return true; }, async authorizeMany(requests) { - const first = requests[0]; - if (!first) return {} as Readonly>; - console.info( - `P6 server | FAKE ALLOW | ${requests.map((item) => item.capability).join(",")} | ${first.principalId} -> ${first.resourceId} | ${first.requestId}`, - ); - return Object.fromEntries( - requests.map((request) => [request.capability, true]), - ) as Readonly>; + const decisions = {} as Record; + for (const request of requests) { + decisions[request.capability] = await this.authorize(request); + } + return decisions; }, }; } diff --git a/p6-field-inspection/src/server/database.ts b/p6-field-inspection/src/server/database.ts index 5bf2d9b..14550d3 100644 --- a/p6-field-inspection/src/server/database.ts +++ b/p6-field-inspection/src/server/database.ts @@ -1,9 +1,12 @@ import { randomUUID } from "node:crypto"; import Sqlite from "better-sqlite3"; -import { tutorialUsers } from "../shared/catalog.ts"; +import { checklistKeys, tutorialUsers } from "../shared/catalog.ts"; +import { validNotes } from "../shared/notes.ts"; import type { + AssignmentChange, Checklist, Inspection, + InspectionReview, Role, User, WorkOrder, @@ -59,7 +62,7 @@ CREATE TABLE IF NOT EXISTS work_orders ( id TEXT PRIMARY KEY, equipment TEXT NOT NULL, site TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('open', 'completed')), + status TEXT NOT NULL CHECK (status IN ('open', 'awaiting_review', 'changes_requested', 'completed')), work_order_version INTEGER NOT NULL CHECK (work_order_version > 0) ); CREATE TABLE IF NOT EXISTS assignments ( @@ -69,18 +72,34 @@ CREATE TABLE IF NOT EXISTS assignments ( ); CREATE TABLE IF NOT EXISTS inspections ( id TEXT PRIMARY KEY, - work_order_id TEXT NOT NULL UNIQUE REFERENCES work_orders(id), + work_order_id TEXT NOT NULL REFERENCES work_orders(id), submitted_by TEXT NOT NULL REFERENCES users(id), checklist_json TEXT NOT NULL, notes TEXT NOT NULL, created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS assignment_changes ( + id INTEGER PRIMARY KEY, + work_order_id TEXT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + previous_assignee TEXT NOT NULL REFERENCES users(id), + new_assignee TEXT NOT NULL REFERENCES users(id), + changed_by TEXT NOT NULL REFERENCES users(id), + changed_at TEXT NOT NULL, + assignment_epoch INTEGER NOT NULL +); CREATE TABLE IF NOT EXISTS idempotency_results ( idempotency_key TEXT PRIMARY KEY, principal_id TEXT NOT NULL REFERENCES users(id), request_hash TEXT NOT NULL, result_json TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS inspection_reviews ( + inspection_id TEXT PRIMARY KEY REFERENCES inspections(id), + reviewed_by TEXT NOT NULL REFERENCES users(id), + outcome TEXT NOT NULL CHECK (outcome IN ('complete', 'request_corrections')), + notes TEXT NOT NULL, + created_at TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS oidc_transactions ( id_hash TEXT PRIMARY KEY, state TEXT NOT NULL, @@ -99,11 +118,76 @@ CREATE TABLE IF NOT EXISTS sessions ( export class AppDatabase { private readonly raw: Sqlite.Database; - constructor(path: string, issuer: string) { + constructor(path: string, issuer: string, options: { reset?: boolean } = {}) { this.raw = new Sqlite(path); - this.raw.pragma("journal_mode = WAL"); - this.raw.exec(schema); - this.seed(issuer); + try { + if (!options.reset) this.requireCurrentChecklist(); + this.raw.pragma("journal_mode = WAL"); + this.raw.exec(schema); + this.migrateInspectionWorkflow(); + if (options.reset) this.reset(issuer); + else this.seed(issuer); + } catch (error) { + this.raw.close(); + throw error; + } + } + + private requireCurrentChecklist(): void { + const table = this.raw + .prepare("SELECT sql FROM sqlite_master WHERE name = 'inspections'") + .get(); + if (!table) return; + const records = this.raw + .prepare("SELECT checklist_json FROM inspections") + .iterate() as Iterable<{ checklist_json: string }>; + for (const row of records) { + const value = JSON.parse(row.checklist_json) as Record; + if ( + Object.keys(value).length !== checklistKeys.length || + checklistKeys.some((key) => typeof value[key] !== "boolean") + ) { + throw new Error( + "P6 saved inspections use a different checklist. Stop P6 and run pnpm reset to replace its practice data. See README.md for Docker reset instructions.", + ); + } + } + } + + private migrateInspectionWorkflow(): void { + const table = this.raw + .prepare("SELECT sql FROM sqlite_master WHERE name = 'work_orders'") + .get() as { sql: string }; + if (table.sql.includes("'awaiting_review'")) return; + // Rebuild the two legacy tables together. All IDs, completed records, and + // idempotency results survive; no supervisor review is invented. + this.raw.pragma("foreign_keys = OFF"); + try { + this.raw.transaction(() => { + this.raw.exec(` + CREATE TABLE work_orders_v2 ( + id TEXT PRIMARY KEY, equipment TEXT NOT NULL, site TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open', 'awaiting_review', 'changes_requested', 'completed')), + work_order_version INTEGER NOT NULL CHECK (work_order_version > 0) + ); + INSERT INTO work_orders_v2 SELECT * FROM work_orders; + CREATE TABLE inspections_v2 ( + id TEXT PRIMARY KEY, work_order_id TEXT NOT NULL REFERENCES work_orders(id), + submitted_by TEXT NOT NULL REFERENCES users(id), checklist_json TEXT NOT NULL, + notes TEXT NOT NULL, created_at TEXT NOT NULL + ); + INSERT INTO inspections_v2 SELECT * FROM inspections; + DROP TABLE inspections; + DROP TABLE work_orders; + ALTER TABLE work_orders_v2 RENAME TO work_orders; + ALTER TABLE inspections_v2 RENAME TO inspections; + `); + if ((this.raw.pragma("foreign_key_check") as unknown[]).length) + throw new Error("Inspection migration failed integrity checks"); + })(); + } finally { + this.raw.pragma("foreign_keys = ON"); + } } close(): void { @@ -116,7 +200,9 @@ export class AppDatabase { DELETE FROM sessions; DELETE FROM oidc_transactions; DELETE FROM idempotency_results; + DELETE FROM inspection_reviews; DELETE FROM inspections; + DELETE FROM assignment_changes; DELETE FROM assignments; DELETE FROM work_orders; DELETE FROM users; @@ -299,7 +385,7 @@ export class AppDatabase { .run(tokenHash(id)); } - listWorkOrders(limit = 20): readonly WorkOrder[] { + listWorkOrders(limit = 20, offset = 0): readonly WorkOrder[] { const rows = this.raw .prepare( `SELECT w.id, w.equipment, w.site, w.status, w.work_order_version, @@ -307,9 +393,9 @@ export class AppDatabase { FROM work_orders w JOIN assignments a ON a.work_order_id = w.id JOIN users u ON u.id = a.technician_id - ORDER BY w.id LIMIT ?`, + ORDER BY w.id LIMIT ? OFFSET ?`, ) - .all(Math.min(limit, 20)) as WorkOrderRow[]; + .all(Math.min(limit, 20), offset) as WorkOrderRow[]; return rows.map(mapWorkOrder); } @@ -378,6 +464,7 @@ export class AppDatabase { } reassign(values: { + principalId: string; workOrderId: string; technicianId: string; expectedAssignmentEpoch: number; @@ -387,11 +474,19 @@ export class AppDatabase { if (technician?.role !== "technician") { throw new DomainError("invalid_technician", 400); } + const before = this.findWorkOrder(values.workOrderId); + if ( + !before || + before.assignmentEpoch !== values.expectedAssignmentEpoch || + !["open", "changes_requested"].includes(before.status) + ) + throw new DomainError("state_conflict", 409); + if (before.assigneeId === values.technicianId) return before; const result = this.raw .prepare( `UPDATE assignments SET technician_id = ?, assignment_epoch = assignment_epoch + 1 WHERE work_order_id = ? AND assignment_epoch = ? - AND EXISTS (SELECT 1 FROM work_orders WHERE id = ? AND status = 'open')`, + AND EXISTS (SELECT 1 FROM work_orders WHERE id = ? AND status IN ('open', 'changes_requested'))`, ) .run( values.technicianId, @@ -400,10 +495,34 @@ export class AppDatabase { values.workOrderId, ); if (result.changes !== 1) throw new DomainError("state_conflict", 409); + this.raw + .prepare(`INSERT INTO assignment_changes + (work_order_id, previous_assignee, new_assignee, changed_by, changed_at, assignment_epoch) + VALUES (?, ?, ?, ?, ?, ?)`) + .run( + values.workOrderId, + before.assigneeId, + values.technicianId, + values.principalId, + new Date().toISOString(), + before.assignmentEpoch + 1, + ); return this.findWorkOrder(values.workOrderId) as WorkOrder; })(); } + assignmentHistory(workOrderId: string): readonly AssignmentChange[] { + return this.raw + .prepare(`SELECT previous.name AS previousAssignee, next.name AS newAssignee, + actor.name AS changedBy, c.changed_at AS changedAt, c.assignment_epoch AS assignmentEpoch + FROM assignment_changes c + JOIN users previous ON previous.id = c.previous_assignee + JOIN users next ON next.id = c.new_assignee + JOIN users actor ON actor.id = c.changed_by + WHERE c.work_order_id = ? ORDER BY c.id DESC LIMIT 20`) + .all(workOrderId) as AssignmentChange[]; + } + submit(values: { principalId: string; workOrderId: string; @@ -413,6 +532,19 @@ export class AppDatabase { checklist: Checklist; notes: string; }): { inspection: Inspection; workOrder: WorkOrder; replayed: boolean } { + if ( + !values.checklist || + Object.keys(values.checklist).length !== checklistKeys.length || + checklistKeys.some((key) => typeof values.checklist[key] !== "boolean") + ) { + throw new DomainError("invalid_checklist", 400); + } + if (!validNotes(values.notes)) throw new DomainError("invalid_notes", 400); + if ( + checklistKeys.some((key) => !values.checklist[key]) && + !values.notes.trim() + ) + throw new DomainError("failure_notes_required", 400); const normalized = { workOrderId: values.workOrderId, expectedWorkOrderVersion: values.expectedWorkOrderVersion, @@ -443,7 +575,8 @@ export class AppDatabase { } const current = this.findWorkOrder(values.workOrderId); if ( - current?.status !== "open" || + !current || + !["open", "changes_requested"].includes(current.status) || current.workOrderVersion !== values.expectedWorkOrderVersion || current.assignmentEpoch !== values.capturedAssignmentEpoch ) { @@ -451,8 +584,8 @@ export class AppDatabase { } const update = this.raw .prepare( - `UPDATE work_orders SET status = 'completed', work_order_version = work_order_version + 1 - WHERE id = ? AND status = 'open' AND work_order_version = ? + `UPDATE work_orders SET status = 'awaiting_review', work_order_version = work_order_version + 1 + WHERE id = ? AND status IN ('open', 'changes_requested') AND work_order_version = ? AND EXISTS ( SELECT 1 FROM assignments WHERE work_order_id = ? AND assignment_epoch = ? @@ -512,6 +645,130 @@ export class AppDatabase { } ).count; } + + inspections(workOrderId: string): readonly Inspection[] { + const rows = this.raw + .prepare(`SELECT i.*, r.reviewed_by, r.outcome, r.notes AS review_notes, r.created_at AS reviewed_at + FROM inspections i LEFT JOIN inspection_reviews r ON r.inspection_id = i.id + WHERE i.work_order_id = ? ORDER BY i.rowid DESC LIMIT 20`) + .all(workOrderId) as { + id: string; + work_order_id: string; + submitted_by: string; + checklist_json: string; + notes: string; + created_at: string; + reviewed_by: string | null; + outcome: InspectionReview["outcome"]; + review_notes: string; + reviewed_at: string; + }[]; + return rows.map((row) => ({ + id: row.id, + workOrderId: row.work_order_id, + submittedBy: row.submitted_by, + checklist: JSON.parse(row.checklist_json) as Checklist, + notes: row.notes, + createdAt: row.created_at, + ...(row.reviewed_by + ? { + review: { + reviewedBy: row.reviewed_by, + outcome: row.outcome, + notes: row.review_notes, + createdAt: row.reviewed_at, + }, + } + : {}), + })); + } + + review( + values: InspectionReview & { principalId: string; workOrderId: string }, + ): { workOrder: WorkOrder; replayed: boolean } { + if (!["complete", "request_corrections"].includes(values.outcome)) + throw new DomainError("invalid_review", 400); + if ( + !validNotes(values.notes) || + (values.outcome === "request_corrections" && !values.notes.trim()) + ) + throw new DomainError("invalid_review_notes", 400); + const requestHash = contentHash({ + action: "review", + workOrderId: values.workOrderId, + inspectionId: values.inspectionId, + expectedWorkOrderVersion: values.expectedWorkOrderVersion, + outcome: values.outcome, + notes: values.notes, + }); + return this.raw.transaction(() => { + const prior = this.raw + .prepare( + "SELECT principal_id, request_hash, result_json FROM idempotency_results WHERE idempotency_key = ?", + ) + .get(values.idempotencyKey) as + | { principal_id: string; request_hash: string; result_json: string } + | undefined; + if (prior) { + if ( + prior.principal_id !== values.principalId || + prior.request_hash !== requestHash + ) + throw new DomainError("idempotency_conflict", 409); + return { ...JSON.parse(prior.result_json), replayed: true } as { + workOrder: WorkOrder; + replayed: boolean; + }; + } + const current = this.findWorkOrder(values.workOrderId); + const latest = this.inspections(values.workOrderId)[0]; + if ( + current?.status !== "awaiting_review" || + current.workOrderVersion !== values.expectedWorkOrderVersion || + latest?.id !== values.inspectionId || + latest.review + ) + throw new DomainError("state_conflict", 409); + if ( + values.outcome === "complete" && + checklistKeys.some((key) => !latest.checklist[key]) + ) + throw new DomainError("corrections_required", 409); + this.raw + .prepare( + "INSERT INTO inspection_reviews (inspection_id, reviewed_by, outcome, notes, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .run( + values.inspectionId, + values.principalId, + values.outcome, + values.notes, + new Date().toISOString(), + ); + this.raw + .prepare( + "UPDATE work_orders SET status = ?, work_order_version = work_order_version + 1 WHERE id = ?", + ) + .run( + values.outcome === "complete" ? "completed" : "changes_requested", + values.workOrderId, + ); + const stored = { + workOrder: this.findWorkOrder(values.workOrderId) as WorkOrder, + }; + this.raw + .prepare( + "INSERT INTO idempotency_results (idempotency_key, principal_id, request_hash, result_json) VALUES (?, ?, ?, ?)", + ) + .run( + values.idempotencyKey, + values.principalId, + requestHash, + JSON.stringify(stored), + ); + return { ...stored, replayed: false }; + })(); + } } function mapWorkOrder(row: WorkOrderRow): WorkOrder { diff --git a/p6-field-inspection/src/shared/capabilities.ts b/p6-field-inspection/src/shared/capabilities.ts index 48416ae..9bf38be 100644 --- a/p6-field-inspection/src/shared/capabilities.ts +++ b/p6-field-inspection/src/shared/capabilities.ts @@ -3,6 +3,7 @@ export const capabilities = { create: "workorder.create", delete: "workorder.delete", submit: "inspection.submit", + review: "inspection.review", reassign: "workorder.reassign", } as const; diff --git a/p6-field-inspection/src/shared/catalog.ts b/p6-field-inspection/src/shared/catalog.ts index 0b221d2..93b8305 100644 --- a/p6-field-inspection/src/shared/catalog.ts +++ b/p6-field-inspection/src/shared/catalog.ts @@ -26,11 +26,20 @@ export const technicians = tutorialUsers.filter( ); export const checklistItems = [ - { key: "safetyGuardSecured", label: "Safety guard secured" }, - { key: "fluidLevelChecked", label: "Fluid level checked" }, { - key: "operatingTemperatureRecorded", - label: "Operating temperature recorded", + key: "equipmentConditionPassed", + label: "Equipment condition", + help: "Check the equipment's visible condition and record any damage.", + }, + { + key: "safetyChecksPassed", + label: "Safety checks", + help: "Record the result of the safety checks required for this work order.", + }, + { + key: "functionalCheckPassed", + label: "Functional check", + help: "Record whether the equipment passed its permitted functional check.", }, ] as const; diff --git a/p6-field-inspection/src/shared/notes.ts b/p6-field-inspection/src/shared/notes.ts new file mode 100644 index 0000000..75475b6 --- /dev/null +++ b/p6-field-inspection/src/shared/notes.ts @@ -0,0 +1,10 @@ +export const MAX_NOTE_BYTES = 1_000; + +export const noteBytes = (notes: string): number => + new TextEncoder().encode(notes).byteLength; + +export const validNotes = (notes: unknown): notes is string => + typeof notes === "string" && noteBytes(notes) <= MAX_NOTE_BYTES; + +export const noteLimitMessage = + "Notes must fit within 1,000 UTF-8 bytes. Shorten the text before saving or sending it."; diff --git a/p6-field-inspection/src/shared/types.ts b/p6-field-inspection/src/shared/types.ts index 13cc451..ee3dc5f 100644 --- a/p6-field-inspection/src/shared/types.ts +++ b/p6-field-inspection/src/shared/types.ts @@ -1,7 +1,11 @@ import type { Capability } from "./capabilities.ts"; export type Role = "technician" | "supervisor"; -export type WorkOrderStatus = "open" | "completed"; +type WorkOrderStatus = + | "open" + | "awaiting_review" + | "changes_requested" + | "completed"; export type User = Readonly<{ id: string; @@ -10,9 +14,9 @@ export type User = Readonly<{ }>; export type Checklist = Readonly<{ - safetyGuardSecured: boolean; - fluidLevelChecked: boolean; - operatingTemperatureRecorded: boolean; + equipmentConditionPassed: boolean; + safetyChecksPassed: boolean; + functionalCheckPassed: boolean; }>; export type WorkOrder = Readonly<{ @@ -34,8 +38,18 @@ export type AuthorizationEnvelope = Readonly<{ export type WorkOrderDetail = WorkOrder & Readonly<{ envelope: AuthorizationEnvelope; + inspections: readonly Inspection[]; + assignmentHistory: readonly AssignmentChange[]; }>; +export type AssignmentChange = Readonly<{ + previousAssignee: string; + newAssignee: string; + changedBy: string; + changedAt: string; + assignmentEpoch: number; +}>; + export type Inspection = Readonly<{ id: string; workOrderId: string; @@ -43,6 +57,20 @@ export type Inspection = Readonly<{ checklist: Checklist; notes: string; createdAt: string; + review?: Readonly<{ + reviewedBy: string; + outcome: InspectionReview["outcome"]; + notes: string; + createdAt: string; + }>; +}>; + +export type InspectionReview = Readonly<{ + inspectionId: string; + expectedWorkOrderVersion: number; + idempotencyKey: string; + outcome: "complete" | "request_corrections"; + notes: string; }>; export type Session = Readonly<{ diff --git a/p6-field-inspection/src/web/App.tsx b/p6-field-inspection/src/web/App.tsx index b0ce51b..f50f085 100644 --- a/p6-field-inspection/src/web/App.tsx +++ b/p6-field-inspection/src/web/App.tsx @@ -1,21 +1,38 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { capabilities } from "../shared/capabilities.ts"; +import { type Capability, capabilities } from "../shared/capabilities.ts"; import { checklistItems, technicians, tutorialUsers, } from "../shared/catalog.ts"; +import { noteBytes, noteLimitMessage, validNotes } from "../shared/notes.ts"; import type { - Checklist, + InspectionReview, Session, WorkOrder, WorkOrderDetail, } from "../shared/types.ts"; -import { ApiError, api } from "./api.ts"; +import { ApiError, api, type RequestEvidence } from "./api.ts"; import cedarlingMark from "./assets/cedarling-mark.png"; import cedarlingWordmark from "./assets/cedarling-wordmark-dark.webp"; import { authorizePresentation } from "./authorization.ts"; -import { drafts, type InspectionDraft } from "./drafts.ts"; +import { ChecklistSummary } from "./ChecklistSummary.tsx"; +import { CreationDialog } from "./CreationDialog.tsx"; +import { type DetailTab, DetailTabs } from "./DetailTabs.tsx"; +import { + answered, + DraftStorageError, + drafts, + emptyChecklist, + IncompatibleDraftError, + type InspectionDraft, +} from "./drafts.ts"; +import { InspectionHistory } from "./InspectionHistory.tsx"; +import { SavedDrafts } from "./SavedDrafts.tsx"; +import { + type OperationDetails, + TechnicalDetails, +} from "./TechnicalDetails.tsx"; type Notice = { kind: "success" | "error"; text: string } | null; type SessionState = @@ -23,39 +40,66 @@ type SessionState = | { kind: "authenticated"; session: Session } | { kind: "unauthenticated"; expired: boolean } | { kind: "error" }; -const emptyChecklist: Checklist = { - safetyGuardSecured: false, - fluidLevelChecked: false, - operatingTemperatureRecorded: false, -}; +const editable = (status: WorkOrder["status"]) => + status === "open" || status === "changes_requested"; +const statusLabel = (status: WorkOrder["status"]) => + ({ + open: "Open", + awaiting_review: "Awaiting review", + changes_requested: "Changes requested", + completed: "Completed", + })[status]; function message(error: unknown): string { + if ( + error instanceof IncompatibleDraftError || + error instanceof DraftStorageError + ) + return error.message; if (error instanceof ApiError) { + if (error.code === "not_found") return "This work order is unavailable."; + if (error.code === "invalid_csrf" || error.code === "invalid_origin") + return "The request could not be verified. Reload the page and try again."; + if (error.code === "idempotency_conflict") + return "This submission changed after it was sent. Refresh the work order before submitting again."; if (error.code === "state_conflict") return "The work order changed. Review its current state."; if (error.code === "forbidden") - return "The current identity cannot perform this action."; + return "Your account cannot perform this action."; if (error.code === "authentication_required") return "Your session expired."; + if (error.code === "failure_notes_required") + return "Describe the failed checks in the inspection notes."; + if (error.code === "invalid_checklist") + return "Choose Pass or Fail for every check."; + if (error.code === "invalid_notes" || error.code === "invalid_review_notes") + return noteLimitMessage; + if (error.code === "corrections_required") + return "Failed checks must be corrected before this work order can be completed."; + if (error.status === 400) return "Check the entries and try again."; } - return error instanceof Error - ? error.message - : "The request could not be completed."; + return "The request could not be completed. Check your connection and try again."; } function BrandRail({ session, signOut, -}: Readonly<{ session?: Session; signOut?: () => void }>) { + busy = false, +}: Readonly<{ session?: Session; signOut?: () => void; busy?: boolean }>) { return (
- + Cedarling

P6 - Reauthorizing Offline Field Inspections with Cedarling

-

Recheck current assignment before every inspection effect.

+

+ Check current access when an offline inspection reaches the server. +

{session ? (
@@ -72,10 +116,10 @@ function BrandRail({
- -
@@ -91,13 +135,37 @@ function Footer() { return ( ); @@ -110,7 +178,6 @@ function Login({ expired = false }: Readonly<{ expired?: boolean }>) {

Choose a tutorial identity

-

Compare one field workflow across current assignments.

{expired && (

Session expired. Choose an identity again. @@ -171,8 +238,19 @@ function Workspace({ expired, }: Readonly<{ session: Session; expired: () => void }>) { const [workOrders, setWorkOrders] = useState([]); + const [nextOffset, setNextOffset] = useState(null); + const [listLoading, setListLoading] = useState(false); + const [listError, setListError] = useState(""); + const listGeneration = useRef(0); + const loadingMore = useRef(false); const [createAllowed, setCreateAllowed] = useState(false); const [showCreate, setShowCreate] = useState(false); + const [createError, setCreateError] = useState(""); + const creating = useRef(false); + const [activeTab, setActiveTab] = useState("inspection"); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [assigneeFilter, setAssigneeFilter] = useState(""); const [newOrder, setNewOrder] = useState({ equipment: "", site: "", @@ -183,83 +261,301 @@ function Workspace({ ); const [detail, setDetail] = useState(); const [draft, setDraft] = useState(); - const [orphanedDrafts, setOrphanedDrafts] = useState([]); + const [savedDrafts, setSavedDrafts] = useState([]); const [online, setOnline] = useState(navigator.onLine); const [busy, setBusy] = useState(false); + const [pendingAction, setPendingAction] = useState(""); + const [operation, setOperation] = useState(); + const refreshInFlight = useRef(false); + const [incompatibleDrafts, setIncompatibleDrafts] = useState< + { key: string; workOrderId: string; notes: string }[] + >([]); const [notice, setNotice] = useState(null); const [loadError, setLoadError] = useState(""); + const [storageReady, setStorageReady] = useState(false); + const [storageError, setStorageError] = useState(""); + const [retryingStorage, setRetryingStorage] = useState(false); + const storageFailed = useRef(false); const [target, setTarget] = useState("user-malik"); const [confirmDelete, setConfirmDelete] = useState(false); const [envelopeClock, setEnvelopeClock] = useState(() => Date.now()); const reconnectPending = useRef(false); + const submitting = useRef(false); + const detailGeneration = useRef(0); + const [mobileDetail, setMobileDetail] = useState(false); + const [dirty, setDirty] = useState(false); + const dirtyRef = useRef(dirty); + dirtyRef.current = dirty; + const [reviewNotes, setReviewNotes] = useState(""); + const reviewAttempt = useRef(undefined); + const listHeading = useRef(null); + const detailHeading = useRef(null); + const filteredOrders = workOrders.filter( + (order) => + (!statusFilter || order.status === statusFilter) && + (!assigneeFilter || order.assigneeId === assigneeFilter) && + `${order.equipment} ${order.site} ${order.id}` + .toLocaleLowerCase() + .includes(search.trim().toLocaleLowerCase()), + ); + const storageFailure = useCallback((error: unknown) => { + storageFailed.current = true; + setStorageReady(false); + setStorageError(message(error)); + }, []); + const reportError = (error: unknown) => { + if (error instanceof DraftStorageError) { + storageFailure(error); + setNotice(null); + } else setNotice({ kind: "error", text: message(error) }); + }; + const reloadSavedDrafts = useCallback(async () => { + try { + const [saved, incompatible] = await Promise.all([ + drafts.saved(session.user.id), + drafts.incompatible(session.user.id), + ]); + setSavedDrafts(saved); + setIncompatibleDrafts(incompatible); + if (!storageFailed.current) setStorageReady(true); + return true; + } catch (error) { + storageFailure(error); + return false; + } + }, [session.user.id, storageFailure]); + + const preflight = (capability: Capability, resourceId: string) => { + const allowed = authorizePresentation({ + capability, + principalId: session.user.id, + resourceId, + }); + setOperation({ capability, local: allowed }); + return allowed; + }; + const observeRequest = useCallback((server: RequestEvidence) => { + setOperation((current) => ({ + capability: current?.capability ?? capabilities.submit, + local: current?.local, + server, + })); + }, []); const reloadList = useCallback(async (): Promise => { + const generation = ++listGeneration.current; + loadingMore.current = false; + setListLoading(true); + setNextOffset(null); try { const result = await api.workOrders(); + if (generation !== listGeneration.current) return []; setWorkOrders(result.workOrders); + setNextOffset(result.nextOffset); setCreateAllowed(result.createAllowed); - setSelectedId((current) => - current && result.workOrders.some((item) => item.id === current) - ? current - : (result.workOrders[0]?.id ?? ""), - ); - setLoadError(""); + // A bounded queue omitting a selected order is not proof of lost access. + setSelectedId((current) => current || result.workOrders[0]?.id || ""); + setListError(""); return result.workOrders; } catch (error) { + if (generation !== listGeneration.current) return []; if (error instanceof ApiError && error.status === 401) expired(); - setLoadError(message(error)); + setListError(message(error)); return []; + } finally { + if (generation === listGeneration.current) setListLoading(false); } }, [expired]); + const loadMore = async () => { + if (nextOffset === null || listLoading || loadingMore.current) return; + const generation = listGeneration.current; + loadingMore.current = true; + setListLoading(true); + try { + const result = await api.workOrders(nextOffset); + if (generation !== listGeneration.current) return; + setWorkOrders((current) => [ + ...new Map( + [...current, ...result.workOrders].map((order) => [order.id, order]), + ).values(), + ]); + setNextOffset(result.nextOffset); + setCreateAllowed(result.createAllowed); + setListError(""); + } catch (error) { + if (generation !== listGeneration.current) return; + if (error instanceof ApiError && error.status === 401) expired(); + setListError(message(error)); + } finally { + if (generation === listGeneration.current) { + loadingMore.current = false; + setListLoading(false); + } + } + }; + const reloadDetail = useCallback(async () => { + const generation = ++detailGeneration.current; if (!selectedId) return setDetail(undefined); + let stored: InspectionDraft | undefined; try { + stored = await drafts + .get(drafts.key(session.user.id, selectedId)) + .catch((error: unknown) => { + if (error instanceof IncompatibleDraftError) { + const key = drafts.key(session.user.id, selectedId); + setIncompatibleDrafts((current) => [ + ...current.filter((item) => item.key !== key), + { key, workOrderId: selectedId, notes: error.notes }, + ]); + return undefined; + } + storageFailure(error); + return undefined; + }); + if (generation !== detailGeneration.current) return; const result = await api.workOrder(selectedId); + if (generation !== detailGeneration.current) return; setDetail(result); setTarget( technicians.find((item) => item.id !== result.assigneeId)?.id ?? result.assigneeId, ); - const stored = await drafts.get(drafts.key(session.user.id, selectedId)); setDraft(stored); + setDirty(false); + setReviewNotes(""); + reviewAttempt.current = undefined; const url = new URL(location.href); url.searchParams.set("workOrder", selectedId); history.replaceState(null, "", url); setLoadError(""); + return result; } catch (error) { + if (generation !== detailGeneration.current) return; if (error instanceof ApiError && error.status === 401) return expired(); - if (error instanceof ApiError && error.status === 404) { + if (error instanceof ApiError && [403, 404].includes(error.status)) { setDetail(undefined); + if (stored) { + const preserved = { ...stored, status: "orphaned" as const }; + try { + await drafts.put(preserved); + await reloadSavedDrafts(); + if (generation === detailGeneration.current) setDraft(preserved); + } catch (storageError) { + storageFailure(storageError); + setLoadError(message(error)); + return; + } + } } setLoadError(message(error)); } - }, [expired, selectedId, session.user.id]); + }, [expired, selectedId, session.user.id, reloadSavedDrafts, storageFailure]); + + const refreshWorkOrder = async () => { + if ( + !detail || + !online || + busy || + refreshInFlight.current || + draft?.status === "queued" + ) + return; + if ( + dirty && + !window.confirm("Discard unsaved changes and refresh this work order?") + ) + return; + refreshInFlight.current = true; + setBusy(true); + setPendingAction("Refreshing work order…"); + setNotice(null); + setLoadError(""); + try { + const result = await reloadDetail(); + if (!result) return; + // Renewing the queue window alone does not mean the work record changed. + const snapshot = (record: WorkOrderDetail) => + JSON.stringify({ + ...record, + envelope: { ceiling: record.envelope.ceiling }, + }); + setWorkOrders((current) => + current.map((order) => (order.id === result.id ? result : order)), + ); + setNotice({ + kind: "success", + text: + snapshot(detail) === snapshot(result) + ? "Work order is up to date." + : "Work order updated.", + }); + } finally { + refreshInFlight.current = false; + setBusy(false); + } + }; + + const retryStorage = async () => { + if (retryingStorage) return; + setRetryingStorage(true); + const generation = detailGeneration.current; + try { + if (!(await reloadSavedDrafts())) return; + let stored: InspectionDraft | undefined; + try { + stored = selectedId + ? await drafts.get(drafts.key(session.user.id, selectedId)) + : undefined; + } catch (error) { + if (!(error instanceof IncompatibleDraftError)) throw error; + } + if (generation !== detailGeneration.current) return; + // A storage retry must never replace edits or automatically resend a queue. + if (!dirtyRef.current) setDraft(stored); + reconnectPending.current = false; + storageFailed.current = false; + setStorageReady(true); + setStorageError(""); + } catch (error) { + storageFailure(error); + } finally { + setRetryingStorage(false); + } + }; useEffect(() => void reloadList(), [reloadList]); useEffect(() => { - void drafts - .orphaned(session.user.id) - .then(setOrphanedDrafts) - .catch((error: unknown) => setLoadError(message(error))); - }, [session.user.id]); + void reloadSavedDrafts(); + }, [reloadSavedDrafts]); useEffect(() => { setDraft(undefined); + setDetail(undefined); + setOperation(undefined); setConfirmDelete(false); + setActiveTab("inspection"); void reloadDetail(); + return () => { + detailGeneration.current += 1; + }; }, [reloadDetail]); + const loadedDetailId = detail?.id; + useEffect(() => { + if (mobileDetail && loadedDetailId) detailHeading.current?.focus(); + }, [mobileDetail, loadedDetailId]); + useEffect(() => { + const warn = (event: BeforeUnloadEvent) => { + if (dirty) event.preventDefault(); + }; + addEventListener("beforeunload", warn); + return () => removeEventListener("beforeunload", warn); + }, [dirty]); useEffect(() => { setEnvelopeClock(Date.now()); if (!detail) return; - const delay = Math.max( - 0, - Date.parse(detail.envelope.expiresAt) - Date.now() + 1, - ); - const timer = setTimeout( - () => setEnvelopeClock(Date.now()), - Math.min(delay, 2_147_483_647), - ); - return () => clearTimeout(timer); + const timer = setInterval(() => setEnvelopeClock(Date.now()), 1000); + return () => clearInterval(timer); }, [detail]); useEffect(() => { const connected = () => setOnline(true); @@ -275,10 +571,14 @@ function Workspace({ }; }, []); + const hasIncompatibleDraft = incompatibleDrafts.some( + (item) => item.workOrderId === detail?.id, + ); const currentDraft = useMemo(() => { - if (!detail) return undefined; + if (!detail || (hasIncompatibleDraft && !draft)) return undefined; return ( draft ?? { + formatVersion: 3, key: drafts.key(session.user.id, detail.id), principalId: session.user.id, workOrderId: detail.id, @@ -290,30 +590,55 @@ function Workspace({ status: "draft", } ); - }, [detail, draft, session.user.id]); + }, [detail, draft, session.user.id, hasIncompatibleDraft]); const storeDraft = async (next: InspectionDraft, text: string) => { + if (!validNotes(next.notes)) { + setNotice({ kind: "error", text: noteLimitMessage }); + return false; + } await drafts.put(next); setDraft(next); + setDirty(false); setNotice({ kind: "success", text }); + await reloadSavedDrafts(); + return true; }; const discardOrphaned = async (orphaned: InspectionDraft) => { + if (!storageReady || busy) return; try { await drafts.remove(orphaned.key); - setOrphanedDrafts((current) => + setSavedDrafts((current) => current.filter((item) => item.key !== orphaned.key), ); + if (draft?.key === orphaned.key && !dirty) setDraft(undefined); setNotice({ kind: "success", text: "Preserved draft discarded." }); } catch (error) { - setNotice({ kind: "error", text: message(error) }); + reportError(error); } }; const sync = useCallback( async (queued = draft) => { - if (queued?.status !== "queued" || !online) return; + if ( + queued?.status !== "queued" || + !storageReady || + !online || + submitting.current || + !answered(queued.checklist) + ) + return; + submitting.current = true; setBusy(true); + setPendingAction("Sending inspection…"); + setOperation((current) => ({ + capability: capabilities.submit, + local: + current?.capability === capabilities.submit + ? current.local + : undefined, + })); try { const result = await api.submit( queued.workOrderId, @@ -324,52 +649,78 @@ function Workspace({ notes: queued.notes, }, session.csrfToken, + observeRequest, ); - await drafts.remove(queued.key); + let localRemovalFailed = false; + try { + await drafts.remove(queued.key); + } catch (error) { + localRemovalFailed = true; + storageFailure(error); + } setDraft(undefined); setNotice({ - kind: "success", - text: result.replayed - ? "Inspection already synchronized." - : "Inspection synchronized and work order completed.", + kind: localRemovalFailed ? "error" : "success", + text: localRemovalFailed + ? "The server accepted the inspection, but its local copy could not be removed. Refresh to review the submitted record; retrying the unchanged queued copy is safe." + : result.replayed + ? "This inspection was already submitted." + : "Inspection submitted. It is awaiting supervisor review.", }); - const refreshed = await reloadList(); - const nextOpen = refreshed.find( - (item) => item.status === "open" && item.id !== queued.workOrderId, - ); - if (nextOpen) setSelectedId(nextOpen.id); - else await reloadDetail(); + await reloadList(); + await reloadDetail(); } catch (error) { if (error instanceof ApiError && error.status === 401) return expired(); - if (error instanceof ApiError && error.status === 404) { - const orphaned = { ...queued, status: "orphaned" as const }; - await drafts.put(orphaned); - setDraft(undefined); - setDetail(undefined); - setOrphanedDrafts((current) => [ - orphaned, - ...current.filter((item) => item.key !== orphaned.key), - ]); + try { + if (error instanceof ApiError && error.status === 404) { + const orphaned = { ...queued, status: "orphaned" as const }; + await drafts.put(orphaned); + setDraft(undefined); + setDetail(undefined); + setSavedDrafts((current) => [ + orphaned, + ...current.filter((item) => item.key !== orphaned.key), + ]); + setNotice({ + kind: "error", + text: "The work order is unavailable. Your local inspection was preserved for review.", + }); + await reloadList(); + return; + } + if (error instanceof ApiError && [403, 409].includes(error.status)) { + const review = { ...queued, status: "draft" as const }; + await drafts.put(review); + setDraft(review); + await reloadList(); + await reloadDetail(); + } + setNotice({ kind: "error", text: message(error) }); + } catch (storageError) { + storageFailure(storageError); setNotice({ kind: "error", - text: "The work order was removed. Your local inspection was preserved for review.", + text: message(error), }); - await reloadList(); - return; } - if (error instanceof ApiError && [403, 409].includes(error.status)) { - const review = { ...queued, status: "draft" as const }; - await drafts.put(review); - setDraft(review); - await reloadList(); - await reloadDetail(); - } - setNotice({ kind: "error", text: message(error) }); } finally { + await reloadSavedDrafts(); + submitting.current = false; setBusy(false); } }, - [draft, expired, online, reloadDetail, reloadList, session.csrfToken], + [ + draft, + expired, + online, + reloadDetail, + reloadList, + session.csrfToken, + observeRequest, + reloadSavedDrafts, + storageReady, + storageFailure, + ], ); useEffect(() => { @@ -386,30 +737,166 @@ function Workspace({ const queueAllowed = Boolean( detail && - detail.status === "open" && + editable(detail.status) && detail.envelope.ceiling[capabilities.submit] && Date.parse(detail.envelope.expiresAt) > envelopeClock, ); const reassignAllowed = Boolean( - detail?.status === "open" && detail.envelope.ceiling[capabilities.reassign], + detail && + editable(detail.status) && + detail.envelope.ceiling[capabilities.reassign], ); const deleteAllowed = Boolean( detail?.status === "open" && detail.envelope.ceiling[capabilities.delete], ); - const hasOpenWorkOrder = workOrders.some((item) => item.status === "open"); + const hasOpenWorkOrder = workOrders.some((item) => editable(item.status)); + const latestInspection = detail?.inspections[0]; + const allPassed = + latestInspection && + checklistItems.every((item) => latestInspection.checklist[item.key]); + const reviewAllowed = online && detail?.envelope.ceiling[capabilities.review]; - const reassign = async () => { - if (!detail || !reassignAllowed) return; + const saveLocalDraft = async (next: InspectionDraft, text: string) => { + if (!storageReady || hasIncompatibleDraft || busy) return; + setBusy(true); + setPendingAction("Saving draft…"); + setOperation(undefined); + try { + await storeDraft(next, text); + } catch (error) { + reportError(error); + } finally { + setBusy(false); + } + }; + const queueInspection = async () => { if ( - !authorizePresentation({ - capability: capabilities.reassign, - principalId: session.user.id, - resourceId: detail.id, - }) + !currentDraft || + !detail || + submitting.current || + !storageReady || + hasIncompatibleDraft + ) + return; + if (!validNotes(currentDraft.notes)) { + setNotice({ kind: "error", text: noteLimitMessage }); + return; + } + if (!answered(currentDraft.checklist)) { + setNotice({ + kind: "error", + text: "Choose Pass or Fail for every check.", + }); + return; + } + if ( + checklistItems.some( + (item) => currentDraft.checklist[item.key] === false, + ) && + !currentDraft.notes.trim() ) { + setNotice({ + kind: "error", + text: "Describe the failed checks in the inspection notes.", + }); + return; + } + if (!queueAllowed || Date.parse(detail.envelope.expiresAt) <= Date.now()) { + setEnvelopeClock(Date.now()); + setNotice({ + kind: "error", + text: "Refresh the work order before queuing this inspection.", + }); + return; + } + if (!preflight(capabilities.submit, detail.id)) return; + submitting.current = true; + setBusy(true); + setPendingAction("Queuing inspection…"); + try { + const queued = { ...currentDraft, status: "queued" as const }; + const saved = await storeDraft( + queued, + online + ? "Inspection ready to send." + : "Inspection queued until reconnect.", + ); + submitting.current = false; + if (saved && online) await sync(queued); + } catch (error) { + reportError(error); + } finally { + submitting.current = false; + setBusy(false); + } + }; + const reviewInspection = async (outcome: InspectionReview["outcome"]) => { + if (!detail || !latestInspection || !reviewAllowed || submitting.current) + return; + if (!validNotes(reviewNotes)) { + setNotice({ kind: "error", text: noteLimitMessage }); + return; + } + if (!preflight(capabilities.review, detail.id)) return; + submitting.current = true; + setBusy(true); + setPendingAction("Recording review…"); + const previous = reviewAttempt.current; + const input = + previous && + previous.inspectionId === latestInspection.id && + previous.outcome === outcome && + previous.notes === reviewNotes + ? previous + : { + inspectionId: latestInspection.id, + outcome, + notes: reviewNotes, + expectedWorkOrderVersion: detail.workOrderVersion, + idempotencyKey: crypto.randomUUID(), + }; + reviewAttempt.current = input; + try { + await api.review(detail.id, input, session.csrfToken, observeRequest); + setNotice({ + kind: "success", + text: + outcome === "complete" + ? "Review recorded. Work order completed." + : "Corrections requested. The technician can submit a new inspection.", + }); + await reloadList(); + await reloadDetail(); + } catch (error) { + if (error instanceof ApiError && error.status === 401) expired(); + else setNotice({ kind: "error", text: message(error) }); + } finally { + submitting.current = false; + setBusy(false); + } + }; + + const reassign = async () => { + if ( + !detail || + !reassignAllowed || + !online || + busy || + target === detail.assigneeId + ) + return; + if ( + dirty && + !window.confirm( + "Discard unsaved changes and reassign this work order? Saved drafts stay on this device.", + ) + ) + return; + if (!preflight(capabilities.reassign, detail.id)) { return; } setBusy(true); + setPendingAction("Reassigning work order…"); try { await api.reassign( detail.id, @@ -418,6 +905,7 @@ function Workspace({ technicianId: target, }, session.csrfToken, + observeRequest, ); setNotice({ kind: "success", text: "Work order reassigned." }); await reloadList(); @@ -431,6 +919,14 @@ function Workspace({ }; const createWorkOrder = async () => { + if (creating.current || busy) return; + if ( + dirty && + !window.confirm( + "Discard unsaved changes and add a new work order? Saved drafts stay on this device.", + ) + ) + return; if ( !online || !createAllowed || @@ -439,16 +935,13 @@ function Workspace({ ) { return; } - if ( - !authorizePresentation({ - capability: capabilities.create, - principalId: session.user.id, - resourceId: "work-orders", - }) - ) { + if (!preflight(capabilities.create, "work-orders")) { return; } + creating.current = true; + setCreateError(""); setBusy(true); + setPendingAction("Adding work order…"); try { const result = await api.createWorkOrder( { @@ -457,9 +950,11 @@ function Workspace({ technicianId: newOrder.technicianId, }, session.csrfToken, + observeRequest, ); await reloadList(); setSelectedId(result.workOrder.id); + setMobileDetail(true); setNewOrder({ equipment: "", site: "", @@ -469,24 +964,20 @@ function Workspace({ setNotice({ kind: "success", text: "Work order added." }); } catch (error) { if (error instanceof ApiError && error.status === 401) return expired(); - setNotice({ kind: "error", text: message(error) }); + setCreateError(message(error)); } finally { + creating.current = false; setBusy(false); } }; const deleteWorkOrder = async () => { if (!detail || !online || !deleteAllowed) return; - if ( - !authorizePresentation({ - capability: capabilities.delete, - principalId: session.user.id, - resourceId: detail.id, - }) - ) { + if (!preflight(capabilities.delete, detail.id)) { return; } setBusy(true); + setPendingAction("Deleting work order…"); try { await api.deleteWorkOrder( detail.id, @@ -495,8 +986,16 @@ function Workspace({ expectedAssignmentEpoch: detail.assignmentEpoch, }, session.csrfToken, + observeRequest, ); - if (draft) await drafts.remove(draft.key); + if (draft && storageReady) { + try { + await drafts.remove(draft.key); + } catch (error) { + storageFailure(error); + } + } + await reloadSavedDrafts(); setDraft(undefined); setDetail(undefined); const refreshed = await reloadList(); @@ -517,78 +1016,92 @@ function Workspace({

{ - await api.logout(session.csrfToken); - location.assign("/"); + if ( + dirty && + !window.confirm( + "Discard unsaved changes and sign out? Saved drafts stay on this device.", + ) + ) + return; + try { + await api.logout(session.csrfToken); + location.assign("/"); + } catch (error) { + setNotice({ kind: "error", text: message(error) }); + } }} /> -
+
-
+
+ + {detail && ( +
+
+

+ {detail.equipment} +

+
+ +
+ )} {notice && (

{notice.text}

)} - {orphanedDrafts.map((orphaned) => { - const completedItems = checklistItems - .filter((item) => orphaned.checklist[item.key]) - .map((item) => item.label); - return ( -
-
- Removed work order -

Preserved inspection draft

-

- {orphaned.workOrderId} no longer exists. Review this local - copy, then discard it when it is no longer needed. -

-
-
-
Checklist
-
- {completedItems.length > 0 - ? completedItems.join(", ") - : "No items selected"} -
-
-
-
Notes
-
{orphaned.notes || "No notes"}
-
-
-
- -
- ); - })} + {storageError && ( +
+

+ {storageError} Saving, queuing, and submitting inspections are + unavailable until storage works again. Online work orders and + management actions remain available. +

+ +
+ )} + {loadError && mobileDetail && ( +

+ {loadError} +

+ )} + {busy && ( +

+ {pendingAction} +

+ )} {!detail ? (
-

Select a work order

-

Choose an inspection from the queue.

+

+ {selectedId && !loadError + ? "Loading work order…" + : "Select a work order"} +

+

+ {selectedId && !loadError + ? "Loading its assignment and inspection history." + : "Choose an inspection from the queue."} +

) : ( <> -
-
- {detail.site} -

{detail.equipment}

+
+
+

+ {detail.site} ·{" "} + {detail.id} +

+

+ Assigned to {detail.assigneeName} +

- {detail.status} + {statusLabel(detail.status)} -
-
-
-
Assigned to
-
{detail.assigneeName}
-
-
-
Work version
-
{detail.workOrderVersion}
-
-
-
Assignment epoch
-
{detail.assignmentEpoch}
+ + {online ? "Online" : "Offline"} + +
+ +

+ {editable(detail.status) + ? "Reloads the record and renews the offline queue window." + : "Loads the latest inspection and review."} +

-
-
Draft
-
{draft?.status ?? "Not saved"}
+
+
- {detail.status === "completed" ? ( -
- Inspection completed -

- This work order is read-only. - {hasOpenWorkOrder - ? " Select an open work order from the field queue to continue." - : " All current work orders are complete."} -

-
- ) : ( - <> -
-
- Inspection checklist - {checklistItems.map((item) => ( - - ))} -
-