From f66b954ba3c1ba7d1c8a3b143a20bef8288ce814 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:00:31 +0100 Subject: [PATCH 01/52] Standardize postcss on 8.4.49 to address CVE in versions < 8.4.49 (#59) * Initial plan * Fix postcss vulnerability by upgrading to 8.5.6 Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Update postcss override to target 8.5.6 to eliminate dual versions Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Standardize on postcss 8.4.49 to fix CVE and eliminate version conflicts Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- apps/web/package.json | 2 +- package.json | 3 +- pnpm-lock.yaml | 401 ++++++++++++++++++++---------------------- 3 files changed, 194 insertions(+), 212 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 57efca4a..4ada5cea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,7 +28,7 @@ "@types/react-dom": "^18.3.1", "autoprefixer": "^10.4.20", "eslint-config-next": "^15.5.10", - "postcss": "^8.4.49", + "postcss": "8.4.49", "tailwindcss": "^3.4.17" } } diff --git a/package.json b/package.json index 0589d492..c18482e4 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "overrides": { "glob@>=10.2.0 <10.5.0": "10.5.0", "glob@>=11.0.0 <11.1.0": "11.1.0", - "lodash@>=4.0.0 <=4.17.22": "4.17.23" + "lodash@>=4.0.0 <=4.17.22": "4.17.23", + "postcss@>=8.0.0 <9.0.0": "8.4.49" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6df4b660..73554902 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: glob@>=10.2.0 <10.5.0: 10.5.0 glob@>=11.0.0 <11.1.0: 11.1.0 lodash@>=4.0.0 <=4.17.22: 4.17.23 + postcss@>=8.0.0 <9.0.0: 8.4.49 importers: @@ -24,13 +25,13 @@ importers: version: link:packages/sim '@nestjs/common': specifier: ^11.1.13 - version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.1.13 - version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.1.13 - version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@prisma/client': specifier: ^6.19.2 version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) @@ -40,10 +41,10 @@ importers: devDependencies: '@better-auth/cli': specifier: ^1.4.18 - version: 1.4.18(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)) + version: 1.4.18(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)) '@nestjs/testing': specifier: ^11.1.13 - version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14) '@types/node': specifier: ^22.19.11 version: 22.19.11 @@ -52,7 +53,7 @@ importers: version: 6.0.3 '@typescript-eslint/eslint-plugin': specifier: ^8.55.0 - version: 8.55.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.56.0 version: 8.56.0(eslint@8.57.1)(typescript@5.9.3) @@ -103,13 +104,13 @@ importers: version: link:../../packages/sim '@thallesp/nestjs-better-auth': specifier: ^2.4.0 - version: 2.4.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)))(express@5.2.1)(typescript@5.9.3) + version: 2.4.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)))(express@5.2.1)(typescript@5.9.3) better-auth: specifier: ^1.4.18 version: 1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)) better-call: specifier: ^1.1.8 - version: 1.1.8(zod@4.3.6) + version: 1.3.2(zod@4.3.6) apps/web: dependencies: @@ -161,13 +162,13 @@ importers: version: 18.3.7(@types/react@18.3.28) autoprefixer: specifier: ^10.4.20 - version: 10.4.24(postcss@8.5.6) + version: 10.4.24(postcss@8.4.49) eslint-config-next: specifier: ^15.5.10 version: 15.5.12(eslint@8.57.1)(typescript@5.9.3) postcss: - specifier: ^8.4.49 - version: 8.5.6 + specifier: 8.4.49 + version: 8.4.49 tailwindcss: specifier: ^3.4.17 version: 3.4.19(tsx@4.21.0) @@ -400,6 +401,9 @@ packages: '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + '@better-auth/utils@0.3.1': + resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==} + '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} @@ -832,8 +836,8 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@nestjs/common@11.1.13': - resolution: {integrity: sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==} + '@nestjs/common@11.1.14': + resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -845,8 +849,8 @@ packages: class-validator: optional: true - '@nestjs/core@11.1.13': - resolution: {integrity: sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==} + '@nestjs/core@11.1.14': + resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -863,14 +867,14 @@ packages: '@nestjs/websockets': optional: true - '@nestjs/platform-express@11.1.13': - resolution: {integrity: sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==} + '@nestjs/platform-express@11.1.14': + resolution: {integrity: sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/testing@11.1.13': - resolution: {integrity: sha512-bOWP8nLEZAOEEX8jAZGBCc1yU0+nv4g2ipc+QEzkVUe3eEEUKHKaeGafJ3GtDuGavlZKfkXEqflZuICdavu5dQ==} + '@nestjs/testing@11.1.14': + resolution: {integrity: sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -1192,6 +1196,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -1521,12 +1538,12 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} - '@typescript-eslint/eslint-plugin@8.55.0': - resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} + '@typescript-eslint/eslint-plugin@8.56.0': + resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.55.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/parser': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/parser@8.56.0': @@ -1536,76 +1553,46 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.55.0': - resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.56.0': resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.55.0': - resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.56.0': resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.55.0': - resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.56.0': resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.55.0': - resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} + '@typescript-eslint/type-utils@8.56.0': + resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.55.0': - resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.56.0': resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.55.0': - resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.56.0': resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.55.0': - resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} + '@typescript-eslint/utils@8.56.0': + resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.55.0': - resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.56.0': resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1840,7 +1827,7 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.1.0 + postcss: 8.4.49 available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -1934,6 +1921,14 @@ packages: zod: optional: true + better-call@1.3.2: + resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-sqlite3@12.6.2: resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} @@ -2028,8 +2023,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001770: + resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} @@ -2553,10 +2548,6 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.0: resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2984,8 +2975,8 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} - is-wsl@3.1.0: - resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} isarray@2.0.5: @@ -3072,8 +3063,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libphonenumber-js@1.12.36: - resolution: {integrity: sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==} + libphonenumber-js@1.12.37: + resolution: {integrity: sha512-rDU6bkpuMs8YRt/UpkuYEAsYSoNuDEbrE41I3KNvmXREGH6DGBJ8Wbak4by29wNOQ27zk4g4HL82zf0OGhwRuw==} lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} @@ -3267,6 +3258,10 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -3455,20 +3450,20 @@ packages: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: - postcss: ^8.0.0 + postcss: 8.4.49 postcss-js@4.1.0: resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: - postcss: ^8.4.21 + postcss: 8.4.49 postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.0.9' + postcss: 8.4.49 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -3485,7 +3480,7 @@ packages: resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: - postcss: ^8.2.14 + postcss: 8.4.49 postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} @@ -3494,12 +3489,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -3563,8 +3554,8 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -3680,8 +3671,9 @@ packages: engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} hasBin: true reusify@1.1.0: @@ -3756,6 +3748,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.0.1: + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4481,12 +4476,12 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/cli@1.4.18(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))': + '@better-auth/cli@1.4.18(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/preset-react': 7.28.5(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@clack/prompts': 0.11.0 @@ -4564,14 +4559,27 @@ snapshots: nanostores: 1.1.0 zod: 4.3.6 + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.2(zod@4.3.6) + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.6 + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@better-auth/utils@0.3.0': {} + '@better-auth/utils@0.3.1': {} + '@better-fetch/fetch@1.1.21': {} '@borewit/text-codec@0.2.1': {} @@ -4898,7 +4906,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.0 iterare: 1.2.1 @@ -4913,9 +4921,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -4925,12 +4933,12 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/platform-express@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)': + '@nestjs/platform-express@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.0.2 @@ -4939,13 +4947,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/testing@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13))': + '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)': dependencies: - '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@next/env@15.5.12': {} @@ -5216,6 +5224,15 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.1 @@ -5409,10 +5426,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@thallesp/nestjs-better-auth@2.4.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)))(express@5.2.1)(typescript@5.9.3)': + '@thallesp/nestjs-better-auth@2.4.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)))(express@5.2.1)(typescript@5.9.3)': dependencies: - '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) better-auth: 1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)) express: 5.2.1 typescript: 5.9.3 @@ -5481,14 +5498,14 @@ snapshots: '@types/validator@13.15.10': {} - '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.56.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/type-utils': 8.56.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.0 eslint: 8.57.1 ignore: 7.0.5 natural-compare: 1.4.0 @@ -5509,15 +5526,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) @@ -5527,29 +5535,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.55.0': - dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - '@typescript-eslint/scope-manager@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 - '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.55.0(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@8.57.1)(typescript@5.9.3) debug: 4.4.3 eslint: 8.57.1 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -5557,25 +5556,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.55.0': {} - '@typescript-eslint/types@8.56.0': {} - '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) @@ -5591,22 +5573,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.55.0(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.55.0': - dependencies: - '@typescript-eslint/types': 8.55.0 - eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 @@ -5681,15 +5658,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@1.21.7)(tsx@4.21.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@22.19.11)(jiti@1.21.7)(tsx@4.21.0) - optional: true - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 @@ -5844,13 +5812,13 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.4.24(postcss@8.5.6): + autoprefixer@10.4.24(postcss@8.4.49): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001770 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.6 + postcss: 8.4.49 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -5919,7 +5887,7 @@ snapshots: better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)): dependencies: - '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -5951,6 +5919,15 @@ snapshots: optionalDependencies: zod: 4.3.6 + better-call@1.3.2(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.0.1 + optionalDependencies: + zod: 4.3.6 + better-sqlite3@12.6.2: dependencies: bindings: 1.5.0 @@ -5976,7 +5953,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.14.2 + qs: 6.15.0 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -5998,7 +5975,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001770 electron-to-chromium: 1.5.286 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -6085,7 +6062,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001770: {} chai@5.3.3: dependencies: @@ -6146,7 +6123,7 @@ snapshots: class-validator@0.14.3: dependencies: '@types/validator': 13.15.10 - libphonenumber-js: 1.12.36 + libphonenumber-js: 1.12.37 validator: 13.15.26 class-variance-authority@0.7.1: @@ -6164,7 +6141,7 @@ snapshots: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -6512,7 +6489,7 @@ snapshots: dependencies: '@next/eslint-plugin-next': 15.5.12 '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.56.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 @@ -6551,7 +6528,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -6573,7 +6550,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6631,7 +6608,7 @@ snapshots: object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.5 + resolve: 2.0.0-next.6 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 @@ -6643,8 +6620,6 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.0: {} eslint@8.57.1: @@ -6740,7 +6715,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.2 + qs: 6.15.0 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -7169,7 +7144,7 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 - is-wsl@3.1.0: + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -7242,7 +7217,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libphonenumber-js@1.12.36: {} + libphonenumber-js@1.12.37: {} lilconfig@2.1.0: {} @@ -7383,8 +7358,8 @@ snapshots: dependencies: '@next/env': 15.5.12 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001769 - postcss: 8.4.31 + caniuse-lite: 1.0.30001770 + postcss: 8.4.49 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@18.3.1) @@ -7408,6 +7383,13 @@ snapshots: node-abort-controller@3.1.1: {} + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + node-fetch-native@1.6.7: {} node-gyp-build-optional-packages@5.2.2: @@ -7586,29 +7568,29 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-import@15.1.0(postcss@8.5.6): + postcss-import@15.1.0(postcss@8.4.49): dependencies: - postcss: 8.5.6 + postcss: 8.4.49 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.11 - postcss-js@4.1.0(postcss@8.5.6): + postcss-js@4.1.0(postcss@8.4.49): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.6 + postcss: 8.4.49 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.4.49)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.6 + postcss: 8.4.49 tsx: 4.21.0 - postcss-nested@6.2.0(postcss@8.5.6): + postcss-nested@6.2.0(postcss@8.4.49): dependencies: - postcss: 8.5.6 + postcss: 8.4.49 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.1.2: @@ -7618,13 +7600,7 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.31: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.6: + postcss@8.4.49: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -7693,7 +7669,7 @@ snapshots: pure-rand@6.1.0: {} - qs@6.14.2: + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -7817,9 +7793,12 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.5: + resolve@2.0.0-next.6: dependencies: + es-errors: 1.3.0 is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -7940,6 +7919,8 @@ snapshots: set-cookie-parser@2.7.2: {} + set-cookie-parser@3.0.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -8162,7 +8143,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.14.2 + qs: 6.15.0 transitivePeerDependencies: - supports-color @@ -8198,11 +8179,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0) - postcss-nested: 6.2.0(postcss@8.5.6) + postcss: 8.4.49 + postcss-import: 15.1.0(postcss@8.4.49) + postcss-js: 4.1.0(postcss@8.4.49) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.4.49)(tsx@4.21.0) + postcss-nested: 6.2.0(postcss@8.4.49) postcss-selector-parser: 6.1.2 resolve: 1.22.11 sucrase: 3.35.1 @@ -8466,7 +8447,7 @@ snapshots: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.4.49 rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: @@ -8481,7 +8462,7 @@ snapshots: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.4.49 rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: @@ -8494,7 +8475,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@1.21.7)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -8629,7 +8610,7 @@ snapshots: wsl-utils@0.1.0: dependencies: - is-wsl: 3.1.0 + is-wsl: 3.1.1 xtend@4.0.2: {} From 102df74dbc1e0d09104ba3fbb18779f8121b0e5b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:45:10 +0100 Subject: [PATCH 02/52] Fix early-game UX issues: form placeholders, time visibility, completion feedback (#73) * Initial plan * Fix prefilled form fields, add tick countdown, improve workforce UI explanations Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Add toast notifications for research and production completion Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Fix TypeScript errors in toast notifications Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Add release entry for UX improvements Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Remove prefilled allocation percentages from workforce page initial state Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Fix capacity delta input reset to empty string after submission Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Apply code review feedback: improve accessibility, fix countdown boundary, deduplicate toast recipes Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> * Update design guidelines documentation with new UX patterns Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- ...218131646-fix-ux-data-visibility-issues.md | 12 ++ .../src/components/layout/tick-countdown.tsx | 53 +++++++++ apps/web/src/components/layout/top-bar.tsx | 6 +- .../market/order-placement-card.tsx | 8 +- .../components/production/production-page.tsx | 33 ++++-- .../src/components/research/research-page.tsx | 43 ++++++- .../components/workforce/workforce-page.tsx | 110 +++++++++++------- apps/web/src/lib/api-client.ts | 2 +- docs/design/DESIGN_GUIDELINES.md | 24 ++++ 9 files changed, 230 insertions(+), 61 deletions(-) create mode 100644 .releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md create mode 100644 apps/web/src/components/layout/tick-countdown.tsx diff --git a/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md b/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md new file mode 100644 index 00000000..877f0f77 --- /dev/null +++ b/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md @@ -0,0 +1,12 @@ +--- +type: minor +area: web +summary: Fix early UX and data visibility issues (forms, time display, workforce clarity, completion feedback) +--- + +- Replace prefilled form values with descriptive placeholders in market orders, production jobs, and workforce +- Add tick countdown timer showing "Next week in Xs" with tooltip explaining time progression +- Reduce health polling from 3s to 15s to minimize UI refresh indicator blinking +- Add comprehensive workforce explanations (capacity impact, allocation labels, help text) +- Add toast notifications for research and production job completions with unlocked recipe details +- Improve overall system transparency and onboarding clarity diff --git a/apps/web/src/components/layout/tick-countdown.tsx b/apps/web/src/components/layout/tick-countdown.tsx new file mode 100644 index 00000000..da99c26d --- /dev/null +++ b/apps/web/src/components/layout/tick-countdown.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Clock } from "lucide-react"; +import { InlineHelp } from "@/components/ui/inline-help"; +import { useWorldHealth } from "./world-health-provider"; + +// Default tick interval (60 seconds) - matches worker default configuration +// TODO: Get this from API configuration endpoint +const DEFAULT_TICK_INTERVAL_MS = 60_000; + +export function TickCountdown() { + const { health } = useWorldHealth(); + const [secondsRemaining, setSecondsRemaining] = useState(null); + + useEffect(() => { + if (!health?.lastAdvancedAt) { + setSecondsRemaining(null); + return; + } + + const updateCountdown = () => { + const lastAdvancedTime = new Date(health.lastAdvancedAt!).getTime(); + const now = Date.now(); + const elapsed = now - lastAdvancedTime; + const remaining = + (DEFAULT_TICK_INTERVAL_MS - (elapsed % DEFAULT_TICK_INTERVAL_MS)) % + DEFAULT_TICK_INTERVAL_MS; + setSecondsRemaining(Math.ceil(remaining / 1000)); + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + + return () => clearInterval(interval); + }, [health?.lastAdvancedAt, health?.currentTick]); + + if (secondsRemaining === null) { + return null; + } + + const helpText = `Time progression: The simulation advances in discrete weeks (ticks). Each week represents ${DEFAULT_TICK_INTERVAL_MS / 1000} seconds of real time. Production jobs, research, and shipments complete when the required number of weeks pass.`; + + return ( +
+ + + Next week in {secondsRemaining}s + + +
+ ); +} diff --git a/apps/web/src/components/layout/top-bar.tsx b/apps/web/src/components/layout/top-bar.tsx index 695cd3d3..d86e9101 100644 --- a/apps/web/src/components/layout/top-bar.tsx +++ b/apps/web/src/components/layout/top-bar.tsx @@ -15,6 +15,7 @@ import { useControlManager } from "./control-manager"; import { PROFILE_PANEL_ID } from "./profile-panel"; import { StatusIndicator } from "./status-indicator"; import { UiSfxSettings } from "./ui-sfx-settings"; +import { TickCountdown } from "./tick-countdown"; import { useWorldHealth } from "./world-health-provider"; export function TopBar() { @@ -38,7 +39,10 @@ export function TopBar() {

{TOP_BAR_TITLES[pathname] ?? "CorpSim"}

-

{formatCadencePoint(health?.currentTick)}

+
+

{formatCadencePoint(health?.currentTick)}

+ +
diff --git a/apps/web/src/components/market/order-placement-card.tsx b/apps/web/src/components/market/order-placement-card.tsx index 1fe6053d..8f74b7a8 100644 --- a/apps/web/src/components/market/order-placement-card.tsx +++ b/apps/web/src/components/market/order-placement-card.tsx @@ -30,8 +30,8 @@ export function OrderPlacementCard({ const [side, setSide] = useState<"BUY" | "SELL">("BUY"); const [selectedItemId, setSelectedItemId] = useState(""); const [itemSearch, setItemSearch] = useState(""); - const [priceInput, setPriceInput] = useState("1.00"); - const [quantityInput, setQuantityInput] = useState("1"); + const [priceInput, setPriceInput] = useState(""); + const [quantityInput, setQuantityInput] = useState(""); const [error, setError] = useState(null); const deferredItemSearch = useDeferredValue(itemSearch); @@ -144,7 +144,7 @@ export function OrderPlacementCard({ setQuantityInput(event.target.value)} - placeholder="1" + placeholder="Enter quantity (e.g., 100)" />
@@ -181,7 +181,7 @@ export function OrderPlacementCard({ setPriceInput(event.target.value)} - placeholder="1.00" + placeholder="Enter price (e.g., 1.50)" />

Enter dollars (for example, 0.80). The order is stored in cents. diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx index 943033fd..9e5e6c38 100644 --- a/apps/web/src/components/production/production-page.tsx +++ b/apps/web/src/components/production/production-page.tsx @@ -78,7 +78,7 @@ export function ProductionPage() { const [recipePage, setRecipePage] = useState(1); const [recipePageSize, setRecipePageSize] = useState<(typeof PRODUCTION_RECIPE_PAGE_SIZE_OPTIONS)[number]>(10); - const [quantityInput, setQuantityInput] = useState("1"); + const [quantityInput, setQuantityInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoadingRecipes, setIsLoadingRecipes] = useState(true); const [isLoadingJobs, setIsLoadingJobs] = useState(true); @@ -331,18 +331,33 @@ export function ProductionPage() { return; } - let hasNewCompletion = false; - for (const jobId of nextIds) { - if (!completedJobIdsRef.current.has(jobId)) { - hasNewCompletion = true; - break; + const newlyCompleted: ProductionJob[] = []; + for (const job of completedJobs) { + if (!completedJobIdsRef.current.has(job.id)) { + newlyCompleted.push(job); } } - if (hasNewCompletion) { + if (newlyCompleted.length > 0) { play("event_production_completed"); + + // Show toast notification for completed jobs + if (newlyCompleted.length === 1) { + const job = newlyCompleted[0]; + showToast({ + title: "Production Complete", + description: `Produced ${job.quantity} × ${job.recipe.outputItem.name}`, + variant: "success" + }); + } else { + showToast({ + title: "Production Complete", + description: `${newlyCompleted.length} production jobs completed`, + variant: "success" + }); + } } completedJobIdsRef.current = nextIds; - }, [completedJobs, play]); + }, [completedJobs, play, showToast]); const showInitialRecipesSkeleton = isLoadingRecipes && !hasLoadedRecipes; const showInitialJobsSkeleton = isLoadingJobs && !hasLoadedJobs; @@ -614,7 +629,7 @@ export function ProductionPage() { setQuantityInput(event.target.value)} - placeholder="1" + placeholder="Enter number of runs (e.g., 10)" /> diff --git a/apps/web/src/components/research/research-page.tsx b/apps/web/src/components/research/research-page.tsx index 056a3481..5db85ee5 100644 --- a/apps/web/src/components/research/research-page.tsx +++ b/apps/web/src/components/research/research-page.tsx @@ -195,6 +195,8 @@ export function ResearchPage() { didPrimeStatusesRef.current = false; }, [activeCompanyId]); + const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]); + useEffect(() => { const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const)); if (!didPrimeStatusesRef.current) { @@ -203,21 +205,50 @@ export function ResearchPage() { return; } - let hasNewCompletion = false; + const completedNodes: ResearchNode[] = []; for (const [nodeId, nextStatus] of nextStatusById.entries()) { const previousStatus = statusByNodeIdRef.current.get(nodeId); if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") { - hasNewCompletion = true; - break; + const node = nodeById.get(nodeId); + if (node) { + completedNodes.push(node); + } } } - if (hasNewCompletion) { + if (completedNodes.length > 0) { play("event_research_completed"); + + // Show toast notification for completed research + const unlockedRecipes = completedNodes.flatMap((node) => node.unlockRecipes); + const uniqueRecipeNamesSet = new Set( + unlockedRecipes + .map((recipe) => recipe.recipeName) + .filter((name): name is string => Boolean(name && name.trim())) + ); + const uniqueRecipeNames = Array.from(uniqueRecipeNamesSet); + const MAX_RECIPES_IN_TOAST = 3; + let message: string; + + if (uniqueRecipeNames.length > 0) { + const displayedNames = uniqueRecipeNames.slice(0, MAX_RECIPES_IN_TOAST); + const remainingCount = uniqueRecipeNames.length - displayedNames.length; + const baseList = displayedNames.join(", "); + const summary = + remainingCount > 0 ? `${baseList} + ${remainingCount} more` : baseList; + message = `Research complete! Unlocked recipes: ${summary}`; + } else { + message = "Research complete!"; + } + + showToast({ + title: completedNodes.length === 1 ? completedNodes[0].name : "Research Complete", + description: message, + variant: "success" + }); } statusByNodeIdRef.current = nextStatusById; - }, [nodes, play]); + }, [nodes, play, showToast, nodeById]); - const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]); const selectedNode = selectedNodeId ? nodeById.get(selectedNodeId) ?? null : null; const filteredNodes = useMemo(() => { diff --git a/apps/web/src/components/workforce/workforce-page.tsx b/apps/web/src/components/workforce/workforce-page.tsx index ffe3dce0..cf3e6596 100644 --- a/apps/web/src/components/workforce/workforce-page.tsx +++ b/apps/web/src/components/workforce/workforce-page.tsx @@ -52,12 +52,12 @@ export function WorkforcePage() { const { health } = useWorldHealth(); const [workforce, setWorkforce] = useState(null); const [allocationDraft, setAllocationDraft] = useState({ - operationsPct: "40", - researchPct: "20", - logisticsPct: "20", - corporatePct: "20" + operationsPct: "", + researchPct: "", + logisticsPct: "", + corporatePct: "" }); - const [capacityDeltaInput, setCapacityDeltaInput] = useState("0"); + const [capacityDeltaInput, setCapacityDeltaInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [hasLoadedWorkforce, setHasLoadedWorkforce] = useState(false); const [isSavingAllocation, setIsSavingAllocation] = useState(false); @@ -207,7 +207,7 @@ export function WorkforcePage() { deltaCapacity }); await loadWorkforce(); - setCapacityDeltaInput("0"); + setCapacityDeltaInput(""); setError(null); showToast({ title: "Capacity request submitted", @@ -246,6 +246,9 @@ export function WorkforcePage() { Organizational Capacity +

+ Workforce capacity determines production speed and research efficiency. Higher capacity allows faster operations, but increases weekly salary costs. Allocation percentages control which departments receive speed bonuses. +

@@ -278,42 +281,69 @@ export function WorkforcePage() { Allocation Controls +

+ Distribute your workforce across departments. Higher allocation in each area provides speed bonuses. Total must equal 100%. +

- - setAllocationDraft((current) => ({ ...current, operationsPct: event.target.value })) - } - placeholder="Operations %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, researchPct: event.target.value })) - } - placeholder="Research %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, logisticsPct: event.target.value })) - } - placeholder="Logistics %" - inputMode="numeric" - /> - - setAllocationDraft((current) => ({ ...current, corporatePct: event.target.value })) - } - placeholder="Corporate %" - inputMode="numeric" - /> -
@@ -345,7 +375,7 @@ export function WorkforcePage() { setCapacityDeltaInput(event.target.value)} - placeholder="Delta capacity" + placeholder="Enter change (e.g., +50 or -20)" inputMode="numeric" /> + + + + + + ); +} diff --git a/apps/web/src/components/buildings/buildings-page.tsx b/apps/web/src/components/buildings/buildings-page.tsx new file mode 100644 index 00000000..371a594c --- /dev/null +++ b/apps/web/src/components/buildings/buildings-page.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useActiveCompany } from "@/components/company/active-company-provider"; +import { useWorldHealth } from "@/components/layout/world-health-provider"; +import { useToastManager } from "@/components/ui/toast-manager"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + BuildingRecord, + BuildingStatus, + BuildingTypeDefinition, + listBuildings, + getBuildingTypeDefinitions, + reactivateBuilding +} from "@/lib/api"; +import { formatCents } from "@/lib/format"; +import { UI_COPY } from "@/lib/ui-copy"; +import { AcquireBuildingDialog } from "./acquire-building-dialog"; + +const STATUS_BADGE_VARIANTS: Record = { + ACTIVE: "default", + INACTIVE: "danger", + CONSTRUCTION: "muted" +}; + +export function BuildingsPage() { + const { activeCompany, activeCompanyId } = useActiveCompany(); + const { health } = useWorldHealth(); + const { showToast } = useToastManager(); + const [buildings, setBuildings] = useState([]); + const [definitions, setDefinitions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [acquireDialogOpen, setAcquireDialogOpen] = useState(false); + + const loadBuildings = useCallback(async () => { + if (!activeCompanyId) { + setBuildings([]); + return; + } + + setIsLoading(true); + try { + const buildingRecords = await listBuildings({ companyId: activeCompanyId }); + setBuildings(buildingRecords); + setError(null); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Failed to load buildings"); + } finally { + setIsLoading(false); + } + }, [activeCompanyId]); + + const loadDefinitions = useCallback(async () => { + try { + const defs = await getBuildingTypeDefinitions(); + setDefinitions(defs); + } catch (caught) { + console.error("Failed to load building definitions:", caught); + } + }, []); + + useEffect(() => { + void loadBuildings(); + }, [loadBuildings]); + + useEffect(() => { + void loadDefinitions(); + }, [loadDefinitions]); + + useEffect(() => { + const tick = health?.currentTick; + if (tick === undefined || !activeCompanyId) { + return; + } + + const timeout = setTimeout(() => { + void loadBuildings(); + }, 500); + + return () => clearTimeout(timeout); + }, [health?.currentTick, loadBuildings, activeCompanyId]); + + const groupedBuildings = useMemo(() => { + const byRegion: Record = {}; + + for (const building of buildings) { + const key = building.region.name; + if (!byRegion[key]) { + byRegion[key] = []; + } + byRegion[key].push(building); + } + + return byRegion; + }, [buildings]); + + const handleReactivate = useCallback( + async (buildingId: string) => { + try { + await reactivateBuilding(buildingId); + showToast({ + title: "Building Reactivated", + variant: "success" + }); + void loadBuildings(); + } catch (caught) { + showToast({ + title: "Reactivation Failed", + description: caught instanceof Error ? caught.message : "Failed to reactivate building", + variant: "error" + }); + } + }, + [loadBuildings, showToast] + ); + + const handleAcquireSuccess = useCallback(() => { + setAcquireDialogOpen(false); + void loadBuildings(); + }, [loadBuildings]); + + if (!activeCompanyId) { + return ( + + + Buildings + + +

{UI_COPY.common.noCompanySelected}

+
+
+ ); + } + + return ( +
+ + + Buildings + + + +

+ Active company: {activeCompany?.name ?? UI_COPY.common.noCompanySelected} +

+ + {error && ( +
+ {error} +
+ )} + + {isLoading && buildings.length === 0 ? ( +

Loading buildings...

+ ) : buildings.length === 0 ? ( +

+ No buildings owned. Click "Acquire Building" to purchase your first facility. +

+ ) : ( +
+ {Object.entries(groupedBuildings).map(([regionName, regionBuildings]) => ( +
+

{regionName}

+ + + + Type + Name + Status + Weekly Cost + Capacity + Actions + + + + {regionBuildings.map((building) => { + const definition = definitions.find( + (d) => d.buildingType === building.buildingType + ); + return ( + + + {definition?.name ?? building.buildingType} + {definition?.category && ( + + ({definition.category}) + + )} + + + {building.name || "—"} + + + + {building.status} + + {building.status === "INACTIVE" && ( +

+ Cannot afford operating costs +

+ )} +
+ + {formatCents(building.weeklyOperatingCostCents)} + + + {building.capacitySlots} + {definition?.category === "STORAGE" && ( + + {" "} + (+{definition.storageCapacity} storage) + + )} + + + {building.status === "INACTIVE" && ( + + )} + +
+ ); + })} +
+
+
+ ))} +
+ )} +
+
+ + +
+ ); +} diff --git a/apps/web/src/components/buildings/storage-meter.tsx b/apps/web/src/components/buildings/storage-meter.tsx new file mode 100644 index 00000000..213ca9c1 --- /dev/null +++ b/apps/web/src/components/buildings/storage-meter.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RegionalStorageInfo, getRegionalStorageInfo } from "@/lib/api"; +import { Progress } from "@/components/ui/progress"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertTriangle, AlertCircle } from "lucide-react"; + +interface StorageMeterProps { + companyId: string; + regionId: string; + className?: string; + showDetails?: boolean; +} + +export function StorageMeter({ + companyId, + regionId, + className = "", + showDetails = true +}: StorageMeterProps) { + const [info, setInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const loadInfo = async () => { + setIsLoading(true); + try { + const storageInfo = await getRegionalStorageInfo(companyId, regionId); + if (mounted) { + setInfo(storageInfo); + setError(null); + } + } catch (caught) { + if (mounted) { + setError(caught instanceof Error ? caught.message : "Failed to load storage info"); + } + } finally { + if (mounted) { + setIsLoading(false); + } + } + }; + + void loadInfo(); + + return () => { + mounted = false; + }; + }, [companyId, regionId]); + + if (isLoading) { + return ( +
+
Loading storage info...
+
+ ); + } + + if (error || !info) { + return null; + } + + const percentage = info.usagePercentage; + const warningThreshold = 80; + const criticalThreshold = 95; + const fullThreshold = 100; + + let progressColor = "bg-primary"; + let showWarning = false; + let warningMessage = ""; + let warningIcon = ; + + if (percentage >= fullThreshold) { + progressColor = "bg-destructive"; + showWarning = true; + warningMessage = "Storage is full! Cannot store more items."; + warningIcon = ; + } else if (percentage >= criticalThreshold) { + progressColor = "bg-destructive"; + showWarning = true; + warningMessage = `Storage is ${percentage.toFixed(0)}% full. Critical capacity!`; + } else if (percentage >= warningThreshold) { + progressColor = "bg-yellow-500"; + showWarning = true; + warningMessage = `Storage is ${percentage.toFixed(0)}% full. Consider expanding.`; + } + + return ( +
+ {showDetails && ( +
+ Regional Storage + + {info.currentUsage.toLocaleString()} / {info.maxCapacity.toLocaleString()} + +
+ )} + + + + {showDetails && ( +
+ {percentage.toFixed(1)}% used + {info.warehouseCount > 0 && ( + + {info.warehouseCount} warehouse{info.warehouseCount !== 1 ? "s" : ""} + + )} +
+ )} + + {showWarning && showDetails && ( + = fullThreshold ? "destructive" : "default"} className="mt-2"> +
+ {warningIcon} + {warningMessage} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/logistics/logistics-page.tsx b/apps/web/src/components/logistics/logistics-page.tsx index 4ab5d6b8..62361e40 100644 --- a/apps/web/src/components/logistics/logistics-page.tsx +++ b/apps/web/src/components/logistics/logistics-page.tsx @@ -5,6 +5,7 @@ import { useActiveCompany } from "@/components/company/active-company-provider"; import { ItemLabel } from "@/components/items/item-label"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { useUiSfx } from "@/components/layout/ui-sfx-provider"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -31,6 +32,7 @@ import { import { formatCents } from "@/lib/format"; import { UI_CADENCE_TERMS } from "@/lib/ui-terms"; import { formatCodeLabel, getRegionLabel, UI_COPY } from "@/lib/ui-copy"; +import Link from "next/link"; const SHIPMENT_REFRESH_DEBOUNCE_MS = 600; const SHIPMENT_BASE_FEE_CENTS = Number.parseInt( @@ -714,28 +716,62 @@ export function LogisticsPage() { {showInitialShipmentsSkeleton && pagedInTransit.length === 0 ? : null} - {pagedInTransit.map((shipment) => ( - - - - - - {`${getRegionLabel({ code: shipment.fromRegion.code, name: shipment.fromRegion.name })} -> ${getRegionLabel({ code: shipment.toRegion.code, name: shipment.toRegion.name })}`} - - {shipment.quantity} - {shipment.tickArrives} - - - - - ))} + {pagedInTransit.map((shipment) => { + const isStuck = health?.currentTick !== undefined && shipment.tickArrives < health.currentTick; + return ( + + + + + +
+ + {`${getRegionLabel({ code: shipment.fromRegion.code, name: shipment.fromRegion.name })} -> ${getRegionLabel({ code: shipment.toRegion.code, name: shipment.toRegion.name })}`} + + {isStuck ? ( + + Stuck + + ) : null} +
+
+ {shipment.quantity} + {shipment.tickArrives} + + {isStuck ? ( +
+ + + + +
+ ) : ( + + )} +
+
+ ); + })} {!showInitialShipmentsSkeleton && pagedInTransit.length === 0 ? ( diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 00000000..04d5163a --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription +}; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 00000000..6ac6d6aa --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx new file mode 100644 index 00000000..12e3e72b --- /dev/null +++ b/apps/web/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + indicatorClassName?: string; + } +>(({ className, value, indicatorClassName, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/apps/web/src/lib/api-parsers.ts b/apps/web/src/lib/api-parsers.ts index 0c584d44..6fa7ed25 100644 --- a/apps/web/src/lib/api-parsers.ts +++ b/apps/web/src/lib/api-parsers.ts @@ -1,4 +1,9 @@ import type { + BuildingCategory, + BuildingRecord, + BuildingStatus, + BuildingType, + BuildingTypeDefinition, CompanySpecialization, CompanySpecializationOption, DatabaseSchemaReadiness, @@ -25,14 +30,18 @@ import type { PlayerRegistryCompany, PlayerRegistryEntry, PlayerRegistryItemHolding, + PreflightValidationResult, + ProductionCapacityInfo, ProductionJob, ProductionJobStatus, ProductionRecipe, + RegionalStorageInfo, RegionSummary, ResearchJob, ResearchNode, ResearchNodeStatus, ShipmentRecord, + ValidationIssue, WorkforceCapacityChangeResult, WorldHealth, WorldTickState @@ -905,3 +914,118 @@ export function parseWorkforceCapacityChangeResult( }; } +export function parseBuildingRecord(value: unknown): BuildingRecord { + if (!isRecord(value)) { + throw new Error("Invalid building record payload"); + } + + if (!isRecord(value.region)) { + throw new Error("Invalid building region payload"); + } + + return { + id: readString(value.id, "id"), + companyId: readString(value.companyId, "companyId"), + regionId: readString(value.regionId, "regionId"), + buildingType: readString(value.buildingType, "buildingType") as BuildingType, + status: readString(value.status, "status") as BuildingStatus, + name: readNullableString(value.name, "name"), + acquisitionCostCents: readString(value.acquisitionCostCents, "acquisitionCostCents"), + weeklyOperatingCostCents: readString( + value.weeklyOperatingCostCents, + "weeklyOperatingCostCents" + ), + capacitySlots: readNumber(value.capacitySlots, "capacitySlots"), + tickAcquired: readNumber(value.tickAcquired, "tickAcquired"), + tickConstructionCompletes: + value.tickConstructionCompletes === null + ? null + : readNumber(value.tickConstructionCompletes, "tickConstructionCompletes"), + lastOperatingCostTick: + value.lastOperatingCostTick === null + ? null + : readNumber(value.lastOperatingCostTick, "lastOperatingCostTick"), + createdAt: readString(value.createdAt, "createdAt"), + updatedAt: readString(value.updatedAt, "updatedAt"), + region: { + id: readString(value.region.id, "region.id"), + code: readString(value.region.code, "region.code"), + name: readString(value.region.name, "region.name") + } + }; +} + +export function parseRegionalStorageInfo(value: unknown): RegionalStorageInfo { + if (!isRecord(value)) { + throw new Error("Invalid regional storage info payload"); + } + + return { + companyId: readString(value.companyId, "companyId"), + regionId: readString(value.regionId, "regionId"), + currentUsage: readNumber(value.currentUsage, "currentUsage"), + maxCapacity: readNumber(value.maxCapacity, "maxCapacity"), + usagePercentage: readNumber(value.usagePercentage, "usagePercentage"), + warehouseCount: readNumber(value.warehouseCount, "warehouseCount") + }; +} + +export function parseProductionCapacityInfo(value: unknown): ProductionCapacityInfo { + if (!isRecord(value)) { + throw new Error("Invalid production capacity info payload"); + } + + return { + companyId: readString(value.companyId, "companyId"), + totalCapacity: readNumber(value.totalCapacity, "totalCapacity"), + usedCapacity: readNumber(value.usedCapacity, "usedCapacity"), + availableCapacity: readNumber(value.availableCapacity, "availableCapacity"), + usagePercentage: readNumber(value.usagePercentage, "usagePercentage") + }; +} + +export function parseValidationIssue(value: unknown): ValidationIssue { + if (!isRecord(value)) { + throw new Error("Invalid validation issue payload"); + } + + return { + code: readString(value.code, "code"), + message: readString(value.message, "message"), + severity: readString(value.severity, "severity") as "ERROR" | "WARNING" + }; +} + +export function parsePreflightValidationResult(value: unknown): PreflightValidationResult { + if (!isRecord(value)) { + throw new Error("Invalid preflight validation result payload"); + } + + return { + valid: readBoolean(value.valid, "valid"), + issues: readArray(value.issues, "issues").map(parseValidationIssue) + }; +} + +export function parseBuildingTypeDefinition(value: unknown): BuildingTypeDefinition { + if (!isRecord(value)) { + throw new Error("Invalid building type definition payload"); + } + + return { + buildingType: readString(value.buildingType, "buildingType") as BuildingType, + category: readString(value.category, "category") as BuildingCategory, + name: readString(value.name, "name"), + description: readString(value.description, "description"), + acquisitionCostCents: readString(value.acquisitionCostCents, "acquisitionCostCents"), + weeklyOperatingCostCents: readString( + value.weeklyOperatingCostCents, + "weeklyOperatingCostCents" + ), + capacitySlots: readNumber(value.capacitySlots, "capacitySlots"), + storageCapacity: + value.storageCapacity === undefined + ? undefined + : readNumber(value.storageCapacity, "storageCapacity") + }; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index d09e5a78..00a6f343 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,8 @@ import type { + AcquireBuildingInput, + BuildingListFilters, + BuildingRecord, + BuildingTypeDefinition, CompanySpecialization, CompanySpecializationOption, CompanyWorkforce, @@ -27,9 +31,12 @@ import type { PlaceMarketOrderInput, PlayerIdentity, PlayerRegistryEntry, + PreflightValidationResult, + ProductionCapacityInfo, ProductionJob, ProductionJobFilters, ProductionRecipe, + RegionalStorageInfo, RegionSummary, ResearchJob, ResearchNode, @@ -55,6 +62,8 @@ import { invalidateCachedRequestPrefix } from "./request-cache"; import { + parseBuildingRecord, + parseBuildingTypeDefinition, parseCompanyDetails, parseCompanySpecializationOption, parseCompanyWorkforce, @@ -74,8 +83,11 @@ import { parseOnboardingStatus, parsePlayerIdentity, parsePlayerRegistryEntry, + parsePreflightValidationResult, + parseProductionCapacityInfo, parseProductionJob, parseProductionRecipe, + parseRegionalStorageInfo, parseRegionSummary, parseResearchJob, parseResearchNode, @@ -836,3 +848,84 @@ export async function resetWorld(reseed = true): Promise { invalidateResearchCaches(); return result; } + +// Buildings API + +export async function listBuildings(filters: BuildingListFilters): Promise { + const params = new URLSearchParams(); + params.set("companyId", filters.companyId); + if (filters.regionId) { + params.set("regionId", filters.regionId); + } + if (filters.status) { + params.set("status", filters.status); + } + + return fetchJson(`/v1/buildings?${params.toString()}`, (value) => + readArray(value, "buildings").map(parseBuildingRecord) + ); +} + +export async function acquireBuilding(input: AcquireBuildingInput): Promise { + return fetchJson("/v1/buildings/acquire", parseBuildingRecord, { + method: "POST", + body: JSON.stringify(input) + }); +} + +export async function reactivateBuilding(buildingId: string): Promise { + return fetchJson("/v1/buildings/reactivate", parseBuildingRecord, { + method: "POST", + body: JSON.stringify({ buildingId }) + }); +} + +export async function getRegionalStorageInfo( + companyId: string, + regionId: string +): Promise { + const params = new URLSearchParams(); + params.set("companyId", companyId); + params.set("regionId", regionId); + + return fetchJson(`/v1/buildings/storage-info?${params.toString()}`, parseRegionalStorageInfo); +} + +export async function getProductionCapacityInfo( + companyId: string +): Promise { + const params = new URLSearchParams(); + params.set("companyId", companyId); + + return fetchJson(`/v1/buildings/capacity-info?${params.toString()}`, parseProductionCapacityInfo); +} + +export async function preflightProductionJob(input: { + companyId: string; + recipeId: string; + quantity: number; +}): Promise { + return fetchJson("/v1/buildings/preflight/production-job", parsePreflightValidationResult, { + method: "POST", + body: JSON.stringify(input) + }); +} + +export async function preflightBuyOrder(input: { + companyId: string; + regionId: string; + itemId: string; + quantity: number; +}): Promise { + return fetchJson("/v1/buildings/preflight/buy-order", parsePreflightValidationResult, { + method: "POST", + body: JSON.stringify(input) + }); +} + +export async function getBuildingTypeDefinitions(): Promise { + return fetchJson("/v1/buildings/definitions", (value) => + readArray(value, "definitions").map(parseBuildingTypeDefinition) + ); +} + diff --git a/docs/overflow-policy-and-economics.md b/docs/overflow-policy-and-economics.md new file mode 100644 index 00000000..bb2daa22 --- /dev/null +++ b/docs/overflow-policy-and-economics.md @@ -0,0 +1,305 @@ +# Phase 4 & 5 - Overflow Policy & Economic Design + +## Deterministic Overflow Policy + +### Strategy: Hybrid Approach + +**1. Player-Initiated Actions → REJECT AT SOURCE** +- Production job creation +- Market buy orders +- Building acquisition + +**Behavior:** Validation occurs BEFORE transaction. Operation fails with actionable error message. + +**Implementation:** +```typescript +// Production +await validateStorageCapacity(tx, companyId, regionId, netInventoryChange); +// Location: packages/sim/src/services/production.ts:247-252 + +// Market Buy +await validateStorageCapacity(tx, buyOrder.companyId, buyOrder.regionId, match.quantity); +// Location: packages/sim/src/services/market-matching.ts:356-361 +``` + +**2. System-Driven Operations → DELIVERY ROLLBACK** +- Shipment deliveries + +**Behavior:** When destination storage full, delivery rolls back to origin (NOT a new shipment with travel time). + +**Implementation:** +```typescript +try { + await validateStorageCapacity(tx, shipment.companyId, shipment.toRegionId, shipment.quantity); + destinationRegionId = shipment.toRegionId; // Normal delivery +} catch (error) { + if (error instanceof DomainInvariantError && error.message.includes("storage capacity exceeded")) { + destinationRegionId = shipment.fromRegionId; // Rollback to origin + returnedCount += 1; + + // CRITICAL: Validate origin capacity (maintains hard cap invariants) + await validateStorageCapacity(tx, shipment.companyId, shipment.fromRegionId, shipment.quantity); + // If origin also full, delivery fails - player must manage storage better + } +} +// Location: packages/sim/src/services/shipments.ts:713-734 +``` + +**Key Differences from "Return to Sender":** +- This is a ROLLBACK, not a logistics return +- No new shipment record created +- No additional travel time +- Origin capacity IS validated (hard caps maintained) +- May fail if both regions full (acceptable - player error) + +--- + +## Multi-Operation Storage Contention + +### Same-Tick Processing Order + +When multiple operations target the same storage in a single tick: + +1. **Deterministic Order:** All operations processed in deterministic order: `ORDER BY tickArrives ASC, tickCreated ASC` + - Uses tickCreated (deterministic) instead of createdAt (wall-clock time) + - Guarantees same order on replay +2. **Sequential Validation:** Each operation validates against current state +3. **First-Come-First-Served:** Early operations succeed, later ones may fail/rollback + +### Example Scenario + +``` +Tick 100 at Region A (capacity: 1000, current: 900): + 1. Shipment 1 (tickCreated=50) arrives (quantity: 50) → ✅ Delivered (950/1000) + 2. Shipment 2 (tickCreated=51) arrives (quantity: 100) → ❌ Rollback to origin + - Destination full, attempts rollback + - Origin capacity validated - succeeds or fails based on origin state + 3. Production completes (net +80) → ❌ Fails validation (950/1000) +``` + +**Key Insight:** No race conditions possible - everything is sequential within transaction, ordered by deterministic fields. + +--- + +## Soft-Lock Prevention + +### Problem Statement +Without overflow policy, player could be stuck: +- Ship 1000 items to Region B +- Region B only has 500 capacity +- Shipment arrival fails tick processing +- **Game broken - tick cannot advance** + +### Solution +Shipment delivery has three-tier fallback to ensure tick NEVER fails: + +**Tier 1: Normal Delivery (Destination Has Space)** +- ✅ Tick advances +- ✅ Items delivered to destination +- ✅ Shipment status: DELIVERED + +**Tier 2: Rollback (Destination Full, Origin Has Space)** +- ✅ Tick advances +- ✅ Items returned to origin +- ❌ Logistics fee wasted ($250 + $15/unit) +- ❌ Travel time wasted +- ✅ Shipment status: DELIVERED (with rollback) + +**Tier 3: Retry (Both Regions Full)** +- ✅ Tick advances (CRITICAL - never fails) +- ⏸️ Shipment stays IN_TRANSIT +- 🔄 Automatic retry next tick +- ℹ️ Player must clear space in either region +- ✅ Shipment status: IN_TRANSIT (unchanged) + +**Player learns:** +- Check destination capacity before shipping +- Don't overfill origin during transit +- Manage storage proactively to avoid retry loops + +**Hard Cap Invariants Maintained:** +- All regions respect capacity limits +- No storage bypass in any scenario +- Retry mechanism prevents soft-lock without violating invariants + +--- + +## Economic Balance Analysis + +### Building Cost Structure + +| Building | Acquisition | Weekly Cost | Capacity | Category | +|----------|-------------|-------------|----------|----------| +| Workshop | $25,000 | $1,500 | 1 slot | Early | +| Mine | $100,000 | $5,000 | 2 slots | Early | +| Farm | $80,000 | $4,000 | 2 slots | Early | +| Factory | $250,000 | $12,000 | 3 slots | Mid | +| MegaFactory | $1,000,000 | $50,000 | 10 slots | Late | +| Warehouse | $150,000 | $8,000 | +500 items | Storage | +| HQ | $500,000 | $25,000 | 1 slot | Corporate | +| R&D Center | $300,000 | $15,000 | 1 slot | Corporate | + +### Break-Even Analysis + +#### Assumptions +- Player starts with $500,000 seed capital (default) +- Production recipes yield 20-40% margin +- Weekly operating costs must be covered by revenue + +#### Workshop (Recommended Addition) +**Cost:** $25k acquisition + $1.5k/week +**Break-Even:** $1.5k weekly profit = $214/day @ 7 ticks/week +**Use Case:** First building for absolute beginners +**Status:** NOT YET IMPLEMENTED - RECOMMENDED + +#### Mine +**Cost:** $100k acquisition + $5k/week +**Break-Even:** $5k weekly profit = $714/day +**Feasible:** ✅ With basic ore → metal recipe +**Risk:** Moderate - needs consistent production + +#### Farm +**Cost:** $80k acquisition + $4k/week +**Break-Even:** $4k weekly profit = $571/day +**Feasible:** ✅ With agricultural products +**Risk:** Low-moderate + +#### Factory +**Cost:** $250k acquisition + $12k/week +**Break-Even:** $12k weekly profit = $1,714/day +**Feasible:** ⚠️ Requires established operation +**Risk:** High for early game + +#### MegaFactory +**Cost:** $1M acquisition + $50k/week +**Break-Even:** $50k weekly profit = $7,142/day +**Feasible:** ❌ Late game only +**Risk:** **DANGEROUS** if purchased too early - can bankrupt company + +### Early Game Viability Assessment + +**Current State:** ⚠️ **MARGINAL** +- Cheapest building: Mine at $100k (20% of starting capital) +- Must produce $5k profit/week immediately +- No forgiveness for operational errors + +**Recommendation:** Add Workshop tier +```typescript +WORKSHOP: { + category: "PRODUCTION", + name: "Workshop", + description: "Small-scale production facility for beginners", + acquisitionCostCents: "2500000", // $25,000 + weeklyOperatingCostCents: "150000", // $1,500/week + capacitySlots: 1 +} +``` + +### Over-Expansion Risk + +**Scenario:** Player buys MegaFactory too early +- Acquisition: -$1M (leaves $0 if starting capital) +- Week 1 cost: -$50k → **BANKRUPT** (can't pay) +- Building → INACTIVE +- Production halts +- Soft-lock: Can't earn money without production + +**Prevention:** +1. ✅ Preflight validation warns about costs +2. ✅ Building deactivation preserves company (doesn't delete) +3. ✅ Reactivation possible when cash available +4. ⏳ UI warning for "danger zone" acquisitions + +### Storage Lock Scenarios + +**Scenario:** Player fills all storage, can't receive shipments/production +- Current storage: 1000/1000 (no warehouses) +- Shipment arrives: 500 units → Returns to sender +- Production completes: 100 units → **FAILS** (loses inputs) + +**Prevention:** +1. ✅ Storage meter shows capacity in UI +2. ✅ Preflight validation warns before production +3. ✅ Shipments return safely (no loss) +4. ⚠️ Production failure **LOSES INPUTS** - this is harsh +5. **TODO:** Consider returning production inputs to inventory on storage failure + +--- + +## Integration Test Coverage Plan + +### Required Tests (Deferred - Need Database) + +#### 1. Shipment Overflow Return +```typescript +it("returns shipment to sender when destination storage full") +it("delivers shipment successfully when capacity available") +it("handles multiple shipments with mixed outcomes") +``` + +#### 2. Same-Tick Storage Contention +```typescript +it("processes operations in deterministic order") +it("allows first operation, rejects second when total exceeds capacity") +``` + +#### 3. Building Deactivation During Production +```typescript +it("allows running jobs to complete after building deactivates") +it("prevents new jobs when all buildings inactive") +it("reactivates building and allows new jobs") +``` + +#### 4. Acquisition at Cash Boundary +```typescript +it("allows acquisition when exactly at acquisition cost") +it("rejects acquisition when $1 short") +it("respects reserved cash during acquisition") +``` + +#### 5. Bankruptcy Risk +```typescript +it("deactivates buildings when operating cost exceeds available cash") +it("preserves company when all buildings deactivated") +it("allows recovery via market sales or contracts") +``` + +--- + +## Production-Grade Checklist + +- [x] Deterministic overflow policy defined +- [x] Shipment return-to-sender implemented +- [x] Documentation comprehensive +- [x] All unit tests passing (64/64) +- [ ] Integration tests implemented (requires DB setup) +- [ ] Workshop tier added to building definitions +- [ ] Economic balance validated with real gameplay +- [ ] Production failure input recovery (design decision needed) +- [ ] Player-facing overflow documentation +- [ ] UI warnings for danger zone operations + +--- + +## Open Design Questions + +### 1. Production Failure Behavior +**Current:** Inputs consumed, no output (storage full) +**Alternative:** Return inputs to inventory, cancel job +**Decision:** **Reviewer input needed** + +### 2. Workshop Tier Priority +**Recommendation:** High priority +**Rationale:** Early game currently too harsh +**Decision:** **Approve for implementation?** + +### 3. Automatic Building Reactivation +**Current:** Manual only +**Alternative:** Auto-reactivate when cash available +**Decision:** **Keep manual for player control** + +--- + +**Document Version:** 1.0 +**Last Updated:** 2026-02-19 +**Status:** Awaiting Reviewer Feedback diff --git a/packages/db/prisma/migrations/20260219133500_add_workshop_building_type/migration.sql b/packages/db/prisma/migrations/20260219133500_add_workshop_building_type/migration.sql new file mode 100644 index 00000000..48b2265d --- /dev/null +++ b/packages/db/prisma/migrations/20260219133500_add_workshop_building_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "BuildingType" ADD VALUE 'WORKSHOP'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b640fe77..6902eef1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -77,6 +77,7 @@ enum LedgerEntryType { } enum BuildingType { + WORKSHOP MINE FARM FACTORY diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts index 519d3227..260b9048 100644 --- a/packages/shared/src/api-types.ts +++ b/packages/shared/src/api-types.ts @@ -557,3 +557,91 @@ export interface WorkforceCapacityChangeResult { workforceCapacity: number; orgEfficiencyBps: number; } + +export type BuildingType = + | "WORKSHOP" + | "MINE" + | "FARM" + | "FACTORY" + | "MEGA_FACTORY" + | "WAREHOUSE" + | "HEADQUARTERS" + | "RND_CENTER"; + +export type BuildingStatus = "ACTIVE" | "INACTIVE" | "CONSTRUCTION"; + +export type BuildingCategory = "PRODUCTION" | "STORAGE" | "CORPORATE"; + +export interface BuildingRecord { + id: string; + companyId: string; + regionId: string; + buildingType: BuildingType; + status: BuildingStatus; + name: string | null; + acquisitionCostCents: string; + weeklyOperatingCostCents: string; + capacitySlots: number; + tickAcquired: number; + tickConstructionCompletes: number | null; + lastOperatingCostTick: number | null; + createdAt: string; + updatedAt: string; + region: { + id: string; + code: string; + name: string; + }; +} + +export interface BuildingListFilters { + companyId: string; + regionId?: string; + status?: BuildingStatus; +} + +export interface AcquireBuildingInput { + companyId: string; + regionId: string; + buildingType: BuildingType; + name?: string; +} + +export interface RegionalStorageInfo { + companyId: string; + regionId: string; + currentUsage: number; + maxCapacity: number; + usagePercentage: number; + warehouseCount: number; +} + +export interface ProductionCapacityInfo { + companyId: string; + totalCapacity: number; + usedCapacity: number; + availableCapacity: number; + usagePercentage: number; +} + +export interface ValidationIssue { + code: string; + message: string; + severity: "ERROR" | "WARNING"; +} + +export interface PreflightValidationResult { + valid: boolean; + issues: ValidationIssue[]; +} + +export interface BuildingTypeDefinition { + buildingType: BuildingType; + category: BuildingCategory; + name: string; + description: string; + acquisitionCostCents: string; + weeklyOperatingCostCents: string; + capacitySlots: number; + storageCapacity?: number; +} diff --git a/packages/sim/src/services/buildings.ts b/packages/sim/src/services/buildings.ts index 96fa98a6..03ad47e3 100644 --- a/packages/sim/src/services/buildings.ts +++ b/packages/sim/src/services/buildings.ts @@ -16,7 +16,7 @@ * 4. **Reactivation**: When cash is available, building can be manually or automatically reactivated * * ## Building Types and Categories - * - **PRODUCTION**: MINE, FARM, FACTORY, MEGA_FACTORY + * - **PRODUCTION**: WORKSHOP, MINE, FARM, FACTORY, MEGA_FACTORY * - Provide production job capacity * - Required for production jobs * - Have capacity slots limiting concurrent jobs @@ -86,6 +86,17 @@ import { } from "../domain/errors"; import { availableCash } from "../domain/reservations"; +/** + * Production building types that provide manufacturing capacity + */ +export const PRODUCTION_BUILDING_TYPES: BuildingType[] = [ + BuildingType.WORKSHOP, + BuildingType.MINE, + BuildingType.FARM, + BuildingType.FACTORY, + BuildingType.MEGA_FACTORY +]; + /** * Input for acquiring a new building */ @@ -496,17 +507,10 @@ export async function getProductionCapacityForCompany( tx: Prisma.TransactionClient | PrismaClient, companyId: string ): Promise<{ totalCapacity: number; usedCapacity: number }> { - const productionBuildingTypes = [ - BuildingType.MINE, - BuildingType.FARM, - BuildingType.FACTORY, - BuildingType.MEGA_FACTORY - ]; - const buildings = await tx.building.findMany({ where: { companyId, - buildingType: { in: productionBuildingTypes }, + buildingType: { in: PRODUCTION_BUILDING_TYPES }, status: BuildingStatus.ACTIVE }, select: { @@ -627,7 +631,7 @@ export async function validateStorageCapacity( * @throws {DomainInvariantError} If company has no active production buildings * * @remarks - * - Production buildings include: MINE, FARM, FACTORY, MEGA_FACTORY + * - Production buildings include: WORKSHOP, MINE, FARM, FACTORY, MEGA_FACTORY * - Only ACTIVE buildings are counted * - Must be called before creating production jobs */ @@ -639,17 +643,10 @@ export async function validateProductionBuildingAvailable( throw new DomainInvariantError("companyId is required"); } - const productionBuildingTypes = [ - BuildingType.MINE, - BuildingType.FARM, - BuildingType.FACTORY, - BuildingType.MEGA_FACTORY - ]; - const activeBuildingCount = await tx.building.count({ where: { companyId, - buildingType: { in: productionBuildingTypes }, + buildingType: { in: PRODUCTION_BUILDING_TYPES }, status: BuildingStatus.ACTIVE } }); diff --git a/packages/sim/src/services/shipments.ts b/packages/sim/src/services/shipments.ts index 92a0d723..b1bd4300 100644 --- a/packages/sim/src/services/shipments.ts +++ b/packages/sim/src/services/shipments.ts @@ -22,6 +22,40 @@ * 3. **Delivery**: At arrival tick, `deliverDueShipmentsForTick()` transfers inventory to destination * 4. **Cancellation** (optional): Player can cancel in-transit shipments to recover inventory * + * ## DETERMINISTIC OVERFLOW POLICY + * **Storage Full at Destination → Delivery Rollback or Retry** + * + * When a shipment arrives but destination storage is full: + * + * **Case 1: Origin has capacity (typical)** + * - Shipment status: DELIVERED (completed with rollback) + * - Inventory action: ROLLBACK - items returned to origin region (fromRegionId) + * - This is NOT a new shipment with transit time - it's an immediate rollback + * - Origin capacity validation: YES - maintains hard cap invariants + * - Player consequence: Wasted logistics fee, items back at origin + * + * **Case 2: Both regions full (edge case)** + * - Shipment status: REMAINS IN_TRANSIT (not updated) + * - Inventory action: NONE - no delivery attempted + * - Next tick: Shipment will be retried automatically + * - Tick advancement: NEVER FAILS - continues deterministically + * - Player must: Clear space in either region for delivery to succeed + * + * **Critical Guarantee: Tick Never Fails** + * If both destination and origin are full, the shipment remains IN_TRANSIT and will + * retry next tick. This ensures tick advancement NEVER fails due to storage overflow, + * preventing game-breaking soft-locks. The shipment will automatically deliver once + * the player clears space in either region. + * + * **Rationale for retry vs throw:** + * Shipment delivery happens during tick processing (system-driven, not player action). + * Throwing an error would block tick advancement, creating a soft-lock. By keeping + * the shipment IN_TRANSIT and retrying next tick, we maintain determinism while + * giving the player time to manage storage. + * + * **Atomicity:** Rollback happens in same transaction as delivery attempt + * **Idempotency:** Shipment update uses optimistic locking (updateMany with status condition) + * * ## Invariants Enforced * - Item must be unlocked by research (player companies) or specialization constraints * - Cannot ship to same region (source != destination) @@ -29,12 +63,17 @@ * - Must have available inventory (quantity - reserved) and cash for fees * - Cancellation only allowed for IN_TRANSIT status * - Destination inventory created or incremented atomically on delivery + * - **Overflow invariant**: Delivery never exceeds destination capacity (rollback or retry) + * - **Hard capacity caps**: All regions respect capacity limits (no bypass) + * - **Tick safety**: Tick advancement never fails due to storage (retry on double-full) * * ## Side Effects and Transaction Boundaries * All operations are atomic (prisma.$transaction): * - **createShipment**: Decrements inventory + cash (two optimistic locks), creates shipment record, * logs fee ledger entry - * - **deliverDueShipmentsForTick**: Updates shipment status, upserts destination inventory + * - **deliverDueShipmentsForTick**: Updates shipment status to DELIVERED (if delivery succeeds), + * upserts destination inventory OR rolls back to origin (if origin has capacity) OR leaves + * IN_TRANSIT for retry (if both regions full - ensures tick never fails) * - **cancelShipment**: Returns inventory to source, updates shipment status to CANCELLED * * ## Deterministic Travel Time @@ -45,20 +84,25 @@ * - **Arrival Tick**: `currentTick + adjustedDuration` * * ## Delivery Processing - * - Deterministic order: `ORDER BY tickArrives ASC, createdAt ASC` + * - Deterministic order: `ORDER BY tickArrives ASC, tickCreated ASC` * - Batch processing for efficiency (all due shipments in single tick) * - Atomic inventory transfer (no partial deliveries) + * - Overflow handling: Rollback to origin if destination full (validates origin capacity) + * - Retry mechanism: If both regions full, shipment stays IN_TRANSIT (tick never fails) + * - Idempotency: updateMany prevents double-processing on retry * * ## Fee Structure * - **Base Fee**: Fixed cost per shipment (configured) * - **Unit Fee**: Cost per item shipped (configured) * - **Total**: `baseFee + (quantity * feePerUnit)` - * - Fees are non-refundable even on cancellation + * - Fees are non-refundable even on cancellation or overflow return + * - **NO additional ledger entry on return** - fee already recorded at shipment creation * * ## Error Handling * - NotFoundError: Shipment, company, region, or item doesn't exist * - DomainInvariantError: Same-region shipment, insufficient inventory/cash, item locked * - OptimisticLockConflictError: Concurrent modification detected (retry required) + * - **No errors on overflow**: Returns to sender deterministically */ import { LedgerEntryType, @@ -632,7 +676,7 @@ export async function cancelShipmentWithTx( export async function deliverDueShipmentsForTick( tx: Prisma.TransactionClient, tick: number -): Promise { +): Promise<{ deliveredCount: number; returnedCount: number }> { ensureTick(tick, "tick"); const dueShipments = await tx.shipment.findMany({ @@ -642,55 +686,92 @@ export async function deliverDueShipmentsForTick( lte: tick } }, - orderBy: [{ tickArrives: "asc" }, { createdAt: "asc" }], + orderBy: [{ tickArrives: "asc" }, { tickCreated: "asc" }], select: { id: true, companyId: true, fromRegionId: true, toRegionId: true, itemId: true, - quantity: true + quantity: true, + tickCreated: true } }); let deliveredCount = 0; + let returnedCount = 0; for (const shipment of dueShipments) { - const updated = await tx.shipment.updateMany({ - where: { - id: shipment.id, - status: ShipmentStatus.IN_TRANSIT - }, - data: { - status: ShipmentStatus.DELIVERED, - tickClosed: tick + // Check if destination has storage capacity + // If not, attempt rollback delivery to origin (overflow policy: immediate rollback) + let destinationRegionId = shipment.toRegionId; + let deliveredToDestination = true; + let shouldDeliver = true; + + try { + await validateStorageCapacity( + tx, + shipment.companyId, + shipment.toRegionId, + shipment.quantity + ); + } catch (error) { + // Storage full at destination - attempt rollback to origin + if (error instanceof DomainInvariantError && error.message.includes("storage capacity exceeded")) { + // Try to rollback to origin + try { + await validateStorageCapacity( + tx, + shipment.companyId, + shipment.fromRegionId, + shipment.quantity + ); + // Origin has capacity - rollback succeeds + destinationRegionId = shipment.fromRegionId; + deliveredToDestination = false; + returnedCount += 1; + } catch { + // Both destination AND origin are full - cannot deliver + // Keep shipment IN_TRANSIT, do not update status, will retry next tick + // This ensures tick advancement never fails due to storage overflow + shouldDeliver = false; + } + } else { + throw error; } - }); - - if (updated.count !== 1) { - continue; } - // Validate storage capacity before adding delivered inventory - await validateStorageCapacity( - tx, - shipment.companyId, - shipment.toRegionId, - shipment.quantity - ); + // Only proceed with delivery if we have capacity somewhere + if (shouldDeliver) { + // Update shipment status to DELIVERED (with optimistic locking) + const updated = await tx.shipment.updateMany({ + where: { + id: shipment.id, + status: ShipmentStatus.IN_TRANSIT + }, + data: { + status: ShipmentStatus.DELIVERED, + tickClosed: tick + } + }); - await tx.inventory.upsert({ + if (updated.count !== 1) { + // Another process already updated this shipment, skip + continue; + } + + await tx.inventory.upsert({ where: { companyId_itemId_regionId: { companyId: shipment.companyId, itemId: shipment.itemId, - regionId: shipment.toRegionId + regionId: destinationRegionId } }, create: { companyId: shipment.companyId, itemId: shipment.itemId, - regionId: shipment.toRegionId, + regionId: destinationRegionId, quantity: shipment.quantity, reservedQuantity: 0 }, @@ -701,8 +782,12 @@ export async function deliverDueShipmentsForTick( } }); - deliveredCount += 1; + if (deliveredToDestination) { + deliveredCount += 1; + } + } + // If shouldDeliver is false, shipment remains IN_TRANSIT and will be retried next tick } - return deliveredCount; + return { deliveredCount, returnedCount }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73554902..b1800120 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,9 +117,18 @@ importers: '@corpsim/shared': specifier: workspace:* version: link:../../packages/shared + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.1.4 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: ^2.1.2 version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -669,89 +678,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -909,24 +934,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.12': resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.12': resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.12': resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.12': resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} @@ -1065,6 +1094,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -1131,6 +1169,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.15': resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} peerDependencies: @@ -1209,6 +1260,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.8': + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -1362,66 +1426,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1639,41 +1716,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4482,19 +4567,19 @@ snapshots: '@babel/preset-react': 7.28.5(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@clack/prompts': 0.11.0 '@mrleebo/prisma-ast': 0.13.1 '@prisma/client': 5.22.0(prisma@6.19.2(typescript@5.9.3)) '@types/pg': 8.16.0 - better-auth: 1.4.18(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)) + better-auth: 1.4.18(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)) better-sqlite3: 12.6.2 c12: 3.3.3 chalk: 5.6.2 commander: 12.1.0 dotenv: 17.3.1 - drizzle-orm: 0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)) + drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)) open: 10.2.0 pg: 8.18.0 prettier: 3.8.1 @@ -4571,6 +4656,12 @@ snapshots: zod: 4.3.6 '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 @@ -5089,6 +5180,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@radix-ui/react-context@1.1.3(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5154,6 +5251,15 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5233,6 +5339,16 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.1 @@ -5835,10 +5951,10 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)): + better-auth@1.4.18(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -5852,7 +5968,7 @@ snapshots: optionalDependencies: '@prisma/client': 5.22.0(prisma@6.19.2(typescript@5.9.3)) better-sqlite3: 12.6.2 - drizzle-orm: 0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)) + drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)) next: 15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) pg: 8.18.0 prisma: 6.19.2(typescript@5.9.3) @@ -5887,8 +6003,8 @@ snapshots: better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)): dependencies: - '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -6313,6 +6429,15 @@ snapshots: dotenv@17.3.1: {} + drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)): + optionalDependencies: + '@prisma/client': 5.22.0(prisma@6.19.2(typescript@5.9.3)) + '@types/pg': 8.16.0 + better-sqlite3: 12.6.2 + kysely: 0.28.11 + pg: 8.18.0 + prisma: 6.19.2(typescript@5.9.3) + drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)): optionalDependencies: '@prisma/client': 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) @@ -6321,6 +6446,7 @@ snapshots: kysely: 0.28.11 pg: 8.18.0 prisma: 6.19.2(typescript@5.9.3) + optional: true dunder-proto@1.0.1: dependencies: From 37800c6163f27c457c4f23892fd5b0af869cc926 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 16:28:39 +0100 Subject: [PATCH 07/52] fix(auth): support single-origin sso routing --- .env.example | 4 + ...22153300-single-origin-sso-auth-routing.md | 10 ++ apps/api/src/lib/auth.ts | 23 +++- apps/web/app/api/auth/[[...path]]/route.ts | 119 ++++++++++++++++++ apps/web/app/v1/[...path]/route.ts | 5 +- apps/web/src/lib/api-client.ts | 22 +++- apps/web/src/lib/auth-client.ts | 40 ++++-- docs/project/DOKPLOY_DOCKERFILE.md | 39 ++++-- docs/project/DOKPLOY_PREVIEW.md | 24 ++-- docs/project/corpsim.altitude.nginx.conf | 11 +- 10 files changed, 241 insertions(+), 56 deletions(-) create mode 100644 .releases/unreleased/20260222153300-single-origin-sso-auth-routing.md create mode 100644 apps/web/app/api/auth/[[...path]]/route.ts diff --git a/.env.example b/.env.example index adb9dcee..92c0ea06 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,10 @@ COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4 ######################################## # Frontend (Next.js public vars) ######################################## +NEXT_PUBLIC_APP_URL=http://localhost:4311 +# Optional explicit auth origin for Better Auth client requests. +# Leave blank to use same-origin routing. +NEXT_PUBLIC_AUTH_URL= NEXT_PUBLIC_API_URL=http://localhost:4310 NEXT_PUBLIC_APP_NAME=CorpSim NEXT_PUBLIC_COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4 diff --git a/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md b/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md new file mode 100644 index 00000000..62ee7836 --- /dev/null +++ b/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md @@ -0,0 +1,10 @@ +--- +type: patch +area: web, api +summary: Support single-origin SSO by proxying auth routes and preferring web origin for auth base URL +--- + +- Added Next.js proxy routing for `/api/auth/*` to the API upstream so auth can run on the web origin. +- Updated Better Auth base URL precedence to prefer explicit/web origins before internal API URLs. +- Hardened web API upstream resolution to avoid accidental proxy loops when public URLs point to the web origin. +- Updated deployment docs and env examples for single-domain SSO setup. diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 06fc5bcb..657ab634 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -476,6 +476,7 @@ async function ensurePlayerExistsForAuthUser(user: { function resolveTrustedOrigins(): string[] { const sources = [ + process.env.BETTER_AUTH_URL, process.env.CORS_ORIGIN, process.env.APP_URL, process.env.WEB_URL, @@ -497,14 +498,24 @@ function resolveTrustedOrigins(): string[] { return Array.from(resolved); } +function trimTrailingSlash(value: string): string { + return value.endsWith("/") ? value.slice(0, -1) : value; +} + function resolveAuthBaseUrl(): string { - const explicit = - process.env.BETTER_AUTH_URL?.trim() || - process.env.API_URL?.trim() || - process.env.APP_URL?.trim(); + const sources = [ + process.env.BETTER_AUTH_URL, + process.env.APP_URL, + process.env.WEB_URL, + process.env.NEXT_PUBLIC_APP_URL, + process.env.API_URL + ]; - if (explicit) { - return explicit; + for (const source of sources) { + const explicit = source?.trim(); + if (explicit) { + return trimTrailingSlash(explicit); + } } const apiPort = process.env.API_PORT?.trim() || process.env.PORT?.trim() || "4310"; diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts new file mode 100644 index 00000000..dc5db440 --- /dev/null +++ b/apps/web/app/api/auth/[[...path]]/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; + +type RouteContext = { + params: Promise<{ + path?: string[]; + }>; +}; + +const REQUEST_BLOCKED_HEADERS = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +const RESPONSE_BLOCKED_HEADERS = new Set([ + "connection", + "content-encoding", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +function sanitizeHeaders(source: Headers, blocked: Set): Headers { + const target = new Headers(); + source.forEach((value, key) => { + if (!blocked.has(key.toLowerCase())) { + target.set(key, value); + } + }); + return target; +} + +function resolveApiUpstreamBaseUrl(): string { + const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim(); + + if (explicit) { + return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit; + } + + const port = process.env.API_PORT?.trim() || "4310"; + return `http://127.0.0.1:${port}`; +} + +async function proxyToApi(request: NextRequest, context: RouteContext): Promise { + const { path } = await context.params; + const suffix = path && path.length > 0 ? `/${path.join("/")}` : ""; + const upstreamUrl = new URL(`${resolveApiUpstreamBaseUrl()}/api/auth${suffix}`); + upstreamUrl.search = request.nextUrl.search; + + const headers = sanitizeHeaders(request.headers, REQUEST_BLOCKED_HEADERS); + const method = request.method.toUpperCase(); + + let body: ArrayBuffer | undefined; + if (method !== "GET" && method !== "HEAD") { + const buffer = await request.arrayBuffer(); + body = buffer.byteLength > 0 ? buffer : undefined; + } + + try { + const upstreamResponse = await fetch(upstreamUrl, { + method, + headers, + body, + cache: "no-store", + redirect: "manual" + }); + + const responseHeaders = sanitizeHeaders(upstreamResponse.headers, RESPONSE_BLOCKED_HEADERS); + return new NextResponse(upstreamResponse.body, { + status: upstreamResponse.status, + headers: responseHeaders + }); + } catch { + return NextResponse.json({ message: "Auth upstream is unreachable." }, { status: 502 }); + } +} + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function POST(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function PUT(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function PATCH(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function OPTIONS(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function HEAD(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts index 11153032..d09d86b0 100644 --- a/apps/web/app/v1/[...path]/route.ts +++ b/apps/web/app/v1/[...path]/route.ts @@ -43,10 +43,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers { } function resolveApiUpstreamBaseUrl(): string { - const explicit = - process.env.API_URL?.trim() || - process.env.API_INTERNAL_URL?.trim() || - process.env.NEXT_PUBLIC_API_URL?.trim(); + const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim(); if (explicit) { return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit; diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index f477d874..4548eb64 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -9,17 +9,27 @@ if (typeof window !== "undefined") { const currentHost = window.location.hostname; const isProduction = !isLocalhostHostname(currentHost); - if (isProduction && !API_BASE_URL) { - console.warn( - "[CorpSim API Client] NEXT_PUBLIC_API_URL is not set. API requests may fail. " + - "Ensure NEXT_PUBLIC_API_URL is set as a build argument when building the Docker image." - ); - } else if (isProduction && isLocalhostUrl(API_BASE_URL)) { + if (isProduction && API_BASE_URL && isLocalhostUrl(API_BASE_URL)) { console.warn( `[CorpSim API Client] NEXT_PUBLIC_API_URL is set to "${API_BASE_URL}" but you're accessing from "${currentHost}". ` + "This likely means the environment variable was not set as a build argument. " + "Set NEXT_PUBLIC_API_URL as a build argument in your deployment platform and rebuild." ); + } else if (isProduction && API_BASE_URL) { + try { + const apiHost = new URL(API_BASE_URL).hostname; + if (apiHost !== currentHost) { + console.warn( + `[CorpSim API Client] API base URL host "${apiHost}" differs from current host "${currentHost}". ` + + "For single-domain SSO deployments, prefer same-origin API routing." + ); + } + } catch { + console.warn( + `[CorpSim API Client] NEXT_PUBLIC_API_URL is set to "${API_BASE_URL}" but is not a valid absolute URL. ` + + "Use an absolute HTTPS URL, or leave it empty to use same-origin routing." + ); + } } } diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index a8b2503c..fa53e1c3 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -5,7 +5,11 @@ import { adminClient, twoFactorClient, usernameClient } from "better-auth/client import { isLocalhostHostname, isLocalhostUrl } from "./localhost-utils"; function resolveAuthBaseUrl(): string { - const raw = process.env.NEXT_PUBLIC_API_URL?.trim() ?? ""; + const raw = + process.env.NEXT_PUBLIC_AUTH_URL?.trim() || + process.env.NEXT_PUBLIC_APP_URL?.trim() || + process.env.NEXT_PUBLIC_API_URL?.trim() || + ""; if (!raw) { return ""; } @@ -17,7 +21,10 @@ function validateAuthConfiguration(): void { return; } - const apiUrl = process.env.NEXT_PUBLIC_API_URL?.trim(); + const authUrl = + process.env.NEXT_PUBLIC_AUTH_URL?.trim() || + process.env.NEXT_PUBLIC_APP_URL?.trim() || + process.env.NEXT_PUBLIC_API_URL?.trim(); const currentHost = window.location.hostname; // Skip validation in development @@ -25,21 +32,32 @@ function validateAuthConfiguration(): void { return; } - // Check if API URL is missing or pointing to localhost in production - if (!apiUrl) { + if (!authUrl) { + return; + } + + if (isLocalhostUrl(authUrl)) { console.warn( - "[CorpSim Auth] NEXT_PUBLIC_API_URL is not set. Authentication will fail. " + - "Ensure NEXT_PUBLIC_API_URL is set as a build argument when building the Docker image." + `[CorpSim Auth] Auth base URL is set to "${authUrl}" but you're accessing the site from "${currentHost}". ` + + "This likely means the environment variable was not set as a build argument. " + + "Authentication requests may fail. " + + "Set NEXT_PUBLIC_AUTH_URL (or NEXT_PUBLIC_APP_URL) to your deployed app origin and rebuild the image." ); return; } - if (isLocalhostUrl(apiUrl)) { + try { + const authHost = new URL(authUrl).hostname; + if (authHost !== currentHost) { + console.warn( + `[CorpSim Auth] Auth base URL host "${authHost}" differs from current host "${currentHost}". ` + + "For SSO providers that require a single domain, point auth traffic to the same public origin." + ); + } + } catch { console.warn( - `[CorpSim Auth] NEXT_PUBLIC_API_URL is set to "${apiUrl}" but you're accessing the site from "${currentHost}". ` + - "This likely means the environment variable was not set as a build argument. " + - "Authentication requests will fail. " + - "Set NEXT_PUBLIC_API_URL as a build argument in your deployment platform and rebuild the image." + `[CorpSim Auth] Auth base URL "${authUrl}" is not a valid absolute URL. ` + + "Use an absolute HTTPS URL, or leave it empty to use same-origin routing." ); } } diff --git a/docs/project/DOKPLOY_DOCKERFILE.md b/docs/project/DOKPLOY_DOCKERFILE.md index 936640d0..71804d8b 100644 --- a/docs/project/DOKPLOY_DOCKERFILE.md +++ b/docs/project/DOKPLOY_DOCKERFILE.md @@ -15,7 +15,9 @@ Entrypoint: `scripts/start-container.sh` **IMPORTANT:** When deploying the `web` app (or `APP_ROLE=all`), Next.js `NEXT_PUBLIC_*` environment variables are **baked into the JavaScript bundle at build time**, NOT at runtime. In Dokploy, you MUST configure these as **build arguments**: -- `NEXT_PUBLIC_API_URL=https://` +- `NEXT_PUBLIC_APP_URL=https://` +- `NEXT_PUBLIC_AUTH_URL=https://` (optional; leave empty to use same-origin) +- `NEXT_PUBLIC_API_URL=https://` (recommended for single-domain SSO) or leave empty to use same-origin routing Setting these only as runtime environment variables will NOT work for client-side code. The browser will get the values that were present during `docker build`, not the ones set at runtime. @@ -48,10 +50,15 @@ Create separate Dokploy apps from the same repository and Dockerfile: - `WEB_PORT=4311` - expose/public port `4311` - ⚠️ **BUILD ARGUMENTS (set before building the image):** - - `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com` + - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` + - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) + - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) - **RUNTIME ENVIRONMENT VARIABLES:** - - `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com` + - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` + - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) + - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) - `API_URL=http://corpsim-api:4310` (or internal Docker network address) + - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` All runtime apps also need: @@ -64,14 +71,19 @@ All runtime apps also need: If you insist on one container, set: **Build Arguments (must be set before building):** -- `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com` +- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` +- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) +- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) **Runtime Environment Variables:** - `APP_ROLE=all` - `API_PORT=4310` - `WEB_PORT=4311` - `CORS_ORIGIN=https://corpsim.altitude-interactive.com` -- `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com` +- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` +- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) +- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) +- `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` - `API_URL=http://localhost:4310` (internal server-side API access) - `PREVIEW_DATABASE_URL=postgresql://postgres:@postgres:5432/corpsim` - `PREVIEW_REDIS_HOST=redis` @@ -89,8 +101,8 @@ pnpm sim:seed ## Nginx upstream mapping -- `corpsim.altitude-interactive.com` -> `10.7.0.3:4311` -- `corpsim-api.altitude-interactive.com` -> `10.7.0.3:4310` +- `corpsim.altitude-interactive.com` -> `10.7.0.3:4311` (public) +- Keep API private/internal when possible; web route handlers proxy `/v1/*` and `/api/auth/*` to the API upstream. ## Troubleshooting @@ -101,18 +113,21 @@ pnpm sim:seed - 404 errors in browser console for `/api/auth/sign-in/social` - Auth requests going to wrong URL (e.g., `localhost` instead of your domain) -**Cause:** The `NEXT_PUBLIC_API_URL` build argument was not set when building the Docker image. +**Cause:** Public auth/API URLs were built with the wrong origin (often localhost or a separate API host), or missing entirely. **Solution:** 1. In Dokploy, find the Build Arguments or Build Environment section for your app -2. Add `NEXT_PUBLIC_API_URL=https://` as a build argument -3. Trigger a **full rebuild** of the image (not just a restart) -4. After deployment, check browser's network tab to verify requests go to the correct domain +2. Set `NEXT_PUBLIC_APP_URL` (and optionally `NEXT_PUBLIC_AUTH_URL`) to your web domain +3. Set `NEXT_PUBLIC_API_URL` to your web domain (or leave it empty for same-origin routing) +4. Set runtime `BETTER_AUTH_URL` to your web domain +5. Ensure runtime `API_URL` points to the internal API service (not the public web domain) +6. Trigger a **full rebuild** of the image (not just a restart) +7. After deployment, check browser's network tab to verify auth and API requests go to your web domain ### How to verify build-time values are correct 1. Open your web app in a browser 2. Open Developer Tools → Network tab 3. Trigger an API request (e.g., try to sign in) -4. Check the request URL - it should point to your production API domain, not localhost +4. Check request URLs - for single-domain SSO they should point to your web domain (not localhost) 5. Alternatively, check the Console tab for any warnings from CorpSim about misconfigured URLs diff --git a/docs/project/DOKPLOY_PREVIEW.md b/docs/project/DOKPLOY_PREVIEW.md index 4f65489f..57f1aaf6 100644 --- a/docs/project/DOKPLOY_PREVIEW.md +++ b/docs/project/DOKPLOY_PREVIEW.md @@ -28,7 +28,9 @@ You can use local `.env.preview` as the canonical template for these values. 1. Go to your Compose app → **Environment** tab 2. Set runtime environment variables as usual 3. **Additionally**, if Dokploy provides a "Build Arguments" section, set: - - `NEXT_PUBLIC_API_URL=https://` + - `NEXT_PUBLIC_APP_URL=https://` + - `NEXT_PUBLIC_AUTH_URL=https://` (optional) + - `NEXT_PUBLIC_API_URL=https://` (recommended) or leave empty for same-origin routing If Dokploy doesn't have a separate Build Arguments UI, note that variables set only in the **Environment** section are runtime variables and are **not** automatically available at build time. In that case, you must manually edit your `docker-compose.preview.yml` and add these as Docker build arguments (under the `args:` section of `x-app-build` or the relevant `build:` configuration for the `web` service) so that the `NEXT_PUBLIC_*` values are passed into the Next.js build. @@ -45,7 +47,10 @@ If Dokploy doesn't have a separate Build Arguments UI, note that variables set o - `WEB_PORT=4311` - `WEB_PUBLIC_PORT=4311` - `CORS_ORIGIN=https://` -- `NEXT_PUBLIC_API_URL=https://` ⚠️ **Must also be set as build argument** +- `NEXT_PUBLIC_APP_URL=https://` ⚠️ **Must also be set as build argument** +- `NEXT_PUBLIC_AUTH_URL=https://` (optional, also set as build argument) +- `NEXT_PUBLIC_API_URL=https://` (recommended, also set as build argument; can be empty for same-origin) +- `BETTER_AUTH_URL=https://` Release image settings (tag-first): @@ -65,7 +70,7 @@ Recommended worker settings: ## 3) Networking / domains - Route `web` publicly on `WEB_PUBLIC_PORT`. -- Route `api` publicly on `API_PUBLIC_PORT`. +- Prefer keeping `api` internal/private and letting web proxy `/v1/*` and `/api/auth/*` to the API service. - Keep `postgres` and `redis` internal only. ## 4) First deployment init @@ -95,13 +100,16 @@ Do not run `sim:reset` in preview unless you intentionally want to wipe/reseed s - 404 errors in browser console for `/api/auth/sign-in/social` - Auth requests going to wrong URL (e.g., `localhost` instead of your domain) -**Cause:** The `NEXT_PUBLIC_API_URL` build argument was not set correctly when the Docker image was built. Next.js bakes these values into the client-side JavaScript bundle at build time. +**Cause:** Public auth/API URLs were built with the wrong origin (often localhost or a separate API host), or missing entirely. **Solution:** -1. In Dokploy, ensure `NEXT_PUBLIC_API_URL` is set as a **build argument** (check Build Arguments or Build Environment section) -2. Trigger a **full rebuild** (not just a restart) -3. Verify the built image has the correct value by checking browser's network tab - requests should go to your API domain, not localhost +1. In Dokploy, set `NEXT_PUBLIC_APP_URL` (and optionally `NEXT_PUBLIC_AUTH_URL`) as **build arguments** +2. Set `NEXT_PUBLIC_API_URL` to your web domain (or leave it empty to use same-origin routing) +3. Set runtime `BETTER_AUTH_URL` to your web domain +4. Ensure runtime `API_URL` points to your internal API service +5. Trigger a **full rebuild** (not just a restart) +6. Verify in browser devtools that auth/API requests use your web domain, not localhost ### API calls going to localhost or wrong domain -This is the same issue as above - `NEXT_PUBLIC_*` variables must be set as build arguments before building the image. +This is the same issue as above: set correct `NEXT_PUBLIC_*` build arguments and rebuild the image. diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf index d3dd68d6..6dce5404 100644 --- a/docs/project/corpsim.altitude.nginx.conf +++ b/docs/project/corpsim.altitude.nginx.conf @@ -9,7 +9,7 @@ server { listen 80; listen [::]:80; server_name corpsim-api.altitude-interactive.com; - return 301 https://$host$request_uri; + return 301 https://corpsim.altitude-interactive.com$request_uri; } server { @@ -38,12 +38,5 @@ server { ssl_certificate /etc/ssl/cloudflare/altitude_pubkey.pem; ssl_certificate_key /etc/ssl/cloudflare/altitude_privkey.key; - location / { - proxy_pass http://10.7.0.3:4310; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } + return 308 https://corpsim.altitude-interactive.com$request_uri; } From e3313ab9303939d1dbb4e4f93c1e9a4ac4367897 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 16:31:23 +0100 Subject: [PATCH 08/52] fix(web): preserve repeated proxy headers --- apps/web/app/api/auth/[[...path]]/route.ts | 2 +- apps/web/app/v1/[...path]/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts index dc5db440..56acf40f 100644 --- a/apps/web/app/api/auth/[[...path]]/route.ts +++ b/apps/web/app/api/auth/[[...path]]/route.ts @@ -36,7 +36,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers { const target = new Headers(); source.forEach((value, key) => { if (!blocked.has(key.toLowerCase())) { - target.set(key, value); + target.append(key, value); } }); return target; diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts index d09d86b0..d024104e 100644 --- a/apps/web/app/v1/[...path]/route.ts +++ b/apps/web/app/v1/[...path]/route.ts @@ -36,7 +36,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers { const target = new Headers(); source.forEach((value, key) => { if (!blocked.has(key.toLowerCase())) { - target.set(key, value); + target.append(key, value); } }); return target; From aff1f22f1c93be841be485be1d6fdf3ddae3645c Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 16:48:03 +0100 Subject: [PATCH 09/52] fix(ci): apply migrations before APP_ROLE all startup --- .../20260222154900-run-migrations-in-all-role-startup.md | 8 ++++++++ scripts/start-container.sh | 7 +++++++ 2 files changed, 15 insertions(+) create mode 100644 .releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md diff --git a/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md b/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md new file mode 100644 index 00000000..73a77a5d --- /dev/null +++ b/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md @@ -0,0 +1,8 @@ +--- +type: patch +area: ci +summary: Run Prisma migrations before launching services in APP_ROLE=all mode +--- + +- Updated `scripts/start-container.sh` so `APP_ROLE=all` applies `prisma migrate deploy` before starting API, worker, and web. +- Prevents runtime schema-readiness pauses when single-container deployments start without a dedicated migrate step. diff --git a/scripts/start-container.sh b/scripts/start-container.sh index 44ebc5c4..05b58288 100644 --- a/scripts/start-container.sh +++ b/scripts/start-container.sh @@ -32,7 +32,14 @@ run_migrate() { exec pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma } +apply_migrations() { + pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma +} + run_all() { + # Ensure schema is current before starting long-running processes in single-container mode. + apply_migrations + pnpm --filter @corpsim/api start & api_pid=$! From 5cba719401e7fa04f53af4a994f9be8925654fb3 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 16:51:48 +0100 Subject: [PATCH 10/52] fix(web): hide seeded example accounts in admin list --- ...0260222165400-hide-seeded-example-accounts-in-admin.md | 8 ++++++++ apps/web/src/components/admin/admin-page.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md diff --git a/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md b/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md new file mode 100644 index 00000000..feb74fc8 --- /dev/null +++ b/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Hide seeded example.com accounts from admin user listing +--- + +- Filtered admin dashboard user rows to exclude seeded accounts with emails ending in `@example.com`. +- Keeps production/real user account management focused and uncluttered. diff --git a/apps/web/src/components/admin/admin-page.tsx b/apps/web/src/components/admin/admin-page.tsx index f5564426..a0c9b4bb 100644 --- a/apps/web/src/components/admin/admin-page.tsx +++ b/apps/web/src/components/admin/admin-page.tsx @@ -46,6 +46,11 @@ interface SupportAccount { const MAIN_ADMIN_EMAIL = "admin@corpsim.local"; const STALE_IMPORT_TICK_THRESHOLD = 5; + +function isSeededExampleAccount(email: string): boolean { + return email.trim().toLowerCase().endsWith("@example.com"); +} + export function AdminPage() { const [users, setUsers] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -81,7 +86,8 @@ export function AdminPage() { }); if (result.data) { - setUsers(result.data.users as unknown as UserData[]); + const loadedUsers = result.data.users as unknown as UserData[]; + setUsers(loadedUsers.filter((user) => !isSeededExampleAccount(user.email))); } else if (result.error) { showToast({ title: "Failed to load users", From de183944485848a01cde25b025a13a2188efda0c Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:00:16 +0100 Subject: [PATCH 11/52] fix(api): allow admins on developer catalog read endpoints --- ...00-allow-admin-developer-read-endpoints.md | 8 +++++ .../decorators/current-player-id.decorator.ts | 33 +++++++++++++++++ .../test/current-player-id.decorator.test.ts | 36 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 .releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md create mode 100644 apps/api/test/current-player-id.decorator.test.ts diff --git a/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md b/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md new file mode 100644 index 00000000..7f71f9e9 --- /dev/null +++ b/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md @@ -0,0 +1,8 @@ +--- +type: patch +area: api, web +summary: Allow admin accounts to access developer page read endpoints +--- + +- Updated player-id guard logic to permit admin `GET` access on the specific catalog endpoints used by `/developer`. +- Kept admin restrictions in place for write operations and non-allowlisted gameplay endpoints. diff --git a/apps/api/src/common/decorators/current-player-id.decorator.ts b/apps/api/src/common/decorators/current-player-id.decorator.ts index e59f162e..a6b1cb2b 100644 --- a/apps/api/src/common/decorators/current-player-id.decorator.ts +++ b/apps/api/src/common/decorators/current-player-id.decorator.ts @@ -8,6 +8,10 @@ import type { UserSession } from "@thallesp/nestjs-better-auth"; interface RequestWithSession { session?: UserSession | null; + method?: string; + url?: string; + originalUrl?: string; + path?: string; } function isAdminRole(role: string | string[] | null | undefined): boolean { @@ -20,6 +24,32 @@ function isAdminRole(role: string | string[] | null | undefined): boolean { .some((entry) => entry === "admin"); } +const ADMIN_ALLOWED_READ_PATH_PREFIXES = [ + "/v1/companies", + "/v1/items", + "/v1/production/recipes", + "/v1/research" +] as const; + +function resolveRequestPath(request: RequestWithSession): string { + const rawPath = request.originalUrl ?? request.path ?? request.url ?? ""; + const pathWithoutQuery = rawPath.split("?", 1)[0] ?? ""; + const normalized = pathWithoutQuery.trim(); + return normalized.length > 0 ? normalized : "/"; +} + +export function canAdminAccessPlayerGameplayEndpoint(request: RequestWithSession): boolean { + const method = request.method?.trim().toUpperCase(); + if (method !== "GET") { + return false; + } + + const path = resolveRequestPath(request); + return ADMIN_ALLOWED_READ_PATH_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`) + ); +} + function resolveTestFallbackPlayerId(): string | null { if (process.env.NODE_ENV !== "test") { return null; @@ -43,6 +73,9 @@ export const CurrentPlayerId = createParamDecorator( } if (isAdminRole(request.session?.user?.role)) { + if (canAdminAccessPlayerGameplayEndpoint(request)) { + return playerId; + } throw new ForbiddenException("Admin accounts cannot access player gameplay endpoints."); } return playerId; diff --git a/apps/api/test/current-player-id.decorator.test.ts b/apps/api/test/current-player-id.decorator.test.ts new file mode 100644 index 00000000..70be25ed --- /dev/null +++ b/apps/api/test/current-player-id.decorator.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { canAdminAccessPlayerGameplayEndpoint } from "../src/common/decorators/current-player-id.decorator"; + +describe("canAdminAccessPlayerGameplayEndpoint", () => { + it("allows admin GET requests to developer catalog read endpoints", () => { + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/companies" })).toBe(true); + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/items" })).toBe(true); + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/production/recipes" })).toBe( + true + ); + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "GET", + url: "/v1/research?companyId=company_seed" + }) + ).toBe(true); + }); + + it("denies admin access for non-allowlisted paths", () => { + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "GET", + url: "/v1/market/orders" + }) + ).toBe(false); + }); + + it("denies admin access for write methods even on allowlisted paths", () => { + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "POST", + url: "/v1/production/recipes" + }) + ).toBe(false); + }); +}); From 1429d86ee0001f9d202d62349cd8b91752b60943 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:03:46 +0100 Subject: [PATCH 12/52] fix(web): accept redacted company cash in parsers --- ...1500-handle-redacted-company-cash-in-web-parsers.md | 8 ++++++++ apps/web/src/lib/api-parsers.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md diff --git a/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md b/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md new file mode 100644 index 00000000..062742c2 --- /dev/null +++ b/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Accept optional redacted company cash fields in API parsers +--- + +- Updated web API parsers to treat `cashCents` as optional in company summary and player registry company payloads. +- Prevents admin developer page failures when backend redacts non-owned company cash values. diff --git a/apps/web/src/lib/api-parsers.ts b/apps/web/src/lib/api-parsers.ts index 0c584d44..a8ca63fc 100644 --- a/apps/web/src/lib/api-parsers.ts +++ b/apps/web/src/lib/api-parsers.ts @@ -125,13 +125,16 @@ export function parseCompanySummary(value: unknown): CompanySummary { throw new Error("Invalid company item"); } + const cashCents = + value.cashCents === undefined ? undefined : readString(value.cashCents, "cashCents"); + return { id: readString(value.id, "id"), code: readString(value.code, "code"), name: readString(value.name, "name"), isBot: readBoolean(value.isBot, "isBot"), specialization: parseCompanySpecialization(value.specialization), - cashCents: readString(value.cashCents, "cashCents"), + cashCents, regionId: readString(value.regionId, "regionId"), regionCode: readString(value.regionCode, "regionCode"), regionName: readString(value.regionName, "regionName") @@ -236,12 +239,15 @@ export function parsePlayerRegistryCompany(value: unknown): PlayerRegistryCompan throw new Error("Invalid player registry company"); } + const cashCents = + value.cashCents === undefined ? undefined : readString(value.cashCents, "cashCents"); + return { id: readString(value.id, "id"), code: readString(value.code, "code"), name: readString(value.name, "name"), isBot: readBoolean(value.isBot, "isBot"), - cashCents: readString(value.cashCents, "cashCents"), + cashCents, regionId: readString(value.regionId, "regionId"), regionCode: readString(value.regionCode, "regionCode"), regionName: readString(value.regionName, "regionName"), From 685091618591bd435bcb1c15b9cecda373d526b7 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:06:57 +0100 Subject: [PATCH 13/52] fix(api): support admin research catalog on developer page --- ...enable-admin-developer-research-catalog.md | 8 +++ apps/api/src/research/research.controller.ts | 22 ++++++- apps/api/src/research/research.service.ts | 39 +++++++++++ ...research-admin-catalog.integration.test.ts | 64 +++++++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md create mode 100644 apps/api/test/research-admin-catalog.integration.test.ts diff --git a/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md b/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md new file mode 100644 index 00000000..f2fe5ccd --- /dev/null +++ b/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md @@ -0,0 +1,8 @@ +--- +type: patch +area: api, web +summary: Enable admin access to developer research catalog without player ownership +--- + +- Added admin-only research catalog read path that selects a player-owned company when no company ID is provided. +- Kept existing player ownership enforcement for non-admin research access and all research mutations. diff --git a/apps/api/src/research/research.controller.ts b/apps/api/src/research/research.controller.ts index ce87b435..2f1bd171 100644 --- a/apps/api/src/research/research.controller.ts +++ b/apps/api/src/research/research.controller.ts @@ -1,10 +1,25 @@ -import { Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common"; +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from "@nestjs/common"; +import type { UserSession } from "@thallesp/nestjs-better-auth"; import { CurrentPlayerId } from "../common/decorators/current-player-id.decorator"; import { ListResearchDto } from "./dto/list-research.dto"; import { MutateResearchDto } from "./dto/mutate-research.dto"; import { ResearchNodeParamDto } from "./dto/research-node-param.dto"; import { ResearchService } from "./research.service"; +interface RequestWithSession { + session?: UserSession | null; +} + +function isAdminRole(role: string | string[] | null | undefined): boolean { + if (!role) { + return false; + } + const roleValues = Array.isArray(role) ? role : role.split(","); + return roleValues + .map((entry) => entry.trim().toLowerCase()) + .some((entry) => entry === "admin"); +} + @Controller("v1/research") export class ResearchController { private readonly researchService: ResearchService; @@ -16,8 +31,13 @@ export class ResearchController { @Get() async list( @Query() query: ListResearchDto, + @Req() request: RequestWithSession, @CurrentPlayerId() playerId: string ) { + if (isAdminRole(request.session?.user?.role)) { + return this.researchService.listResearchForAdminCatalog(query.companyId); + } + return this.researchService.listResearch(query.companyId, playerId); } diff --git a/apps/api/src/research/research.service.ts b/apps/api/src/research/research.service.ts index 4e74ee32..6c932c4c 100644 --- a/apps/api/src/research/research.service.ts +++ b/apps/api/src/research/research.service.ts @@ -54,6 +54,31 @@ export class ResearchService { this.prisma = prisma; } + private async resolveAdminCatalogCompanyId(companyId?: string): Promise { + if (companyId) { + return companyId; + } + + const firstPlayerCompany = await this.prisma.company.findFirst({ + where: { + isPlayer: true, + ownerPlayerId: { not: null } + }, + orderBy: { + createdAt: "asc" + }, + select: { + id: true + } + }); + + if (!firstPlayerCompany) { + throw new DomainInvariantError("no player-owned companies available for research catalog"); + } + + return firstPlayerCompany.id; + } + private async resolveOwnedCompanyId(playerId: string, companyId?: string): Promise { if (companyId) { await assertCompanyOwnedByPlayer(this.prisma, playerId, companyId); @@ -84,6 +109,20 @@ export class ResearchService { }; } + async listResearchForAdminCatalog( + companyId?: string + ): Promise<{ companyId: string; nodes: ResearchNode[] }> { + const resolvedCompanyId = await this.resolveAdminCatalogCompanyId(companyId); + const nodes = await listResearchForCompany(this.prisma, { + companyId: resolvedCompanyId + }); + + return { + companyId: resolvedCompanyId, + nodes: nodes.map(mapNodeToDto) + }; + } + async startNode( nodeId: string, companyId: string | undefined, diff --git a/apps/api/test/research-admin-catalog.integration.test.ts b/apps/api/test/research-admin-catalog.integration.test.ts new file mode 100644 index 00000000..7a69abdf --- /dev/null +++ b/apps/api/test/research-admin-catalog.integration.test.ts @@ -0,0 +1,64 @@ +import "reflect-metadata"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { seedWorld } from "@corpsim/db"; +import { HttpErrorFilter } from "../src/common/filters/http-error.filter"; +import { AppModule } from "../src/app.module"; +import { PrismaService } from "../src/prisma/prisma.service"; +import { ResearchService } from "../src/research/research.service"; + +describe("research admin catalog", () => { + let app: INestApplication; + let prisma: PrismaService; + let researchService: ResearchService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule] + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + forbidUnknownValues: true, + transform: false, + stopAtFirstError: true + }) + ); + app.useGlobalFilters(new HttpErrorFilter()); + await app.init(); + + prisma = app.get(PrismaService); + researchService = app.get(ResearchService); + }); + + beforeEach(async () => { + await seedWorld(prisma, { reset: true }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("returns a research catalog for admins without requiring player ownership", async () => { + const result = await researchService.listResearchForAdminCatalog(); + + expect(typeof result.companyId).toBe("string"); + expect(result.companyId.length).toBeGreaterThan(0); + expect(result.nodes.length).toBeGreaterThan(0); + + const company = await prisma.company.findUniqueOrThrow({ + where: { id: result.companyId }, + select: { + isPlayer: true, + ownerPlayerId: true + } + }); + + expect(company.isPlayer).toBe(true); + expect(company.ownerPlayerId).not.toBeNull(); + }); +}); From 048339158406c22725020e6150ceee53642d1606 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:11:04 +0100 Subject: [PATCH 14/52] fix(web): separate recipe input items across catalog views --- ...73000-separate-recipe-input-items-in-ui.md | 8 ++++ .../src/components/dev/dev-catalog-page.tsx | 27 +++++------ .../components/items/item-quantity-list.tsx | 46 +++++++++++++++++++ .../components/production/production-page.tsx | 38 +++++++-------- 4 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 .releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md create mode 100644 apps/web/src/components/items/item-quantity-list.tsx diff --git a/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md b/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md new file mode 100644 index 00000000..b4e76f68 --- /dev/null +++ b/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Improve recipe input readability with explicit separators and quantity labels +--- + +- Added a reusable `ItemQuantityList` UI component that renders item inputs with clear separators and `xN` quantities. +- Updated developer and production recipe sections to use the shared list component, preventing concatenated input labels. diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx index 3d2130bd..c61f5173 100644 --- a/apps/web/src/components/dev/dev-catalog-page.tsx +++ b/apps/web/src/components/dev/dev-catalog-page.tsx @@ -3,6 +3,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { getIconCatalogItemByCode } from "@corpsim/shared"; import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -948,19 +949,19 @@ export function DevCatalogPage() { - {recipe.durationTicks} - -
- {recipe.inputs.map((input) => ( -

- {input.quantityPerRun} - -

- ))} -
-
- - ))} + {recipe.durationTicks} + + ({ + key: `${recipe.id}-${input.itemId}`, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + /> + + + ))} {pagedRecipes.length === 0 ? ( diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx new file mode 100644 index 00000000..3fbcad29 --- /dev/null +++ b/apps/web/src/components/items/item-quantity-list.tsx @@ -0,0 +1,46 @@ +import { Fragment } from "react"; +import { ItemLabel } from "@/components/items/item-label"; +import { cn } from "@/lib/utils"; + +export interface ItemQuantityListEntry { + key: string; + quantity: number; + itemCode?: string | null; + itemName: string; +} + +interface ItemQuantityListProps { + items: ItemQuantityListEntry[]; + className?: string; + itemClassName?: string; + separator?: string; +} + +export function ItemQuantityList({ + items, + className, + itemClassName, + separator = "," +}: ItemQuantityListProps) { + if (items.length === 0) { + return --; + } + + return ( +
+ {items.map((entry, index) => ( + + {index > 0 ? ( + + ) : null} + + x{entry.quantity} + + + + ))} +
+ ); +} diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx index 943033fd..c350ec76 100644 --- a/apps/web/src/components/production/production-page.tsx +++ b/apps/web/src/components/production/production-page.tsx @@ -5,6 +5,7 @@ import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared"; import { Check, ChevronsUpDown } from "lucide-react"; import { useActiveCompany } from "@/components/company/active-company-provider"; import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { useUiSfx } from "@/components/layout/ui-sfx-provider"; import { Badge } from "@/components/ui/badge"; @@ -632,16 +633,17 @@ export function ProductionPage() { className="inline-flex" />

-

Inputs:

-
    - {selectedRecipe.inputs.map((input) => ( -
  • - {input.quantityPerRun} - - / run -
  • - ))} -
+
+

Inputs / run:

+ ({ + key: input.itemId, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + /> +
) : null} @@ -755,14 +757,14 @@ export function ProductionPage() { {formatCadenceCount(row.recipe.durationTicks)} -
- {row.recipe.inputs.map((input) => ( - - {input.quantityPerRun} - - - ))} -
+ ({ + key: input.itemId, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + />
))} From bd41de8e4e1799f4ce85baaefbcc2290493d9d96 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:18:35 +0100 Subject: [PATCH 15/52] fix(web): centralize item quantity labels for recipe outputs --- ...e-item-quantity-rendering-and-apply-qua.md | 7 ++++ .../src/components/dev/dev-catalog-page.tsx | 36 ++++++++++--------- .../components/items/item-quantity-label.tsx | 30 ++++++++++++++++ .../components/items/item-quantity-list.tsx | 12 ++++--- .../components/production/production-page.tsx | 19 +++++----- apps/web/src/lib/quantity-controller.ts | 28 +++++++++++++++ 6 files changed, 100 insertions(+), 32 deletions(-) create mode 100644 .releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md create mode 100644 apps/web/src/components/items/item-quantity-label.tsx create mode 100644 apps/web/src/lib/quantity-controller.ts diff --git a/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md b/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md new file mode 100644 index 00000000..f8998a0d --- /dev/null +++ b/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Centralize item quantity rendering and apply quantifier labels to recipe outputs +--- + +- Centralize item quantity rendering and apply quantifier labels to recipe outputs diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx index c61f5173..be56bd31 100644 --- a/apps/web/src/components/dev/dev-catalog-page.tsx +++ b/apps/web/src/components/dev/dev-catalog-page.tsx @@ -3,6 +3,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { getIconCatalogItemByCode } from "@corpsim/shared"; import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -944,24 +945,25 @@ export function DevCatalogPage() {
- - {recipe.outputQuantity} - - + - {recipe.durationTicks} - - ({ - key: `${recipe.id}-${input.itemId}`, - quantity: input.quantityPerRun, - itemCode: input.item.code, - itemName: input.item.name - }))} - /> - -
- ))} + {recipe.durationTicks} + + ({ + key: `${recipe.id}-${input.itemId}`, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + /> + + + ))} {pagedRecipes.length === 0 ? ( diff --git a/apps/web/src/components/items/item-quantity-label.tsx b/apps/web/src/components/items/item-quantity-label.tsx new file mode 100644 index 00000000..2895f810 --- /dev/null +++ b/apps/web/src/components/items/item-quantity-label.tsx @@ -0,0 +1,30 @@ +import { ItemLabel } from "@/components/items/item-label"; +import { formatQuantityToken } from "@/lib/quantity-controller"; +import { cn } from "@/lib/utils"; + +interface ItemQuantityLabelProps { + quantity: number; + itemCode?: string | null; + itemName: string; + className?: string; + quantityClassName?: string; + itemClassName?: string; +} + +export function ItemQuantityLabel({ + quantity, + itemCode, + itemName, + className, + quantityClassName, + itemClassName +}: ItemQuantityLabelProps) { + return ( + + + {formatQuantityToken(quantity)} + + + + ); +} diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx index 3fbcad29..d2c0fe78 100644 --- a/apps/web/src/components/items/item-quantity-list.tsx +++ b/apps/web/src/components/items/item-quantity-list.tsx @@ -1,5 +1,5 @@ import { Fragment } from "react"; -import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; import { cn } from "@/lib/utils"; export interface ItemQuantityListEntry { @@ -35,10 +35,12 @@ export function ItemQuantityList({ {separator} ) : null} - - x{entry.quantity} - - + ))}
diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx index c350ec76..c0ca7f82 100644 --- a/apps/web/src/components/production/production-page.tsx +++ b/apps/web/src/components/production/production-page.tsx @@ -4,7 +4,7 @@ import { FormEvent, useCallback, useDeferredValue, useEffect, useMemo, useRef, u import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared"; import { Check, ChevronsUpDown } from "lucide-react"; import { useActiveCompany } from "@/components/company/active-company-provider"; -import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { useUiSfx } from "@/components/layout/ui-sfx-provider"; @@ -626,8 +626,9 @@ export function ProductionPage() { Duration: {formatCadenceCount(selectedRecipe.durationTicks)} / run

- Output: {selectedRecipe.outputQuantity}{" "} - {row.recipe.name}

- - {row.recipe.outputQuantity} - - + {formatCadenceCount(row.recipe.durationTicks)} diff --git a/apps/web/src/lib/quantity-controller.ts b/apps/web/src/lib/quantity-controller.ts new file mode 100644 index 00000000..8c0dce96 --- /dev/null +++ b/apps/web/src/lib/quantity-controller.ts @@ -0,0 +1,28 @@ +export interface QuantityFormatOptions { + prefix?: string; + fallback?: string; + locale?: string; +} + +export class QuantityController { + format(quantity: number, options?: QuantityFormatOptions): string { + const prefix = options?.prefix ?? "x"; + const fallback = options?.fallback ?? "--"; + + if (!Number.isFinite(quantity)) { + return fallback; + } + + return `${prefix}${quantity.toLocaleString(options?.locale)}`; + } +} + +const quantityController = new QuantityController(); + +export function getQuantityController(): QuantityController { + return quantityController; +} + +export function formatQuantityToken(quantity: number, options?: QuantityFormatOptions): string { + return quantityController.format(quantity, options); +} From 0aba554800869606d878abf5918fcf014bc5bc28 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:25:23 +0100 Subject: [PATCH 16/52] fix(web): resolve unknown item labels in market lists --- ...market-unknown-item-labels-by-using-global-i.md | 7 +++++++ apps/web/src/components/market/market-page.tsx | 14 ++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 .releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md diff --git a/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md b/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md new file mode 100644 index 00000000..363a1e2a --- /dev/null +++ b/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Fix market unknown item labels by using global item metadata +--- + +- Fix market unknown item labels by using global item metadata diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx index ec949927..0ea09418 100644 --- a/apps/web/src/components/market/market-page.tsx +++ b/apps/web/src/components/market/market-page.tsx @@ -89,6 +89,7 @@ export function MarketPage() { const { activeCompanyId, activeCompany } = useActiveCompany(); const { health, refresh: refreshHealth } = useWorldHealth(); + const [catalogItems, setCatalogItems] = useState([]); const [items, setItems] = useState([]); const [companies, setCompanies] = useState([]); const [regions, setRegions] = useState([]); @@ -115,7 +116,8 @@ export function MarketPage() { const [error, setError] = useState(null); const loadCatalog = useCallback(async (): Promise => { - const [itemRows, regionRows, companyRows] = await Promise.all([ + const [catalogItemRows, selectableItemRows, regionRows, companyRows] = await Promise.all([ + listItems(), listItems(activeCompanyId ?? undefined), listRegions(), listCompanies() @@ -140,7 +142,8 @@ export function MarketPage() { } setUnlockedItemIds(Array.from(unlockedIds)); - setItems(itemRows); + setCatalogItems(catalogItemRows); + setItems(selectableItemRows); setRegions(regionRows); setCompanies(companyRows); let resolvedRegionId = ""; @@ -456,8 +459,11 @@ export function MarketPage() { [regions] ); const itemMetaById = useMemo( - () => Object.fromEntries(items.map((item) => [item.id, { code: item.code, name: item.name }])), - [items] + () => + Object.fromEntries( + catalogItems.map((item) => [item.id, { code: item.code, name: item.name }]) + ), + [catalogItems] ); const companyNameById = useMemo( () => Object.fromEntries(companies.map((company) => [company.id, company.name])), From 26315cb2a2c7cc88e2b60f71658134427b549a4c Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:28:00 +0100 Subject: [PATCH 17/52] fix(web): scope market listings to company tradable items --- ...market-views-to-active-company-tradable.md | 7 ++++ .../web/src/components/market/market-page.tsx | 33 ++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 .releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md diff --git a/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md b/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md new file mode 100644 index 00000000..4f8cfc8a --- /dev/null +++ b/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Restrict market views to active company tradable items +--- + +- Restrict market views to active company tradable items diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx index 0ea09418..58d46f3c 100644 --- a/apps/web/src/components/market/market-page.tsx +++ b/apps/web/src/components/market/market-page.tsx @@ -396,11 +396,11 @@ export function MarketPage() { ); const unlockedItemIdSet = useMemo(() => new Set(unlockedItemIds), [unlockedItemIds]); const sortedSelectableItems = useMemo(() => { - if (unlockedItemIdSet.size === 0) { + if (!activeCompanyId) { return sortedItems; } return sortedItems.filter((item) => unlockedItemIdSet.has(item.id)); - }, [sortedItems, unlockedItemIdSet]); + }, [activeCompanyId, sortedItems, unlockedItemIdSet]); const filteredOrderSelectableItems = useMemo(() => { const needle = deferredOrderItemSearch.trim().toLowerCase(); if (!needle) { @@ -475,6 +475,24 @@ export function MarketPage() { name: activeCompany.regionName }) : null; + const visibleOrderBook = useMemo(() => { + if (!activeCompanyId) { + return orderBook; + } + return orderBook.filter((order) => unlockedItemIdSet.has(order.itemId)); + }, [activeCompanyId, orderBook, unlockedItemIdSet]); + const visibleMyOrders = useMemo(() => { + if (!activeCompanyId) { + return myOrders; + } + return myOrders.filter((order) => unlockedItemIdSet.has(order.itemId)); + }, [activeCompanyId, myOrders, unlockedItemIdSet]); + const visibleTrades = useMemo(() => { + if (!activeCompanyId) { + return trades; + } + return trades.filter((trade) => unlockedItemIdSet.has(trade.itemId)); + }, [activeCompanyId, trades, unlockedItemIdSet]); useEffect(() => { if (orderFilters.itemId && !sortedSelectableItems.some((item) => item.id === orderFilters.itemId)) { @@ -610,13 +628,18 @@ export function MarketPage() { Showing first {visibleOrderSelectableItems.length} matching items in order-item dropdown.

) : null} + {activeCompanyId && sortedSelectableItems.length === 0 ? ( +

+ No tradable items are currently unlocked for this company. +

+ ) : null} {error ?

{error}

: null}
Date: Sun, 22 Feb 2026 16:28:39 +0100 Subject: [PATCH 18/52] fix(auth): support single-origin sso routing --- .env.example | 4 + ...22153300-single-origin-sso-auth-routing.md | 10 ++ apps/api/src/lib/auth.ts | 23 +++- apps/web/app/api/auth/[[...path]]/route.ts | 119 ++++++++++++++++++ apps/web/app/v1/[...path]/route.ts | 5 +- apps/web/src/lib/api-client.ts | 22 +++- apps/web/src/lib/auth-client.ts | 40 ++++-- docs/project/DOKPLOY_DOCKERFILE.md | 44 ++++--- docs/project/DOKPLOY_PREVIEW.md | 24 ++-- docs/project/corpsim.altitude.nginx.conf | 11 +- 10 files changed, 242 insertions(+), 60 deletions(-) create mode 100644 .releases/unreleased/20260222153300-single-origin-sso-auth-routing.md create mode 100644 apps/web/app/api/auth/[[...path]]/route.ts diff --git a/.env.example b/.env.example index 7404303c..fc1b1aa7 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,10 @@ COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4 ######################################## # Frontend (Next.js public vars) ######################################## +NEXT_PUBLIC_APP_URL=http://localhost:4311 +# Optional explicit auth origin for Better Auth client requests. +# Leave blank to use same-origin routing. +NEXT_PUBLIC_AUTH_URL= NEXT_PUBLIC_API_URL=http://localhost:4310 NEXT_PUBLIC_APP_NAME=CorpSim NEXT_PUBLIC_COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4 diff --git a/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md b/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md new file mode 100644 index 00000000..62ee7836 --- /dev/null +++ b/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md @@ -0,0 +1,10 @@ +--- +type: patch +area: web, api +summary: Support single-origin SSO by proxying auth routes and preferring web origin for auth base URL +--- + +- Added Next.js proxy routing for `/api/auth/*` to the API upstream so auth can run on the web origin. +- Updated Better Auth base URL precedence to prefer explicit/web origins before internal API URLs. +- Hardened web API upstream resolution to avoid accidental proxy loops when public URLs point to the web origin. +- Updated deployment docs and env examples for single-domain SSO setup. diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 06fc5bcb..657ab634 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -476,6 +476,7 @@ async function ensurePlayerExistsForAuthUser(user: { function resolveTrustedOrigins(): string[] { const sources = [ + process.env.BETTER_AUTH_URL, process.env.CORS_ORIGIN, process.env.APP_URL, process.env.WEB_URL, @@ -497,14 +498,24 @@ function resolveTrustedOrigins(): string[] { return Array.from(resolved); } +function trimTrailingSlash(value: string): string { + return value.endsWith("/") ? value.slice(0, -1) : value; +} + function resolveAuthBaseUrl(): string { - const explicit = - process.env.BETTER_AUTH_URL?.trim() || - process.env.API_URL?.trim() || - process.env.APP_URL?.trim(); + const sources = [ + process.env.BETTER_AUTH_URL, + process.env.APP_URL, + process.env.WEB_URL, + process.env.NEXT_PUBLIC_APP_URL, + process.env.API_URL + ]; - if (explicit) { - return explicit; + for (const source of sources) { + const explicit = source?.trim(); + if (explicit) { + return trimTrailingSlash(explicit); + } } const apiPort = process.env.API_PORT?.trim() || process.env.PORT?.trim() || "4310"; diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts new file mode 100644 index 00000000..dc5db440 --- /dev/null +++ b/apps/web/app/api/auth/[[...path]]/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; + +type RouteContext = { + params: Promise<{ + path?: string[]; + }>; +}; + +const REQUEST_BLOCKED_HEADERS = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +const RESPONSE_BLOCKED_HEADERS = new Set([ + "connection", + "content-encoding", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +function sanitizeHeaders(source: Headers, blocked: Set): Headers { + const target = new Headers(); + source.forEach((value, key) => { + if (!blocked.has(key.toLowerCase())) { + target.set(key, value); + } + }); + return target; +} + +function resolveApiUpstreamBaseUrl(): string { + const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim(); + + if (explicit) { + return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit; + } + + const port = process.env.API_PORT?.trim() || "4310"; + return `http://127.0.0.1:${port}`; +} + +async function proxyToApi(request: NextRequest, context: RouteContext): Promise { + const { path } = await context.params; + const suffix = path && path.length > 0 ? `/${path.join("/")}` : ""; + const upstreamUrl = new URL(`${resolveApiUpstreamBaseUrl()}/api/auth${suffix}`); + upstreamUrl.search = request.nextUrl.search; + + const headers = sanitizeHeaders(request.headers, REQUEST_BLOCKED_HEADERS); + const method = request.method.toUpperCase(); + + let body: ArrayBuffer | undefined; + if (method !== "GET" && method !== "HEAD") { + const buffer = await request.arrayBuffer(); + body = buffer.byteLength > 0 ? buffer : undefined; + } + + try { + const upstreamResponse = await fetch(upstreamUrl, { + method, + headers, + body, + cache: "no-store", + redirect: "manual" + }); + + const responseHeaders = sanitizeHeaders(upstreamResponse.headers, RESPONSE_BLOCKED_HEADERS); + return new NextResponse(upstreamResponse.body, { + status: upstreamResponse.status, + headers: responseHeaders + }); + } catch { + return NextResponse.json({ message: "Auth upstream is unreachable." }, { status: 502 }); + } +} + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function POST(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function PUT(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function PATCH(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function OPTIONS(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} + +export async function HEAD(request: NextRequest, context: RouteContext) { + return proxyToApi(request, context); +} diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts index 11153032..d09d86b0 100644 --- a/apps/web/app/v1/[...path]/route.ts +++ b/apps/web/app/v1/[...path]/route.ts @@ -43,10 +43,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers { } function resolveApiUpstreamBaseUrl(): string { - const explicit = - process.env.API_URL?.trim() || - process.env.API_INTERNAL_URL?.trim() || - process.env.NEXT_PUBLIC_API_URL?.trim(); + const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim(); if (explicit) { return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit; diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 6b337dea..f6ae8908 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -9,17 +9,27 @@ if (typeof window !== "undefined") { const currentHost = window.location.hostname; const isProduction = !isLocalhostHostname(currentHost); - if (isProduction && !API_BASE_URL) { - console.warn( - "[CorpSim API Client] NEXT_PUBLIC_API_URL is not set. API requests may fail. " + - "Ensure NEXT_PUBLIC_API_URL is set as a build argument when building the Docker image." - ); - } else if (isProduction && isLocalhostUrl(API_BASE_URL)) { + if (isProduction && API_BASE_URL && isLocalhostUrl(API_BASE_URL)) { console.warn( `[CorpSim API Client] NEXT_PUBLIC_API_URL is set to "${API_BASE_URL}" but you're accessing from "${currentHost}". ` + "This likely means the environment variable was not set as a build argument. " + "Set NEXT_PUBLIC_API_URL as a build argument in your deployment platform and rebuild." ); + } else if (isProduction && API_BASE_URL) { + try { + const apiHost = new URL(API_BASE_URL).hostname; + if (apiHost !== currentHost) { + console.warn( + `[CorpSim API Client] API base URL host "${apiHost}" differs from current host "${currentHost}". ` + + "For single-domain SSO deployments, prefer same-origin API routing." + ); + } + } catch { + console.warn( + `[CorpSim API Client] NEXT_PUBLIC_API_URL is set to "${API_BASE_URL}" but is not a valid absolute URL. ` + + "Use an absolute HTTPS URL, or leave it empty to use same-origin routing." + ); + } } } diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index a8b2503c..fa53e1c3 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -5,7 +5,11 @@ import { adminClient, twoFactorClient, usernameClient } from "better-auth/client import { isLocalhostHostname, isLocalhostUrl } from "./localhost-utils"; function resolveAuthBaseUrl(): string { - const raw = process.env.NEXT_PUBLIC_API_URL?.trim() ?? ""; + const raw = + process.env.NEXT_PUBLIC_AUTH_URL?.trim() || + process.env.NEXT_PUBLIC_APP_URL?.trim() || + process.env.NEXT_PUBLIC_API_URL?.trim() || + ""; if (!raw) { return ""; } @@ -17,7 +21,10 @@ function validateAuthConfiguration(): void { return; } - const apiUrl = process.env.NEXT_PUBLIC_API_URL?.trim(); + const authUrl = + process.env.NEXT_PUBLIC_AUTH_URL?.trim() || + process.env.NEXT_PUBLIC_APP_URL?.trim() || + process.env.NEXT_PUBLIC_API_URL?.trim(); const currentHost = window.location.hostname; // Skip validation in development @@ -25,21 +32,32 @@ function validateAuthConfiguration(): void { return; } - // Check if API URL is missing or pointing to localhost in production - if (!apiUrl) { + if (!authUrl) { + return; + } + + if (isLocalhostUrl(authUrl)) { console.warn( - "[CorpSim Auth] NEXT_PUBLIC_API_URL is not set. Authentication will fail. " + - "Ensure NEXT_PUBLIC_API_URL is set as a build argument when building the Docker image." + `[CorpSim Auth] Auth base URL is set to "${authUrl}" but you're accessing the site from "${currentHost}". ` + + "This likely means the environment variable was not set as a build argument. " + + "Authentication requests may fail. " + + "Set NEXT_PUBLIC_AUTH_URL (or NEXT_PUBLIC_APP_URL) to your deployed app origin and rebuild the image." ); return; } - if (isLocalhostUrl(apiUrl)) { + try { + const authHost = new URL(authUrl).hostname; + if (authHost !== currentHost) { + console.warn( + `[CorpSim Auth] Auth base URL host "${authHost}" differs from current host "${currentHost}". ` + + "For SSO providers that require a single domain, point auth traffic to the same public origin." + ); + } + } catch { console.warn( - `[CorpSim Auth] NEXT_PUBLIC_API_URL is set to "${apiUrl}" but you're accessing the site from "${currentHost}". ` + - "This likely means the environment variable was not set as a build argument. " + - "Authentication requests will fail. " + - "Set NEXT_PUBLIC_API_URL as a build argument in your deployment platform and rebuild the image." + `[CorpSim Auth] Auth base URL "${authUrl}" is not a valid absolute URL. ` + + "Use an absolute HTTPS URL, or leave it empty to use same-origin routing." ); } } diff --git a/docs/project/DOKPLOY_DOCKERFILE.md b/docs/project/DOKPLOY_DOCKERFILE.md index adeed8af..a6d39352 100644 --- a/docs/project/DOKPLOY_DOCKERFILE.md +++ b/docs/project/DOKPLOY_DOCKERFILE.md @@ -15,7 +15,9 @@ Entrypoint: `scripts/start-container.sh` **IMPORTANT:** When deploying the `web` app (or `APP_ROLE=all`), Next.js `NEXT_PUBLIC_*` environment variables are **baked into the JavaScript bundle at build time**, NOT at runtime. In Dokploy, you MUST configure these as **build arguments**: -- `NEXT_PUBLIC_API_URL=https://` +- `NEXT_PUBLIC_APP_URL=https://` +- `NEXT_PUBLIC_AUTH_URL=https://` (optional; leave empty to use same-origin) +- `NEXT_PUBLIC_API_URL=https://` (recommended for single-domain SSO) or leave empty to use same-origin routing Setting these only as runtime environment variables will NOT work for client-side code. The browser will get the values that were present during `docker build`, not the ones set at runtime. @@ -50,10 +52,15 @@ Create separate Dokploy apps from the same repository and Dockerfile: - `WEB_PORT=4311` - expose/public port `4311` - ⚠️ **BUILD ARGUMENTS (set before building the image):** - - `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com` + - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` + - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) + - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) - **RUNTIME ENVIRONMENT VARIABLES:** - - `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com` + - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` + - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) + - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) - `API_URL=http://corpsim-api:4310` (or internal Docker network address) + - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` All runtime apps also need: @@ -66,14 +73,19 @@ All runtime apps also need: If you insist on one container, set: **Build Arguments (must be set before building):** -- `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com` +- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` +- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) +- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) **Runtime Environment Variables:** - `APP_ROLE=all` - `API_PORT=4310` - `WEB_PORT=4311` - `CORS_ORIGIN=https://corpsim.altitude-interactive.com` -- `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com` +- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` +- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) +- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) +- `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` - `API_URL=http://localhost:4310` (internal server-side API access) - `PREVIEW_DATABASE_URL=postgresql://postgres:@postgres:5432/corpsim` - `PREVIEW_REDIS_HOST=redis` @@ -91,12 +103,9 @@ pnpm sim:seed ## Nginx upstream mapping -- `corpsim.altitude-interactive.com` -> `192.0.2.10:4311` (web app) -- `corpsim-api.altitude-interactive.com` -> `192.0.2.10:4310` (API server) - -**IMPORTANT**: The nginx configuration for `corpsim.altitude-interactive.com` must proxy `/api/auth/*` requests to the API server at port 4310. This allows OAuth callbacks to work correctly on the main web domain. See `docs/project/corpsim.altitude.nginx.conf` for the complete configuration. - -**Note**: Replace the example IP address `192.0.2.10` (from RFC 5737 documentation range) with your actual server IP address. +- `corpsim.altitude-interactive.com` -> `:4311` (public) +- Keep API private/internal when possible; web route handlers proxy `/v1/*` and `/api/auth/*` to the API upstream. +- If `corpsim-api.altitude-interactive.com` is still configured, redirect it to `corpsim.altitude-interactive.com`. ## Troubleshooting @@ -121,18 +130,21 @@ pnpm sim:seed - 404 errors in browser console for `/api/auth/sign-in/social` - Auth requests going to wrong URL (e.g., `localhost` instead of your domain) -**Cause:** The `NEXT_PUBLIC_API_URL` build argument was not set when building the Docker image. +**Cause:** Public auth/API URLs were built with the wrong origin (often localhost or a separate API host), or missing entirely. **Solution:** 1. In Dokploy, find the Build Arguments or Build Environment section for your app -2. Add `NEXT_PUBLIC_API_URL=https://` as a build argument -3. Trigger a **full rebuild** of the image (not just a restart) -4. After deployment, check browser's network tab to verify requests go to the correct domain +2. Set `NEXT_PUBLIC_APP_URL` (and optionally `NEXT_PUBLIC_AUTH_URL`) to your web domain +3. Set `NEXT_PUBLIC_API_URL` to your web domain (or leave it empty for same-origin routing) +4. Set runtime `BETTER_AUTH_URL` to your web domain +5. Ensure runtime `API_URL` points to the internal API service (not the public web domain) +6. Trigger a **full rebuild** of the image (not just a restart) +7. After deployment, check browser's network tab to verify auth and API requests go to your web domain ### How to verify build-time values are correct 1. Open your web app in a browser 2. Open Developer Tools → Network tab 3. Trigger an API request (e.g., try to sign in) -4. Check the request URL - it should point to your production API domain, not localhost +4. Check request URLs - for single-domain SSO they should point to your web domain (not localhost) 5. Alternatively, check the Console tab for any warnings from CorpSim about misconfigured URLs diff --git a/docs/project/DOKPLOY_PREVIEW.md b/docs/project/DOKPLOY_PREVIEW.md index 4f65489f..57f1aaf6 100644 --- a/docs/project/DOKPLOY_PREVIEW.md +++ b/docs/project/DOKPLOY_PREVIEW.md @@ -28,7 +28,9 @@ You can use local `.env.preview` as the canonical template for these values. 1. Go to your Compose app → **Environment** tab 2. Set runtime environment variables as usual 3. **Additionally**, if Dokploy provides a "Build Arguments" section, set: - - `NEXT_PUBLIC_API_URL=https://` + - `NEXT_PUBLIC_APP_URL=https://` + - `NEXT_PUBLIC_AUTH_URL=https://` (optional) + - `NEXT_PUBLIC_API_URL=https://` (recommended) or leave empty for same-origin routing If Dokploy doesn't have a separate Build Arguments UI, note that variables set only in the **Environment** section are runtime variables and are **not** automatically available at build time. In that case, you must manually edit your `docker-compose.preview.yml` and add these as Docker build arguments (under the `args:` section of `x-app-build` or the relevant `build:` configuration for the `web` service) so that the `NEXT_PUBLIC_*` values are passed into the Next.js build. @@ -45,7 +47,10 @@ If Dokploy doesn't have a separate Build Arguments UI, note that variables set o - `WEB_PORT=4311` - `WEB_PUBLIC_PORT=4311` - `CORS_ORIGIN=https://` -- `NEXT_PUBLIC_API_URL=https://` ⚠️ **Must also be set as build argument** +- `NEXT_PUBLIC_APP_URL=https://` ⚠️ **Must also be set as build argument** +- `NEXT_PUBLIC_AUTH_URL=https://` (optional, also set as build argument) +- `NEXT_PUBLIC_API_URL=https://` (recommended, also set as build argument; can be empty for same-origin) +- `BETTER_AUTH_URL=https://` Release image settings (tag-first): @@ -65,7 +70,7 @@ Recommended worker settings: ## 3) Networking / domains - Route `web` publicly on `WEB_PUBLIC_PORT`. -- Route `api` publicly on `API_PUBLIC_PORT`. +- Prefer keeping `api` internal/private and letting web proxy `/v1/*` and `/api/auth/*` to the API service. - Keep `postgres` and `redis` internal only. ## 4) First deployment init @@ -95,13 +100,16 @@ Do not run `sim:reset` in preview unless you intentionally want to wipe/reseed s - 404 errors in browser console for `/api/auth/sign-in/social` - Auth requests going to wrong URL (e.g., `localhost` instead of your domain) -**Cause:** The `NEXT_PUBLIC_API_URL` build argument was not set correctly when the Docker image was built. Next.js bakes these values into the client-side JavaScript bundle at build time. +**Cause:** Public auth/API URLs were built with the wrong origin (often localhost or a separate API host), or missing entirely. **Solution:** -1. In Dokploy, ensure `NEXT_PUBLIC_API_URL` is set as a **build argument** (check Build Arguments or Build Environment section) -2. Trigger a **full rebuild** (not just a restart) -3. Verify the built image has the correct value by checking browser's network tab - requests should go to your API domain, not localhost +1. In Dokploy, set `NEXT_PUBLIC_APP_URL` (and optionally `NEXT_PUBLIC_AUTH_URL`) as **build arguments** +2. Set `NEXT_PUBLIC_API_URL` to your web domain (or leave it empty to use same-origin routing) +3. Set runtime `BETTER_AUTH_URL` to your web domain +4. Ensure runtime `API_URL` points to your internal API service +5. Trigger a **full rebuild** (not just a restart) +6. Verify in browser devtools that auth/API requests use your web domain, not localhost ### API calls going to localhost or wrong domain -This is the same issue as above - `NEXT_PUBLIC_*` variables must be set as build arguments before building the image. +This is the same issue as above: set correct `NEXT_PUBLIC_*` build arguments and rebuild the image. diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf index 764249b2..6d308243 100644 --- a/docs/project/corpsim.altitude.nginx.conf +++ b/docs/project/corpsim.altitude.nginx.conf @@ -9,7 +9,7 @@ server { listen 80; listen [::]:80; server_name corpsim-api.altitude-interactive.com; - return 301 https://$host$request_uri; + return 301 https://corpsim.altitude-interactive.com$request_uri; } server { @@ -48,12 +48,5 @@ server { ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; - location / { - proxy_pass http://192.0.2.10:4310; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } + return 308 https://corpsim.altitude-interactive.com$request_uri; } From e0f236b378e59f55445686b9a5de53798173bfb3 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 16:31:23 +0100 Subject: [PATCH 19/52] fix(web): preserve repeated proxy headers --- apps/web/app/api/auth/[[...path]]/route.ts | 2 +- apps/web/app/v1/[...path]/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts index dc5db440..56acf40f 100644 --- a/apps/web/app/api/auth/[[...path]]/route.ts +++ b/apps/web/app/api/auth/[[...path]]/route.ts @@ -36,7 +36,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers { const target = new Headers(); source.forEach((value, key) => { if (!blocked.has(key.toLowerCase())) { - target.set(key, value); + target.append(key, value); } }); return target; diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts index d09d86b0..d024104e 100644 --- a/apps/web/app/v1/[...path]/route.ts +++ b/apps/web/app/v1/[...path]/route.ts @@ -36,7 +36,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers { const target = new Headers(); source.forEach((value, key) => { if (!blocked.has(key.toLowerCase())) { - target.set(key, value); + target.append(key, value); } }); return target; From b9ac090daacd0574419e0e64a3ce52aa85864bab Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 16:48:03 +0100 Subject: [PATCH 20/52] fix(ci): apply migrations before APP_ROLE all startup --- .../20260222154900-run-migrations-in-all-role-startup.md | 8 ++++++++ scripts/start-container.sh | 7 +++++++ 2 files changed, 15 insertions(+) create mode 100644 .releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md diff --git a/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md b/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md new file mode 100644 index 00000000..73a77a5d --- /dev/null +++ b/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md @@ -0,0 +1,8 @@ +--- +type: patch +area: ci +summary: Run Prisma migrations before launching services in APP_ROLE=all mode +--- + +- Updated `scripts/start-container.sh` so `APP_ROLE=all` applies `prisma migrate deploy` before starting API, worker, and web. +- Prevents runtime schema-readiness pauses when single-container deployments start without a dedicated migrate step. diff --git a/scripts/start-container.sh b/scripts/start-container.sh index 44ebc5c4..05b58288 100644 --- a/scripts/start-container.sh +++ b/scripts/start-container.sh @@ -32,7 +32,14 @@ run_migrate() { exec pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma } +apply_migrations() { + pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma +} + run_all() { + # Ensure schema is current before starting long-running processes in single-container mode. + apply_migrations + pnpm --filter @corpsim/api start & api_pid=$! From 7b63a1dbead5eb6420e0da6d4a0844a0597f3994 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 16:51:48 +0100 Subject: [PATCH 21/52] fix(web): hide seeded example accounts in admin list --- ...0260222165400-hide-seeded-example-accounts-in-admin.md | 8 ++++++++ apps/web/src/components/admin/admin-page.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md diff --git a/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md b/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md new file mode 100644 index 00000000..feb74fc8 --- /dev/null +++ b/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Hide seeded example.com accounts from admin user listing +--- + +- Filtered admin dashboard user rows to exclude seeded accounts with emails ending in `@example.com`. +- Keeps production/real user account management focused and uncluttered. diff --git a/apps/web/src/components/admin/admin-page.tsx b/apps/web/src/components/admin/admin-page.tsx index f5564426..a0c9b4bb 100644 --- a/apps/web/src/components/admin/admin-page.tsx +++ b/apps/web/src/components/admin/admin-page.tsx @@ -46,6 +46,11 @@ interface SupportAccount { const MAIN_ADMIN_EMAIL = "admin@corpsim.local"; const STALE_IMPORT_TICK_THRESHOLD = 5; + +function isSeededExampleAccount(email: string): boolean { + return email.trim().toLowerCase().endsWith("@example.com"); +} + export function AdminPage() { const [users, setUsers] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -81,7 +86,8 @@ export function AdminPage() { }); if (result.data) { - setUsers(result.data.users as unknown as UserData[]); + const loadedUsers = result.data.users as unknown as UserData[]; + setUsers(loadedUsers.filter((user) => !isSeededExampleAccount(user.email))); } else if (result.error) { showToast({ title: "Failed to load users", From f09d929cef8e7a9b2980f2088d6d63e2e574c4fc Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:00:16 +0100 Subject: [PATCH 22/52] fix(api): allow admins on developer catalog read endpoints --- ...00-allow-admin-developer-read-endpoints.md | 8 +++++ .../decorators/current-player-id.decorator.ts | 33 +++++++++++++++++ .../test/current-player-id.decorator.test.ts | 36 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 .releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md create mode 100644 apps/api/test/current-player-id.decorator.test.ts diff --git a/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md b/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md new file mode 100644 index 00000000..7f71f9e9 --- /dev/null +++ b/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md @@ -0,0 +1,8 @@ +--- +type: patch +area: api, web +summary: Allow admin accounts to access developer page read endpoints +--- + +- Updated player-id guard logic to permit admin `GET` access on the specific catalog endpoints used by `/developer`. +- Kept admin restrictions in place for write operations and non-allowlisted gameplay endpoints. diff --git a/apps/api/src/common/decorators/current-player-id.decorator.ts b/apps/api/src/common/decorators/current-player-id.decorator.ts index e59f162e..a6b1cb2b 100644 --- a/apps/api/src/common/decorators/current-player-id.decorator.ts +++ b/apps/api/src/common/decorators/current-player-id.decorator.ts @@ -8,6 +8,10 @@ import type { UserSession } from "@thallesp/nestjs-better-auth"; interface RequestWithSession { session?: UserSession | null; + method?: string; + url?: string; + originalUrl?: string; + path?: string; } function isAdminRole(role: string | string[] | null | undefined): boolean { @@ -20,6 +24,32 @@ function isAdminRole(role: string | string[] | null | undefined): boolean { .some((entry) => entry === "admin"); } +const ADMIN_ALLOWED_READ_PATH_PREFIXES = [ + "/v1/companies", + "/v1/items", + "/v1/production/recipes", + "/v1/research" +] as const; + +function resolveRequestPath(request: RequestWithSession): string { + const rawPath = request.originalUrl ?? request.path ?? request.url ?? ""; + const pathWithoutQuery = rawPath.split("?", 1)[0] ?? ""; + const normalized = pathWithoutQuery.trim(); + return normalized.length > 0 ? normalized : "/"; +} + +export function canAdminAccessPlayerGameplayEndpoint(request: RequestWithSession): boolean { + const method = request.method?.trim().toUpperCase(); + if (method !== "GET") { + return false; + } + + const path = resolveRequestPath(request); + return ADMIN_ALLOWED_READ_PATH_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`) + ); +} + function resolveTestFallbackPlayerId(): string | null { if (process.env.NODE_ENV !== "test") { return null; @@ -43,6 +73,9 @@ export const CurrentPlayerId = createParamDecorator( } if (isAdminRole(request.session?.user?.role)) { + if (canAdminAccessPlayerGameplayEndpoint(request)) { + return playerId; + } throw new ForbiddenException("Admin accounts cannot access player gameplay endpoints."); } return playerId; diff --git a/apps/api/test/current-player-id.decorator.test.ts b/apps/api/test/current-player-id.decorator.test.ts new file mode 100644 index 00000000..70be25ed --- /dev/null +++ b/apps/api/test/current-player-id.decorator.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { canAdminAccessPlayerGameplayEndpoint } from "../src/common/decorators/current-player-id.decorator"; + +describe("canAdminAccessPlayerGameplayEndpoint", () => { + it("allows admin GET requests to developer catalog read endpoints", () => { + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/companies" })).toBe(true); + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/items" })).toBe(true); + expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/production/recipes" })).toBe( + true + ); + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "GET", + url: "/v1/research?companyId=company_seed" + }) + ).toBe(true); + }); + + it("denies admin access for non-allowlisted paths", () => { + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "GET", + url: "/v1/market/orders" + }) + ).toBe(false); + }); + + it("denies admin access for write methods even on allowlisted paths", () => { + expect( + canAdminAccessPlayerGameplayEndpoint({ + method: "POST", + url: "/v1/production/recipes" + }) + ).toBe(false); + }); +}); From b5f1e939dfc47b237d92d34dc295b58c73c3461e Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:03:46 +0100 Subject: [PATCH 23/52] fix(web): accept redacted company cash in parsers --- ...1500-handle-redacted-company-cash-in-web-parsers.md | 8 ++++++++ apps/web/src/lib/api-parsers.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md diff --git a/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md b/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md new file mode 100644 index 00000000..062742c2 --- /dev/null +++ b/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Accept optional redacted company cash fields in API parsers +--- + +- Updated web API parsers to treat `cashCents` as optional in company summary and player registry company payloads. +- Prevents admin developer page failures when backend redacts non-owned company cash values. diff --git a/apps/web/src/lib/api-parsers.ts b/apps/web/src/lib/api-parsers.ts index 6fa7ed25..aed0cd62 100644 --- a/apps/web/src/lib/api-parsers.ts +++ b/apps/web/src/lib/api-parsers.ts @@ -134,13 +134,16 @@ export function parseCompanySummary(value: unknown): CompanySummary { throw new Error("Invalid company item"); } + const cashCents = + value.cashCents === undefined ? undefined : readString(value.cashCents, "cashCents"); + return { id: readString(value.id, "id"), code: readString(value.code, "code"), name: readString(value.name, "name"), isBot: readBoolean(value.isBot, "isBot"), specialization: parseCompanySpecialization(value.specialization), - cashCents: readString(value.cashCents, "cashCents"), + cashCents, regionId: readString(value.regionId, "regionId"), regionCode: readString(value.regionCode, "regionCode"), regionName: readString(value.regionName, "regionName") @@ -245,12 +248,15 @@ export function parsePlayerRegistryCompany(value: unknown): PlayerRegistryCompan throw new Error("Invalid player registry company"); } + const cashCents = + value.cashCents === undefined ? undefined : readString(value.cashCents, "cashCents"); + return { id: readString(value.id, "id"), code: readString(value.code, "code"), name: readString(value.name, "name"), isBot: readBoolean(value.isBot, "isBot"), - cashCents: readString(value.cashCents, "cashCents"), + cashCents, regionId: readString(value.regionId, "regionId"), regionCode: readString(value.regionCode, "regionCode"), regionName: readString(value.regionName, "regionName"), From d462c173dc810051fd9b26af5151c3a7ba032e39 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:06:57 +0100 Subject: [PATCH 24/52] fix(api): support admin research catalog on developer page --- ...enable-admin-developer-research-catalog.md | 8 +++ apps/api/src/research/research.controller.ts | 22 ++++++- apps/api/src/research/research.service.ts | 39 +++++++++++ ...research-admin-catalog.integration.test.ts | 64 +++++++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md create mode 100644 apps/api/test/research-admin-catalog.integration.test.ts diff --git a/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md b/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md new file mode 100644 index 00000000..f2fe5ccd --- /dev/null +++ b/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md @@ -0,0 +1,8 @@ +--- +type: patch +area: api, web +summary: Enable admin access to developer research catalog without player ownership +--- + +- Added admin-only research catalog read path that selects a player-owned company when no company ID is provided. +- Kept existing player ownership enforcement for non-admin research access and all research mutations. diff --git a/apps/api/src/research/research.controller.ts b/apps/api/src/research/research.controller.ts index ce87b435..2f1bd171 100644 --- a/apps/api/src/research/research.controller.ts +++ b/apps/api/src/research/research.controller.ts @@ -1,10 +1,25 @@ -import { Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common"; +import { Body, Controller, Get, Inject, Param, Post, Query, Req } from "@nestjs/common"; +import type { UserSession } from "@thallesp/nestjs-better-auth"; import { CurrentPlayerId } from "../common/decorators/current-player-id.decorator"; import { ListResearchDto } from "./dto/list-research.dto"; import { MutateResearchDto } from "./dto/mutate-research.dto"; import { ResearchNodeParamDto } from "./dto/research-node-param.dto"; import { ResearchService } from "./research.service"; +interface RequestWithSession { + session?: UserSession | null; +} + +function isAdminRole(role: string | string[] | null | undefined): boolean { + if (!role) { + return false; + } + const roleValues = Array.isArray(role) ? role : role.split(","); + return roleValues + .map((entry) => entry.trim().toLowerCase()) + .some((entry) => entry === "admin"); +} + @Controller("v1/research") export class ResearchController { private readonly researchService: ResearchService; @@ -16,8 +31,13 @@ export class ResearchController { @Get() async list( @Query() query: ListResearchDto, + @Req() request: RequestWithSession, @CurrentPlayerId() playerId: string ) { + if (isAdminRole(request.session?.user?.role)) { + return this.researchService.listResearchForAdminCatalog(query.companyId); + } + return this.researchService.listResearch(query.companyId, playerId); } diff --git a/apps/api/src/research/research.service.ts b/apps/api/src/research/research.service.ts index 4e74ee32..6c932c4c 100644 --- a/apps/api/src/research/research.service.ts +++ b/apps/api/src/research/research.service.ts @@ -54,6 +54,31 @@ export class ResearchService { this.prisma = prisma; } + private async resolveAdminCatalogCompanyId(companyId?: string): Promise { + if (companyId) { + return companyId; + } + + const firstPlayerCompany = await this.prisma.company.findFirst({ + where: { + isPlayer: true, + ownerPlayerId: { not: null } + }, + orderBy: { + createdAt: "asc" + }, + select: { + id: true + } + }); + + if (!firstPlayerCompany) { + throw new DomainInvariantError("no player-owned companies available for research catalog"); + } + + return firstPlayerCompany.id; + } + private async resolveOwnedCompanyId(playerId: string, companyId?: string): Promise { if (companyId) { await assertCompanyOwnedByPlayer(this.prisma, playerId, companyId); @@ -84,6 +109,20 @@ export class ResearchService { }; } + async listResearchForAdminCatalog( + companyId?: string + ): Promise<{ companyId: string; nodes: ResearchNode[] }> { + const resolvedCompanyId = await this.resolveAdminCatalogCompanyId(companyId); + const nodes = await listResearchForCompany(this.prisma, { + companyId: resolvedCompanyId + }); + + return { + companyId: resolvedCompanyId, + nodes: nodes.map(mapNodeToDto) + }; + } + async startNode( nodeId: string, companyId: string | undefined, diff --git a/apps/api/test/research-admin-catalog.integration.test.ts b/apps/api/test/research-admin-catalog.integration.test.ts new file mode 100644 index 00000000..7a69abdf --- /dev/null +++ b/apps/api/test/research-admin-catalog.integration.test.ts @@ -0,0 +1,64 @@ +import "reflect-metadata"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { seedWorld } from "@corpsim/db"; +import { HttpErrorFilter } from "../src/common/filters/http-error.filter"; +import { AppModule } from "../src/app.module"; +import { PrismaService } from "../src/prisma/prisma.service"; +import { ResearchService } from "../src/research/research.service"; + +describe("research admin catalog", () => { + let app: INestApplication; + let prisma: PrismaService; + let researchService: ResearchService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule] + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + forbidUnknownValues: true, + transform: false, + stopAtFirstError: true + }) + ); + app.useGlobalFilters(new HttpErrorFilter()); + await app.init(); + + prisma = app.get(PrismaService); + researchService = app.get(ResearchService); + }); + + beforeEach(async () => { + await seedWorld(prisma, { reset: true }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("returns a research catalog for admins without requiring player ownership", async () => { + const result = await researchService.listResearchForAdminCatalog(); + + expect(typeof result.companyId).toBe("string"); + expect(result.companyId.length).toBeGreaterThan(0); + expect(result.nodes.length).toBeGreaterThan(0); + + const company = await prisma.company.findUniqueOrThrow({ + where: { id: result.companyId }, + select: { + isPlayer: true, + ownerPlayerId: true + } + }); + + expect(company.isPlayer).toBe(true); + expect(company.ownerPlayerId).not.toBeNull(); + }); +}); From 16d598b0eb6ee4294d9a6035310076563f2beb94 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:11:04 +0100 Subject: [PATCH 25/52] fix(web): separate recipe input items across catalog views --- ...73000-separate-recipe-input-items-in-ui.md | 8 ++++ .../src/components/dev/dev-catalog-page.tsx | 27 +++++------ .../components/items/item-quantity-list.tsx | 46 +++++++++++++++++++ .../components/production/production-page.tsx | 38 +++++++-------- 4 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 .releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md create mode 100644 apps/web/src/components/items/item-quantity-list.tsx diff --git a/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md b/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md new file mode 100644 index 00000000..b4e76f68 --- /dev/null +++ b/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Improve recipe input readability with explicit separators and quantity labels +--- + +- Added a reusable `ItemQuantityList` UI component that renders item inputs with clear separators and `xN` quantities. +- Updated developer and production recipe sections to use the shared list component, preventing concatenated input labels. diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx index 3d2130bd..c61f5173 100644 --- a/apps/web/src/components/dev/dev-catalog-page.tsx +++ b/apps/web/src/components/dev/dev-catalog-page.tsx @@ -3,6 +3,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { getIconCatalogItemByCode } from "@corpsim/shared"; import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -948,19 +949,19 @@ export function DevCatalogPage() { - {recipe.durationTicks} - -
- {recipe.inputs.map((input) => ( -

- {input.quantityPerRun} - -

- ))} -
-
- - ))} + {recipe.durationTicks} + + ({ + key: `${recipe.id}-${input.itemId}`, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + /> + + + ))} {pagedRecipes.length === 0 ? ( diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx new file mode 100644 index 00000000..3fbcad29 --- /dev/null +++ b/apps/web/src/components/items/item-quantity-list.tsx @@ -0,0 +1,46 @@ +import { Fragment } from "react"; +import { ItemLabel } from "@/components/items/item-label"; +import { cn } from "@/lib/utils"; + +export interface ItemQuantityListEntry { + key: string; + quantity: number; + itemCode?: string | null; + itemName: string; +} + +interface ItemQuantityListProps { + items: ItemQuantityListEntry[]; + className?: string; + itemClassName?: string; + separator?: string; +} + +export function ItemQuantityList({ + items, + className, + itemClassName, + separator = "," +}: ItemQuantityListProps) { + if (items.length === 0) { + return --; + } + + return ( +
+ {items.map((entry, index) => ( + + {index > 0 ? ( + + ) : null} + + x{entry.quantity} + + + + ))} +
+ ); +} diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx index 9e5e6c38..fe486221 100644 --- a/apps/web/src/components/production/production-page.tsx +++ b/apps/web/src/components/production/production-page.tsx @@ -5,6 +5,7 @@ import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared"; import { Check, ChevronsUpDown } from "lucide-react"; import { useActiveCompany } from "@/components/company/active-company-provider"; import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { useUiSfx } from "@/components/layout/ui-sfx-provider"; import { Badge } from "@/components/ui/badge"; @@ -647,16 +648,17 @@ export function ProductionPage() { className="inline-flex" />

-

Inputs:

-
    - {selectedRecipe.inputs.map((input) => ( -
  • - {input.quantityPerRun} - - / run -
  • - ))} -
+
+

Inputs / run:

+ ({ + key: input.itemId, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + /> +
) : null} @@ -770,14 +772,14 @@ export function ProductionPage() {
{formatCadenceCount(row.recipe.durationTicks)} -
- {row.recipe.inputs.map((input) => ( - - {input.quantityPerRun} - - - ))} -
+ ({ + key: input.itemId, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + />
))} From 2eb934da9a3de1b800c7d62deb66d03938595078 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:18:35 +0100 Subject: [PATCH 26/52] fix(web): centralize item quantity labels for recipe outputs --- ...e-item-quantity-rendering-and-apply-qua.md | 7 ++++ .../src/components/dev/dev-catalog-page.tsx | 36 ++++++++++--------- .../components/items/item-quantity-label.tsx | 30 ++++++++++++++++ .../components/items/item-quantity-list.tsx | 12 ++++--- .../components/production/production-page.tsx | 19 +++++----- apps/web/src/lib/quantity-controller.ts | 28 +++++++++++++++ 6 files changed, 100 insertions(+), 32 deletions(-) create mode 100644 .releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md create mode 100644 apps/web/src/components/items/item-quantity-label.tsx create mode 100644 apps/web/src/lib/quantity-controller.ts diff --git a/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md b/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md new file mode 100644 index 00000000..f8998a0d --- /dev/null +++ b/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Centralize item quantity rendering and apply quantifier labels to recipe outputs +--- + +- Centralize item quantity rendering and apply quantifier labels to recipe outputs diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx index c61f5173..be56bd31 100644 --- a/apps/web/src/components/dev/dev-catalog-page.tsx +++ b/apps/web/src/components/dev/dev-catalog-page.tsx @@ -3,6 +3,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { getIconCatalogItemByCode } from "@corpsim/shared"; import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -944,24 +945,25 @@ export function DevCatalogPage() { - - {recipe.outputQuantity} - - + - {recipe.durationTicks} - - ({ - key: `${recipe.id}-${input.itemId}`, - quantity: input.quantityPerRun, - itemCode: input.item.code, - itemName: input.item.name - }))} - /> - - - ))} + {recipe.durationTicks} + + ({ + key: `${recipe.id}-${input.itemId}`, + quantity: input.quantityPerRun, + itemCode: input.item.code, + itemName: input.item.name + }))} + /> + + + ))} {pagedRecipes.length === 0 ? ( diff --git a/apps/web/src/components/items/item-quantity-label.tsx b/apps/web/src/components/items/item-quantity-label.tsx new file mode 100644 index 00000000..2895f810 --- /dev/null +++ b/apps/web/src/components/items/item-quantity-label.tsx @@ -0,0 +1,30 @@ +import { ItemLabel } from "@/components/items/item-label"; +import { formatQuantityToken } from "@/lib/quantity-controller"; +import { cn } from "@/lib/utils"; + +interface ItemQuantityLabelProps { + quantity: number; + itemCode?: string | null; + itemName: string; + className?: string; + quantityClassName?: string; + itemClassName?: string; +} + +export function ItemQuantityLabel({ + quantity, + itemCode, + itemName, + className, + quantityClassName, + itemClassName +}: ItemQuantityLabelProps) { + return ( + + + {formatQuantityToken(quantity)} + + + + ); +} diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx index 3fbcad29..d2c0fe78 100644 --- a/apps/web/src/components/items/item-quantity-list.tsx +++ b/apps/web/src/components/items/item-quantity-list.tsx @@ -1,5 +1,5 @@ import { Fragment } from "react"; -import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; import { cn } from "@/lib/utils"; export interface ItemQuantityListEntry { @@ -35,10 +35,12 @@ export function ItemQuantityList({ {separator} ) : null} - - x{entry.quantity} - - + ))} diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx index fe486221..75a3b78e 100644 --- a/apps/web/src/components/production/production-page.tsx +++ b/apps/web/src/components/production/production-page.tsx @@ -4,7 +4,7 @@ import { FormEvent, useCallback, useDeferredValue, useEffect, useMemo, useRef, u import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared"; import { Check, ChevronsUpDown } from "lucide-react"; import { useActiveCompany } from "@/components/company/active-company-provider"; -import { ItemLabel } from "@/components/items/item-label"; +import { ItemQuantityLabel } from "@/components/items/item-quantity-label"; import { ItemQuantityList } from "@/components/items/item-quantity-list"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { useUiSfx } from "@/components/layout/ui-sfx-provider"; @@ -641,8 +641,9 @@ export function ProductionPage() { Duration: {formatCadenceCount(selectedRecipe.durationTicks)} / run

- Output: {selectedRecipe.outputQuantity}{" "} - {row.recipe.name}

- - {row.recipe.outputQuantity} - - + {formatCadenceCount(row.recipe.durationTicks)} diff --git a/apps/web/src/lib/quantity-controller.ts b/apps/web/src/lib/quantity-controller.ts new file mode 100644 index 00000000..8c0dce96 --- /dev/null +++ b/apps/web/src/lib/quantity-controller.ts @@ -0,0 +1,28 @@ +export interface QuantityFormatOptions { + prefix?: string; + fallback?: string; + locale?: string; +} + +export class QuantityController { + format(quantity: number, options?: QuantityFormatOptions): string { + const prefix = options?.prefix ?? "x"; + const fallback = options?.fallback ?? "--"; + + if (!Number.isFinite(quantity)) { + return fallback; + } + + return `${prefix}${quantity.toLocaleString(options?.locale)}`; + } +} + +const quantityController = new QuantityController(); + +export function getQuantityController(): QuantityController { + return quantityController; +} + +export function formatQuantityToken(quantity: number, options?: QuantityFormatOptions): string { + return quantityController.format(quantity, options); +} From f251dc092f3acb52f1fdf43d3547ec9fe0c2d90a Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:25:23 +0100 Subject: [PATCH 27/52] fix(web): resolve unknown item labels in market lists --- ...market-unknown-item-labels-by-using-global-i.md | 7 +++++++ apps/web/src/components/market/market-page.tsx | 14 ++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 .releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md diff --git a/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md b/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md new file mode 100644 index 00000000..363a1e2a --- /dev/null +++ b/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Fix market unknown item labels by using global item metadata +--- + +- Fix market unknown item labels by using global item metadata diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx index ec949927..0ea09418 100644 --- a/apps/web/src/components/market/market-page.tsx +++ b/apps/web/src/components/market/market-page.tsx @@ -89,6 +89,7 @@ export function MarketPage() { const { activeCompanyId, activeCompany } = useActiveCompany(); const { health, refresh: refreshHealth } = useWorldHealth(); + const [catalogItems, setCatalogItems] = useState([]); const [items, setItems] = useState([]); const [companies, setCompanies] = useState([]); const [regions, setRegions] = useState([]); @@ -115,7 +116,8 @@ export function MarketPage() { const [error, setError] = useState(null); const loadCatalog = useCallback(async (): Promise => { - const [itemRows, regionRows, companyRows] = await Promise.all([ + const [catalogItemRows, selectableItemRows, regionRows, companyRows] = await Promise.all([ + listItems(), listItems(activeCompanyId ?? undefined), listRegions(), listCompanies() @@ -140,7 +142,8 @@ export function MarketPage() { } setUnlockedItemIds(Array.from(unlockedIds)); - setItems(itemRows); + setCatalogItems(catalogItemRows); + setItems(selectableItemRows); setRegions(regionRows); setCompanies(companyRows); let resolvedRegionId = ""; @@ -456,8 +459,11 @@ export function MarketPage() { [regions] ); const itemMetaById = useMemo( - () => Object.fromEntries(items.map((item) => [item.id, { code: item.code, name: item.name }])), - [items] + () => + Object.fromEntries( + catalogItems.map((item) => [item.id, { code: item.code, name: item.name }]) + ), + [catalogItems] ); const companyNameById = useMemo( () => Object.fromEntries(companies.map((company) => [company.id, company.name])), From a2057be1fd1a24f32710eee2f05d92a7b885a471 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:28:00 +0100 Subject: [PATCH 28/52] fix(web): scope market listings to company tradable items --- ...market-views-to-active-company-tradable.md | 7 ++++ .../web/src/components/market/market-page.tsx | 33 ++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 .releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md diff --git a/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md b/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md new file mode 100644 index 00000000..4f8cfc8a --- /dev/null +++ b/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Restrict market views to active company tradable items +--- + +- Restrict market views to active company tradable items diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx index 0ea09418..58d46f3c 100644 --- a/apps/web/src/components/market/market-page.tsx +++ b/apps/web/src/components/market/market-page.tsx @@ -396,11 +396,11 @@ export function MarketPage() { ); const unlockedItemIdSet = useMemo(() => new Set(unlockedItemIds), [unlockedItemIds]); const sortedSelectableItems = useMemo(() => { - if (unlockedItemIdSet.size === 0) { + if (!activeCompanyId) { return sortedItems; } return sortedItems.filter((item) => unlockedItemIdSet.has(item.id)); - }, [sortedItems, unlockedItemIdSet]); + }, [activeCompanyId, sortedItems, unlockedItemIdSet]); const filteredOrderSelectableItems = useMemo(() => { const needle = deferredOrderItemSearch.trim().toLowerCase(); if (!needle) { @@ -475,6 +475,24 @@ export function MarketPage() { name: activeCompany.regionName }) : null; + const visibleOrderBook = useMemo(() => { + if (!activeCompanyId) { + return orderBook; + } + return orderBook.filter((order) => unlockedItemIdSet.has(order.itemId)); + }, [activeCompanyId, orderBook, unlockedItemIdSet]); + const visibleMyOrders = useMemo(() => { + if (!activeCompanyId) { + return myOrders; + } + return myOrders.filter((order) => unlockedItemIdSet.has(order.itemId)); + }, [activeCompanyId, myOrders, unlockedItemIdSet]); + const visibleTrades = useMemo(() => { + if (!activeCompanyId) { + return trades; + } + return trades.filter((trade) => unlockedItemIdSet.has(trade.itemId)); + }, [activeCompanyId, trades, unlockedItemIdSet]); useEffect(() => { if (orderFilters.itemId && !sortedSelectableItems.some((item) => item.id === orderFilters.itemId)) { @@ -610,13 +628,18 @@ export function MarketPage() { Showing first {visibleOrderSelectableItems.length} matching items in order-item dropdown.

) : null} + {activeCompanyId && sortedSelectableItems.length === 0 ? ( +

+ No tradable items are currently unlocked for this company. +

+ ) : null} {error ?

{error}

: null} Date: Sun, 22 Feb 2026 17:38:58 +0100 Subject: [PATCH 29/52] docs(ops): use example domains and RFC5737 IPs in nginx docs --- ...dokploy-nginx-docs-to-example-domains-a.md | 7 +++ docs/project/DOKPLOY_DOCKERFILE.md | 50 ++++++++++--------- docs/project/corpsim.altitude.nginx.conf | 12 ++--- 3 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 .releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md diff --git a/.releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md b/.releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md new file mode 100644 index 00000000..bebdcd9b --- /dev/null +++ b/.releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md @@ -0,0 +1,7 @@ +--- +type: patch +area: ops +summary: Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs +--- + +- Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs diff --git a/docs/project/DOKPLOY_DOCKERFILE.md b/docs/project/DOKPLOY_DOCKERFILE.md index a6d39352..c62d1d15 100644 --- a/docs/project/DOKPLOY_DOCKERFILE.md +++ b/docs/project/DOKPLOY_DOCKERFILE.md @@ -10,7 +10,7 @@ This project supports Dockerfile-only runtime modes via: Entrypoint: `scripts/start-container.sh` -## ⚠️ Critical: Next.js Build Arguments +## Critical: Next.js Build Arguments **IMPORTANT:** When deploying the `web` app (or `APP_ROLE=all`), Next.js `NEXT_PUBLIC_*` environment variables are **baked into the JavaScript bundle at build time**, NOT at runtime. @@ -39,9 +39,9 @@ Create separate Dokploy apps from the same repository and Dockerfile: - `APP_ROLE=api` - `API_PORT=4310` - expose/public port `4310` - - `CORS_ORIGIN=https://corpsim.altitude-interactive.com` - - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` - - ⚠️ **IMPORTANT**: Set `BETTER_AUTH_URL` to the main web domain (not the API subdomain) so OAuth callbacks redirect to the correct domain + - `CORS_ORIGIN=https://app.example.com` + - `BETTER_AUTH_URL=https://app.example.com` + - **IMPORTANT**: Set `BETTER_AUTH_URL` to the main web domain (not the API subdomain) so OAuth callbacks redirect to the correct domain 3. `corpsim-worker` - `APP_ROLE=worker` @@ -51,16 +51,16 @@ Create separate Dokploy apps from the same repository and Dockerfile: - `APP_ROLE=web` - `WEB_PORT=4311` - expose/public port `4311` - - ⚠️ **BUILD ARGUMENTS (set before building the image):** - - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` - - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) - - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) + - **BUILD ARGUMENTS (set before building the image):** + - `NEXT_PUBLIC_APP_URL=https://app.example.com` + - `NEXT_PUBLIC_AUTH_URL=https://app.example.com` (optional) + - `NEXT_PUBLIC_API_URL=https://app.example.com` (or leave empty) - **RUNTIME ENVIRONMENT VARIABLES:** - - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` - - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) - - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) + - `NEXT_PUBLIC_APP_URL=https://app.example.com` + - `NEXT_PUBLIC_AUTH_URL=https://app.example.com` (optional) + - `NEXT_PUBLIC_API_URL=https://app.example.com` (or leave empty) - `API_URL=http://corpsim-api:4310` (or internal Docker network address) - - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` + - `BETTER_AUTH_URL=https://app.example.com` All runtime apps also need: @@ -73,19 +73,19 @@ All runtime apps also need: If you insist on one container, set: **Build Arguments (must be set before building):** -- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` -- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) -- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) +- `NEXT_PUBLIC_APP_URL=https://app.example.com` +- `NEXT_PUBLIC_AUTH_URL=https://app.example.com` (optional) +- `NEXT_PUBLIC_API_URL=https://app.example.com` (or leave empty) **Runtime Environment Variables:** - `APP_ROLE=all` - `API_PORT=4310` - `WEB_PORT=4311` -- `CORS_ORIGIN=https://corpsim.altitude-interactive.com` -- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com` -- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional) -- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty) -- `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` +- `CORS_ORIGIN=https://app.example.com` +- `NEXT_PUBLIC_APP_URL=https://app.example.com` +- `NEXT_PUBLIC_AUTH_URL=https://app.example.com` (optional) +- `NEXT_PUBLIC_API_URL=https://app.example.com` (or leave empty) +- `BETTER_AUTH_URL=https://app.example.com` - `API_URL=http://localhost:4310` (internal server-side API access) - `PREVIEW_DATABASE_URL=postgresql://postgres:@postgres:5432/corpsim` - `PREVIEW_REDIS_HOST=redis` @@ -103,9 +103,9 @@ pnpm sim:seed ## Nginx upstream mapping -- `corpsim.altitude-interactive.com` -> `:4311` (public) +- `app.example.com` -> `192.0.2.10:4311` (public) - Keep API private/internal when possible; web route handlers proxy `/v1/*` and `/api/auth/*` to the API upstream. -- If `corpsim-api.altitude-interactive.com` is still configured, redirect it to `corpsim.altitude-interactive.com`. +- If `api.example.com` is still configured, redirect it to `app.example.com`. ## Troubleshooting @@ -118,10 +118,10 @@ pnpm sim:seed **Cause:** The `BETTER_AUTH_URL` environment variable for the API service is not configured correctly, or the nginx proxy is not set up to forward `/api/auth/*` requests from the web domain to the API server. **Solution:** -1. Ensure `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` is set for the API service (use the main web domain, NOT the API subdomain) +1. Ensure `BETTER_AUTH_URL=https://app.example.com` is set for the API service (use the main web domain, NOT the API subdomain) 2. Verify the nginx configuration includes a proxy rule for `/api/auth/*` (see `docs/project/corpsim.altitude.nginx.conf`) 3. Restart the API service and nginx after making changes -4. Configure OAuth apps (GitHub, Google, Microsoft, Discord) to allow callback URLs like `https://corpsim.altitude-interactive.com/api/auth/callback/{provider}` +4. Configure OAuth apps (GitHub, Google, Microsoft, Discord) to allow callback URLs like `https://app.example.com/api/auth/callback/{provider}` ### Authentication failing with "Provider not found" or 404 errors @@ -148,3 +148,5 @@ pnpm sim:seed 3. Trigger an API request (e.g., try to sign in) 4. Check request URLs - for single-domain SSO they should point to your web domain (not localhost) 5. Alternatively, check the Console tab for any warnings from CorpSim about misconfigured URLs + + diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf index 6d308243..3fe5e778 100644 --- a/docs/project/corpsim.altitude.nginx.conf +++ b/docs/project/corpsim.altitude.nginx.conf @@ -1,21 +1,21 @@ server { listen 80; listen [::]:80; - server_name corpsim.altitude-interactive.com; + server_name app.example.com; return 301 https://$host$request_uri; } server { listen 80; listen [::]:80; - server_name corpsim-api.altitude-interactive.com; - return 301 https://corpsim.altitude-interactive.com$request_uri; + server_name api.example.com; + return 301 https://app.example.com$request_uri; } server { listen 443 ssl http2; listen [::]:443 ssl http2; - server_name corpsim.altitude-interactive.com; + server_name app.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; @@ -43,10 +43,10 @@ server { server { listen 443 ssl http2; listen [::]:443 ssl http2; - server_name corpsim-api.altitude-interactive.com; + server_name api.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; - return 308 https://corpsim.altitude-interactive.com$request_uri; + return 308 https://app.example.com$request_uri; } From e547a831ac927bccfd3cca232a47a2e7a4e59c50 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:40:49 +0100 Subject: [PATCH 30/52] docs(ops): drop API subdomain blocks from nginx sample --- ...egacy-api-subdomain-blocks-from-nginx-sa.md | 7 +++++++ docs/project/corpsim.altitude.nginx.conf | 18 ------------------ 2 files changed, 7 insertions(+), 18 deletions(-) create mode 100644 .releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md diff --git a/.releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md b/.releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md new file mode 100644 index 00000000..e29385b4 --- /dev/null +++ b/.releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md @@ -0,0 +1,7 @@ +--- +type: patch +area: ops +summary: Remove legacy API subdomain blocks from nginx sample +--- + +- Remove legacy API subdomain blocks from nginx sample diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf index 3fe5e778..1e4fba7d 100644 --- a/docs/project/corpsim.altitude.nginx.conf +++ b/docs/project/corpsim.altitude.nginx.conf @@ -5,13 +5,6 @@ server { return 301 https://$host$request_uri; } -server { - listen 80; - listen [::]:80; - server_name api.example.com; - return 301 https://app.example.com$request_uri; -} - server { listen 443 ssl http2; listen [::]:443 ssl http2; @@ -39,14 +32,3 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } } - -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name api.example.com; - - ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; - - return 308 https://app.example.com$request_uri; -} From 926b2727f09e1f2c62f48afb76b167d48cf007ac Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:45:13 +0100 Subject: [PATCH 31/52] feat(web): add ALPHA preview disclaimer to footer version badge --- ...alpha-as-is-disclaimer-next-to-footer-versio.md | 7 +++++++ .../src/components/layout/app-version-badge.tsx | 14 +++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 .releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md diff --git a/.releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md b/.releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md new file mode 100644 index 00000000..ddf9eef7 --- /dev/null +++ b/.releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Add ALPHA as-is disclaimer next to footer version badge +--- + +- Add ALPHA as-is disclaimer next to footer version badge diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx index 1a4f18c4..0580cb7b 100644 --- a/apps/web/src/components/layout/app-version-badge.tsx +++ b/apps/web/src/components/layout/app-version-badge.tsx @@ -32,9 +32,13 @@ export function AppVersionBadge({ className }: { className?: string }) { }; }, []); - if (!version) { - return null; - } - - return

CorpSim ERP v{version}

; + return ( +
+

{version ? `CorpSim ERP v${version}` : "CorpSim ERP"} · ALPHA

+

+ Preview build provided as-is with no player support. Data may be reset or wiped at any time + until beta. +

+
+ ); } From e5197b71035dd10a971a9d999035636f62e39766 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:48:17 +0100 Subject: [PATCH 32/52] feat(web): show alpha notice on version hover and overview --- .env.example | 1 + ...a-disclaimer-on-version-hover-and-overv.md | 7 ++++ .../components/layout/app-version-badge.tsx | 15 ++++--- .../views/overview/overview-view.tsx | 40 +++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 .releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md diff --git a/.env.example b/.env.example index fc1b1aa7..cfa73214 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:4311 NEXT_PUBLIC_AUTH_URL= NEXT_PUBLIC_API_URL=http://localhost:4310 NEXT_PUBLIC_APP_NAME=CorpSim +NEXT_PUBLIC_DISCORD_SERVER_URL= NEXT_PUBLIC_COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4 # SSO provider visibility flags (defaults to false if not set) # Set to 'true' to show SSO login buttons for each provider diff --git a/.releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md b/.releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md new file mode 100644 index 00000000..3c991827 --- /dev/null +++ b/.releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Show ALPHA disclaimer on version hover and overview with Discord link +--- + +- Show ALPHA disclaimer on version hover and overview with Discord link diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx index 0580cb7b..bed6db2a 100644 --- a/apps/web/src/components/layout/app-version-badge.tsx +++ b/apps/web/src/components/layout/app-version-badge.tsx @@ -33,12 +33,15 @@ export function AppVersionBadge({ className }: { className?: string }) { }, []); return ( -
-

{version ? `CorpSim ERP v${version}` : "CorpSim ERP"} · ALPHA

-

- Preview build provided as-is with no player support. Data may be reset or wiped at any time - until beta. -

+
+ + {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} · ALPHA + (hover) + + Preview build provided as-is with no player support. Data may be reset or wiped at any + time until beta. + +
); } diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx index 639b11e1..90923b12 100644 --- a/apps/web/src/components/views/overview/overview-view.tsx +++ b/apps/web/src/components/views/overview/overview-view.tsx @@ -9,8 +9,26 @@ import { formatCents, formatInt } from "@/lib/format"; import { UI_CADENCE_TERMS } from "@/lib/ui-terms"; import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy"; +function resolveDiscordServerUrl(): string | null { + const raw = process.env.NEXT_PUBLIC_DISCORD_SERVER_URL?.trim(); + if (!raw) { + return null; + } + + try { + const parsed = new URL(raw); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + export function OverviewView() { const { health } = useWorldHealth(); + const discordServerUrl = resolveDiscordServerUrl(); if (!health) { return
Loading overview metrics...
; @@ -67,6 +85,28 @@ export function OverviewView() { + + +
+ Alpha Preview Notice + ALPHA +
+
+ +

+ This preview is provided as-is with no player support. Progress and economy data may be + reset or wiped at any time until beta. +

+ {discordServerUrl ? ( + + ) : null} +
+
+ {UI_COPY.documentation.title} From 1dc209d39169c0b147daf61b4335d11387334542 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:51:34 +0100 Subject: [PATCH 33/52] fix(web): remove hover helper text from version badge --- ...2165119-remove-hover-helper-label-from-version-badge.md | 7 +++++++ apps/web/src/components/layout/app-version-badge.tsx | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md diff --git a/.releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md b/.releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md new file mode 100644 index 00000000..97c3714c --- /dev/null +++ b/.releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Remove hover helper label from version badge +--- + +- Remove hover helper label from version badge diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx index bed6db2a..4f22259c 100644 --- a/apps/web/src/components/layout/app-version-badge.tsx +++ b/apps/web/src/components/layout/app-version-badge.tsx @@ -35,8 +35,7 @@ export function AppVersionBadge({ className }: { className?: string }) { return (
- {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} · ALPHA - (hover) + {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA Preview build provided as-is with no player support. Data may be reset or wiped at any time until beta. From cbb511f47d91d4cf6db896af45a8865625cae118 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:53:04 +0100 Subject: [PATCH 34/52] fix(web): remove focus ring box from maintenance overlay --- ...5251-remove-maintenance-overlay-focus-ring-rectangle.md | 7 +++++++ .../web/src/components/maintenance/maintenance-overlay.tsx | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md diff --git a/.releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md b/.releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md new file mode 100644 index 00000000..95ae19e2 --- /dev/null +++ b/.releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Remove maintenance overlay focus ring rectangle +--- + +- Remove maintenance overlay focus ring rectangle diff --git a/apps/web/src/components/maintenance/maintenance-overlay.tsx b/apps/web/src/components/maintenance/maintenance-overlay.tsx index ca5d655b..fa5c4b72 100644 --- a/apps/web/src/components/maintenance/maintenance-overlay.tsx +++ b/apps/web/src/components/maintenance/maintenance-overlay.tsx @@ -98,7 +98,11 @@ export function MaintenanceOverlay({ state }: { state: MaintenanceState }) { labelledBy="maintenance-title" describedBy="maintenance-description" > -
+

Operations notice

CorpSim is under maintenance From 452b60573d8e482ec5fa244d8034b100555baa22 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:54:55 +0100 Subject: [PATCH 35/52] feat(web): link alpha version badge to Discord updates --- ...a-version-tag-clickable-to-discord-and-.md | 7 ++++++ .../components/layout/app-version-badge.tsx | 22 +++++++++++++++---- .../views/overview/overview-view.tsx | 20 ++--------------- apps/web/src/lib/public-links.ts | 20 +++++++++++++++++ 4 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 .releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md create mode 100644 apps/web/src/lib/public-links.ts diff --git a/.releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md b/.releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md new file mode 100644 index 00000000..8de08551 --- /dev/null +++ b/.releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Make ALPHA version tag clickable to Discord and share URL resolver +--- + +- Make ALPHA version tag clickable to Discord and share URL resolver diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx index 4f22259c..cf12b5ea 100644 --- a/apps/web/src/components/layout/app-version-badge.tsx +++ b/apps/web/src/components/layout/app-version-badge.tsx @@ -1,11 +1,13 @@ "use client"; import { useEffect, useState } from "react"; +import { getDiscordServerUrl } from "@/lib/public-links"; import { getDisplayVersion } from "@/lib/version"; import { cn } from "@/lib/utils"; export function AppVersionBadge({ className }: { className?: string }) { const [version, setVersion] = useState(null); + const discordServerUrl = getDiscordServerUrl(); useEffect(() => { let active = true; @@ -34,11 +36,23 @@ export function AppVersionBadge({ className }: { className?: string }) { return (
- - {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA + + {discordServerUrl ? ( + + {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA + + ) : ( + {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA + )} - Preview build provided as-is with no player support. Data may be reset or wiped at any - time until beta. + Preview build provided as-is with no player support. Data may be reset or wiped at any time until beta. + {discordServerUrl ? " Click the version tag to join Discord updates." : ""}
diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx index 90923b12..149b63c4 100644 --- a/apps/web/src/components/views/overview/overview-view.tsx +++ b/apps/web/src/components/views/overview/overview-view.tsx @@ -6,29 +6,13 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { formatCents, formatInt } from "@/lib/format"; +import { getDiscordServerUrl } from "@/lib/public-links"; import { UI_CADENCE_TERMS } from "@/lib/ui-terms"; import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy"; -function resolveDiscordServerUrl(): string | null { - const raw = process.env.NEXT_PUBLIC_DISCORD_SERVER_URL?.trim(); - if (!raw) { - return null; - } - - try { - const parsed = new URL(raw); - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - return null; - } - return parsed.toString(); - } catch { - return null; - } -} - export function OverviewView() { const { health } = useWorldHealth(); - const discordServerUrl = resolveDiscordServerUrl(); + const discordServerUrl = getDiscordServerUrl(); if (!health) { return
Loading overview metrics...
; diff --git a/apps/web/src/lib/public-links.ts b/apps/web/src/lib/public-links.ts new file mode 100644 index 00000000..6b41e1e9 --- /dev/null +++ b/apps/web/src/lib/public-links.ts @@ -0,0 +1,20 @@ +export function resolveOptionalPublicUrl(value: string | undefined): string | null { + const raw = value?.trim(); + if (!raw) { + return null; + } + + try { + const parsed = new URL(raw); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + +export function getDiscordServerUrl(): string | null { + return resolveOptionalPublicUrl(process.env.NEXT_PUBLIC_DISCORD_SERVER_URL); +} From e71440631a6888182cc144a1d3ac0dd2dedbae77 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 17:59:26 +0100 Subject: [PATCH 36/52] fix(web): fetch Discord URL via runtime public-links endpoint --- ...ord-link-from-runtime-meta-config-for-a.md | 7 +++ apps/web/app/meta/public-links/route.ts | 53 +++++++++++++++++++ .../components/layout/app-version-badge.tsx | 24 +++++++-- .../views/overview/overview-view.tsx | 25 +++++++-- apps/web/src/lib/public-links.ts | 31 +++++++++++ 5 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 .releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md create mode 100644 apps/web/app/meta/public-links/route.ts diff --git a/.releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md b/.releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md new file mode 100644 index 00000000..a0d33c4d --- /dev/null +++ b/.releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Load Discord link from runtime meta config for alpha notices +--- + +- Load Discord link from runtime meta config for alpha notices diff --git a/apps/web/app/meta/public-links/route.ts b/apps/web/app/meta/public-links/route.ts new file mode 100644 index 00000000..f0fddd25 --- /dev/null +++ b/apps/web/app/meta/public-links/route.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { config } from "dotenv"; +import { NextResponse } from "next/server"; +import { getDiscordServerUrl } from "@/lib/public-links"; + +let loadedEnv = false; + +function ensureEnvironmentLoaded(): void { + if (loadedEnv) { + return; + } + + if (process.env.NEXT_PUBLIC_DISCORD_SERVER_URL) { + loadedEnv = true; + return; + } + + const candidates = [ + process.env.DOTENV_PATH, + resolve(process.cwd(), ".env"), + resolve(process.cwd(), "../.env"), + resolve(process.cwd(), "../../.env") + ].filter((entry): entry is string => Boolean(entry)); + + for (const path of candidates) { + if (!existsSync(path)) { + continue; + } + config({ path, override: false }); + break; + } + + loadedEnv = true; +} + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + ensureEnvironmentLoaded(); + + return NextResponse.json( + { + discordServerUrl: getDiscordServerUrl() + }, + { + headers: { + "Cache-Control": "no-store" + } + } + ); +} diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx index cf12b5ea..e98470f4 100644 --- a/apps/web/src/components/layout/app-version-badge.tsx +++ b/apps/web/src/components/layout/app-version-badge.tsx @@ -1,13 +1,13 @@ "use client"; import { useEffect, useState } from "react"; -import { getDiscordServerUrl } from "@/lib/public-links"; +import { fetchDiscordServerUrlFromMeta, getDiscordServerUrl } from "@/lib/public-links"; import { getDisplayVersion } from "@/lib/version"; import { cn } from "@/lib/utils"; export function AppVersionBadge({ className }: { className?: string }) { const [version, setVersion] = useState(null); - const discordServerUrl = getDiscordServerUrl(); + const [discordServerUrl, setDiscordServerUrl] = useState(() => getDiscordServerUrl()); useEffect(() => { let active = true; @@ -34,6 +34,24 @@ export function AppVersionBadge({ className }: { className?: string }) { }; }, []); + useEffect(() => { + if (discordServerUrl) { + return; + } + + const controller = new AbortController(); + void fetchDiscordServerUrlFromMeta(controller.signal).then((url) => { + if (!url) { + return; + } + setDiscordServerUrl(url); + }); + + return () => { + controller.abort(); + }; + }, [discordServerUrl]); + return (
@@ -52,7 +70,7 @@ export function AppVersionBadge({ className }: { className?: string }) { )} Preview build provided as-is with no player support. Data may be reset or wiped at any time until beta. - {discordServerUrl ? " Click the version tag to join Discord updates." : ""} + {discordServerUrl ? " Click the version tag to join our Discord server for updates." : ""}
diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx index 149b63c4..dc7291ed 100644 --- a/apps/web/src/components/views/overview/overview-view.tsx +++ b/apps/web/src/components/views/overview/overview-view.tsx @@ -1,18 +1,37 @@ "use client"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { formatCents, formatInt } from "@/lib/format"; -import { getDiscordServerUrl } from "@/lib/public-links"; +import { fetchDiscordServerUrlFromMeta, getDiscordServerUrl } from "@/lib/public-links"; import { UI_CADENCE_TERMS } from "@/lib/ui-terms"; import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy"; export function OverviewView() { const { health } = useWorldHealth(); - const discordServerUrl = getDiscordServerUrl(); + const [discordServerUrl, setDiscordServerUrl] = useState(() => getDiscordServerUrl()); + + useEffect(() => { + if (discordServerUrl) { + return; + } + + const controller = new AbortController(); + void fetchDiscordServerUrlFromMeta(controller.signal).then((url) => { + if (!url) { + return; + } + setDiscordServerUrl(url); + }); + + return () => { + controller.abort(); + }; + }, [discordServerUrl]); if (!health) { return
Loading overview metrics...
; @@ -84,7 +103,7 @@ export function OverviewView() { {discordServerUrl ? ( ) : null} diff --git a/apps/web/src/lib/public-links.ts b/apps/web/src/lib/public-links.ts index 6b41e1e9..7863dc74 100644 --- a/apps/web/src/lib/public-links.ts +++ b/apps/web/src/lib/public-links.ts @@ -18,3 +18,34 @@ export function resolveOptionalPublicUrl(value: string | undefined): string | nu export function getDiscordServerUrl(): string | null { return resolveOptionalPublicUrl(process.env.NEXT_PUBLIC_DISCORD_SERVER_URL); } + +interface PublicLinksPayload { + discordServerUrl?: string | null; +} + +export async function fetchDiscordServerUrlFromMeta(signal?: AbortSignal): Promise { + try { + const response = await fetch("/meta/public-links", { + cache: "no-store", + signal + }); + + if (!response.ok) { + return null; + } + + const payload = (await response.json().catch(() => null)) as PublicLinksPayload | null; + if (!payload || typeof payload !== "object") { + return null; + } + + const raw = payload.discordServerUrl; + if (typeof raw !== "string") { + return null; + } + + return resolveOptionalPublicUrl(raw); + } catch { + return null; + } +} From 367c7a9fe788f99d9d8987de62d003da2c93dce5 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 18:03:04 +0100 Subject: [PATCH 37/52] fix(docs): add guideline to avoid commits to main branch --- docs/agents/AGENTS.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/agents/AGENTS.md b/docs/agents/AGENTS.md index d3469db9..c409bda8 100644 --- a/docs/agents/AGENTS.md +++ b/docs/agents/AGENTS.md @@ -101,6 +101,7 @@ Create one release entry in `.releases/unreleased/*.md` including: * Do NOT bump root `package.json` version during normal commits * Do NOT create git tags * Do NOT bump version if remote already has the same version tag +* NEVER COMMIT TO MAIN, suggest committing to a new dedicated branch (or canary) * Version bump occurs only in a dedicated release-cut commit (`pnpm release:cut`) * Exception: explicitly requested emergency hotfix release @@ -357,15 +358,15 @@ import { useToastManager } from '@/components/ui/toast-manager'; const { showToast, confirmPopup } = useToastManager(); // Show toast -showToast({ - title: "Order placed", - variant: "success" +showToast({ + title: "Order placed", + variant: "success" }); // Confirmation dialog -const confirmed = await confirmPopup({ +const confirmed = await confirmPopup({ title: "Cancel order?", - variant: "danger" + variant: "danger" }); ``` From bcced69e11950997d38dde2311d380fa02381f1e Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 18:08:54 +0100 Subject: [PATCH 38/52] feat(web): replace static onboarding tutorial with guided walkthrough --- ...tatic-onboarding-tutorial-with-guided-c.md | 7 + apps/web/app/tutorial/page.tsx | 106 ++----- .../src/components/auth/auth-route-gate.tsx | 20 +- .../components/inventory/inventory-page.tsx | 4 +- apps/web/src/components/layout/app-shell.tsx | 2 + .../web/src/components/market/market-page.tsx | 30 +- .../components/production/production-page.tsx | 4 +- .../tutorial/guided-tutorial-overlay.tsx | 297 ++++++++++++++++++ .../views/overview/overview-view.tsx | 6 +- 9 files changed, 367 insertions(+), 109 deletions(-) create mode 100644 .releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md create mode 100644 apps/web/src/components/tutorial/guided-tutorial-overlay.tsx diff --git a/.releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md b/.releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md new file mode 100644 index 00000000..f06b2b61 --- /dev/null +++ b/.releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md @@ -0,0 +1,7 @@ +--- +type: minor +area: web +summary: Replace static onboarding tutorial with guided cross-page walkthrough +--- + +- Replace static onboarding tutorial with guided cross-page walkthrough diff --git a/apps/web/app/tutorial/page.tsx b/apps/web/app/tutorial/page.tsx index ad57768a..d39fe900 100644 --- a/apps/web/app/tutorial/page.tsx +++ b/apps/web/app/tutorial/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -8,50 +8,7 @@ import { AuthPageShell } from "@/components/auth/auth-page-shell"; import { completeOnboardingTutorial, getOnboardingStatus } from "@/lib/api"; import { getDocumentationUrl } from "@/lib/ui-copy"; -interface TutorialStep { - title: string; - summary: string; - bullets: string[]; -} - -const TUTORIAL_STEPS: TutorialStep[] = [ - { - title: "Welcome to CorpSim", - summary: "You run a company in a living market. Every choice affects your growth.", - bullets: [ - "You produce goods, trade them, and grow your company over time.", - "Markets move as companies buy and sell, so timing matters.", - "Plan ahead: cash, stock, and production speed all work together." - ] - }, - { - title: "How the Economy Works", - summary: "Buy low, sell smart, and keep enough stock to avoid downtime.", - bullets: [ - "Production turns raw materials into higher-value products.", - "If demand is high and supply is low, prices usually rise.", - "Keep reserve cash so you can react quickly to opportunities." - ] - }, - { - title: "Core Features", - summary: "These pages are your daily tools for running the company.", - bullets: [ - "Production: start jobs and keep lines running.", - "Market and Contracts: buy inputs, sell outputs, and secure deals.", - "Finance, Research, and Logistics: track cash, unlock upgrades, and move goods." - ] - }, - { - title: "Documentation", - summary: "Use the docs anytime for walkthroughs and page-by-page help.", - bullets: [ - "Open the docs from the top bar or sidebar while playing.", - "Follow module guides to learn efficient production loops.", - "Use references when you need exact steps for a feature." - ] - } -]; +const GUIDED_TUTORIAL_TOTAL_STEPS = 8; function readErrorMessage(error: unknown): string { if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { @@ -65,7 +22,6 @@ export default function TutorialPage() { const [isLoading, setLoading] = useState(true); const [isSubmitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [stepIndex, setStepIndex] = useState(0); useEffect(() => { let active = true; @@ -104,15 +60,7 @@ export default function TutorialPage() { }; }, [router]); - const step = useMemo(() => TUTORIAL_STEPS[stepIndex], [stepIndex]); - const isLastStep = stepIndex >= TUTORIAL_STEPS.length - 1; - - async function handleContinue() { - if (!isLastStep) { - setStepIndex((current) => Math.min(current + 1, TUTORIAL_STEPS.length - 1)); - return; - } - + async function handleSkipTutorial() { if (isSubmitting) { return; } @@ -130,18 +78,22 @@ export default function TutorialPage() { } } + function handleStartTour() { + router.replace("/overview?tutorial=1&tutorialStep=0"); + } + if (isLoading) { return ( - -

Preparing your first steps in CorpSim.

+ +

Preparing your first guided steps.

); } return (
-

- Step {stepIndex + 1} of {TUTORIAL_STEPS.length} -

-

{step.title}

-

{step.summary}

+

What to expect

+

+ You will be guided through Overview, Market, Production, and Inventory with focused + highlights on the exact sections that matter first. +

    - {step.bullets.map((bullet) => ( -
  • - {bullet}
  • - ))} +
  • - {GUIDED_TUTORIAL_TOTAL_STEPS} short guided steps across core pages.
  • +
  • - Each step highlights one UI section and tells you what to do there.
  • +
  • - You can skip now and still continue directly to the dashboard.
- {isLastStep ? ( -

- Ready to play? You can open the docs now or continue to the dashboard. -

- ) : null}
{error ? {error} : null} -
- -
diff --git a/apps/web/src/components/auth/auth-route-gate.tsx b/apps/web/src/components/auth/auth-route-gate.tsx index 95e7dc88..3dfebfa1 100644 --- a/apps/web/src/components/auth/auth-route-gate.tsx +++ b/apps/web/src/components/auth/auth-route-gate.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { OnboardingStatus } from "@corpsim/shared"; import { getOnboardingStatus } from "@/lib/api"; import { authClient } from "@/lib/auth-client"; @@ -16,6 +16,8 @@ import { } from "@/lib/auth-routes"; import { isAdminRole, isModeratorRole, isStaffRole } from "@/lib/roles"; +const GUIDED_TUTORIAL_ALLOWED_PAGES = new Set(["/overview", "/market", "/production", "/inventory"]); + function resolveSafeNextPath(raw: string | null): string | null { if (!raw || !raw.startsWith("/")) { return null; @@ -36,13 +38,13 @@ function FullscreenMessage({ message }: { message: string }) { export function AuthRouteGate({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const searchParams = useSearchParams(); const router = useRouter(); - const nextPathFromQuery = (() => { - if (typeof window === "undefined") { - return null; - } - return resolveSafeNextPath(new URLSearchParams(window.location.search).get("next")); - })(); + const nextPathFromQuery = useMemo( + () => resolveSafeNextPath(searchParams.get("next")), + [searchParams] + ); + const isGuidedTutorialMode = searchParams.get("tutorial") === "1"; const { data: session, isPending } = authClient.useSession(); const [onboardingStatus, setOnboardingStatus] = useState(null); const [onboardingStatusPathname, setOnboardingStatusPathname] = useState(null); @@ -149,6 +151,9 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) { if (isTutorialPage(pathname) || isProfilePage(pathname)) { return null; } + if (isGuidedTutorialMode && GUIDED_TUTORIAL_ALLOWED_PAGES.has(pathname)) { + return null; + } return "/tutorial"; } @@ -164,6 +169,7 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) { onboardingStatus, onboardingStatusPathname, pathname, + isGuidedTutorialMode, session?.user?.id ]); diff --git a/apps/web/src/components/inventory/inventory-page.tsx b/apps/web/src/components/inventory/inventory-page.tsx index b21f3dec..ce576f74 100644 --- a/apps/web/src/components/inventory/inventory-page.tsx +++ b/apps/web/src/components/inventory/inventory-page.tsx @@ -168,7 +168,7 @@ export function InventoryPage() { return (
- + Inventory @@ -265,7 +265,7 @@ export function InventoryPage() { - + diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index adfad978..ed106b67 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -15,6 +15,7 @@ import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; import { useWorldHealth } from "./world-health-provider"; import { isAuthPage, isOnboardingPage, isProfilePage, isTutorialPage } from "@/lib/auth-routes"; +import { GuidedTutorialOverlay } from "@/components/tutorial/guided-tutorial-overlay"; export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -95,6 +96,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { +
diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx index 58d46f3c..405d42a8 100644 --- a/apps/web/src/components/market/market-page.tsx +++ b/apps/web/src/components/market/market-page.tsx @@ -509,12 +509,14 @@ export function MarketPage() { return (
- +
+ +
@@ -638,13 +640,15 @@ export function MarketPage() {
- +
+ +
- + Start Production @@ -672,7 +672,7 @@ export function ProductionPage() {
- + Recipes diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx new file mode 100644 index 00000000..1b127ccb --- /dev/null +++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { completeOnboardingTutorial } from "@/lib/api"; +import { Button } from "@/components/ui/button"; + +interface GuidedTutorialStep { + route: string; + targetId: string; + title: string; + description: string; +} + +const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [ + { + route: "/overview", + targetId: "overview-kpis", + title: "Start with your company pulse", + description: "These metrics are your quick status check before taking any action." + }, + { + route: "/overview", + targetId: "overview-integrity", + title: "Watch system integrity", + description: "If there are issues here, investigate before scaling operations." + }, + { + route: "/market", + targetId: "market-order-placement", + title: "Place buy and sell orders", + description: "This is where you create market orders for the active company." + }, + { + route: "/market", + targetId: "market-order-book", + title: "Read the order book", + description: "Use this table to inspect current prices, quantity, and market depth." + }, + { + route: "/production", + targetId: "production-start", + title: "Start production runs", + description: "Pick a recipe and quantity, then launch jobs from this panel." + }, + { + route: "/production", + targetId: "production-recipes", + title: "Review recipes first", + description: "Check output, duration, and required inputs before committing runs." + }, + { + route: "/inventory", + targetId: "inventory-filters", + title: "Filter your inventory view", + description: "Search and region filters help you focus the exact stock you need." + }, + { + route: "/inventory", + targetId: "inventory-table", + title: "Track available stock", + description: "Use quantity, reserved, and available values to avoid production stalls." + } +]; + +const SPOTLIGHT_PADDING_PX = 8; +const CARD_ESTIMATED_HEIGHT_PX = 220; +const CARD_MAX_WIDTH_PX = 360; + +function clampStepIndex(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(Math.max(Math.trunc(value), 0), GUIDED_TUTORIAL_STEPS.length - 1); +} + +function buildTutorialHref(route: string, stepIndex: number): string { + const params = new URLSearchParams(); + params.set("tutorial", "1"); + params.set("tutorialStep", String(stepIndex)); + return `${route}?${params.toString()}`; +} + +function readErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return "Unable to finish tutorial right now. Please try again."; +} + +export function GuidedTutorialOverlay() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + const isTutorialActive = searchParams.get("tutorial") === "1"; + const stepIndex = useMemo( + () => clampStepIndex(Number.parseInt(searchParams.get("tutorialStep") ?? "0", 10)), + [searchParams] + ); + const step = GUIDED_TUTORIAL_STEPS[stepIndex]; + const [targetRect, setTargetRect] = useState(null); + const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); + const [isSubmitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const isFirstStep = stepIndex === 0; + const isLastStep = stepIndex >= GUIDED_TUTORIAL_STEPS.length - 1; + + useEffect(() => { + if (!isTutorialActive) { + return; + } + + const updateViewport = () => + setViewportSize({ width: window.innerWidth, height: window.innerHeight }); + + updateViewport(); + window.addEventListener("resize", updateViewport); + return () => window.removeEventListener("resize", updateViewport); + }, [isTutorialActive]); + + useEffect(() => { + if (!isTutorialActive) { + return; + } + + if (pathname !== step.route) { + router.replace(buildTutorialHref(step.route, stepIndex)); + } + }, [isTutorialActive, pathname, router, step.route, stepIndex]); + + useEffect(() => { + if (!isTutorialActive || pathname !== step.route) { + setTargetRect(null); + return; + } + + const query = `[data-tutorial-id="${step.targetId}"]`; + const updateRect = () => { + const element = document.querySelector(query); + if (!element) { + setTargetRect(null); + return; + } + setTargetRect(element.getBoundingClientRect()); + }; + + updateRect(); + const interval = window.setInterval(updateRect, 250); + window.addEventListener("scroll", updateRect, true); + window.addEventListener("resize", updateRect); + + return () => { + window.clearInterval(interval); + window.removeEventListener("scroll", updateRect, true); + window.removeEventListener("resize", updateRect); + }; + }, [isTutorialActive, pathname, step.route, step.targetId]); + + const spotlightStyle = useMemo(() => { + if (!targetRect) { + return null; + } + + const top = Math.max(8, targetRect.top - SPOTLIGHT_PADDING_PX); + const left = Math.max(8, targetRect.left - SPOTLIGHT_PADDING_PX); + const width = targetRect.width + SPOTLIGHT_PADDING_PX * 2; + const height = targetRect.height + SPOTLIGHT_PADDING_PX * 2; + + return { + top, + left, + width, + height + }; + }, [targetRect]); + + const cardStyle = useMemo(() => { + if (!targetRect || viewportSize.width <= 0 || viewportSize.height <= 0) { + return { + right: 16, + bottom: 16, + width: CARD_MAX_WIDTH_PX + }; + } + + let top = targetRect.bottom + 12; + if (top + CARD_ESTIMATED_HEIGHT_PX > viewportSize.height - 16) { + top = targetRect.top - CARD_ESTIMATED_HEIGHT_PX - 12; + } + top = Math.max(16, top); + + let left = targetRect.left; + const width = Math.min(CARD_MAX_WIDTH_PX, Math.max(280, viewportSize.width - 32)); + if (left + width > viewportSize.width - 16) { + left = viewportSize.width - width - 16; + } + left = Math.max(16, left); + + return { + top, + left, + width + }; + }, [targetRect, viewportSize.height, viewportSize.width]); + + const goToStep = useCallback( + (nextStepIndex: number) => { + const clampedIndex = clampStepIndex(nextStepIndex); + const nextStep = GUIDED_TUTORIAL_STEPS[clampedIndex]; + setError(null); + router.replace(buildTutorialHref(nextStep.route, clampedIndex)); + }, + [router] + ); + + const completeTutorial = useCallback(async () => { + if (isSubmitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await completeOnboardingTutorial(); + router.replace("/overview"); + router.refresh(); + } catch (caught) { + setError(readErrorMessage(caught)); + } finally { + setSubmitting(false); + } + }, [isSubmitting, router]); + + if (!isTutorialActive) { + return null; + } + + return ( +
+ {spotlightStyle ? ( +
+ ) : ( +
+ )} + +
+

+ Guided Tutorial {stepIndex + 1}/{GUIDED_TUTORIAL_STEPS.length} +

+

{step.title}

+

{step.description}

+ + {targetRect ? null : ( +

+ Waiting for this section to load on the page. +

+ )} + + {error ?

{error}

: null} + +
+ + +
+ + {isLastStep ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx index dc7291ed..fedc796b 100644 --- a/apps/web/src/components/views/overview/overview-view.tsx +++ b/apps/web/src/components/views/overview/overview-view.tsx @@ -48,7 +48,7 @@ export function OverviewView() { return (
-
+
{kpis.map((kpi) => ( @@ -61,7 +61,7 @@ export function OverviewView() { ))}
- +
System Integrity @@ -110,7 +110,7 @@ export function OverviewView() { - + {UI_COPY.documentation.title} From 459657a7e99e070cee75afe361d092ce42c78d72 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 18:15:54 +0100 Subject: [PATCH 39/52] fix(web): clarify overview metrics as world-level --- ...clarify-overview-metrics-and-tutorial-copy-as-wo.md | 7 +++++++ .../components/tutorial/guided-tutorial-overlay.tsx | 5 +++-- .../src/components/views/overview/overview-view.tsx | 10 +++++----- 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 .releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md diff --git a/.releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md b/.releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md new file mode 100644 index 00000000..ae78b3fb --- /dev/null +++ b/.releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Clarify overview metrics and tutorial copy as world-level values +--- + +- Clarify overview metrics and tutorial copy as world-level values diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx index 1b127ccb..1697ce3a 100644 --- a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx +++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx @@ -16,8 +16,9 @@ const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [ { route: "/overview", targetId: "overview-kpis", - title: "Start with your company pulse", - description: "These metrics are your quick status check before taking any action." + title: "Start with the world pulse", + description: + "These metrics summarize the full simulation (all companies), not just your company." }, { route: "/overview", diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx index fedc796b..e29f7275 100644 --- a/apps/web/src/components/views/overview/overview-view.tsx +++ b/apps/web/src/components/views/overview/overview-view.tsx @@ -39,11 +39,11 @@ export function OverviewView() { const kpis = [ { label: `Current ${UI_CADENCE_TERMS.singularTitle}`, value: formatInt(health.currentTick) }, - { label: "Open Orders", value: formatInt(health.ordersOpenCount) }, - { label: "Trades (Last 100)", value: formatInt(health.tradesLast100Count) }, - { label: "Companies", value: formatInt(health.companiesCount) }, - { label: "Total Cash", value: formatCents(health.sumCashCents) }, - { label: "Reserved Cash", value: formatCents(health.sumReservedCashCents) } + { label: "World Open Orders", value: formatInt(health.ordersOpenCount) }, + { label: "World Trades (Last 100)", value: formatInt(health.tradesLast100Count) }, + { label: "World Companies", value: formatInt(health.companiesCount) }, + { label: "World Total Cash", value: formatCents(health.sumCashCents) }, + { label: "World Reserved Cash", value: formatCents(health.sumReservedCashCents) } ]; return ( From e806a0444f578af34ed970f39a3d061df5627983 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 18:19:10 +0100 Subject: [PATCH 40/52] feat(web): begin guided tutorial with active company snapshot --- ...ded-tutorial-with-active-company-snapsh.md | 7 ++ apps/web/app/tutorial/page.tsx | 3 +- .../tutorial/guided-tutorial-overlay.tsx | 60 +---------------- .../tutorial/guided-tutorial-steps.ts | 65 +++++++++++++++++++ .../views/overview/overview-view.tsx | 32 ++++++++- 5 files changed, 106 insertions(+), 61 deletions(-) create mode 100644 .releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md create mode 100644 apps/web/src/components/tutorial/guided-tutorial-steps.ts diff --git a/.releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md b/.releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md new file mode 100644 index 00000000..581cb865 --- /dev/null +++ b/.releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md @@ -0,0 +1,7 @@ +--- +type: patch +area: web +summary: Start guided tutorial with active company snapshot before world KPIs +--- + +- Start guided tutorial with active company snapshot before world KPIs diff --git a/apps/web/app/tutorial/page.tsx b/apps/web/app/tutorial/page.tsx index d39fe900..d2420d1a 100644 --- a/apps/web/app/tutorial/page.tsx +++ b/apps/web/app/tutorial/page.tsx @@ -7,8 +7,9 @@ import { Button } from "@/components/ui/button"; import { AuthPageShell } from "@/components/auth/auth-page-shell"; import { completeOnboardingTutorial, getOnboardingStatus } from "@/lib/api"; import { getDocumentationUrl } from "@/lib/ui-copy"; +import { GUIDED_TUTORIAL_STEPS } from "@/components/tutorial/guided-tutorial-steps"; -const GUIDED_TUTORIAL_TOTAL_STEPS = 8; +const GUIDED_TUTORIAL_TOTAL_STEPS = GUIDED_TUTORIAL_STEPS.length; function readErrorMessage(error: unknown): string { if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx index 1697ce3a..74b578b4 100644 --- a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx +++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx @@ -4,65 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { completeOnboardingTutorial } from "@/lib/api"; import { Button } from "@/components/ui/button"; - -interface GuidedTutorialStep { - route: string; - targetId: string; - title: string; - description: string; -} - -const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [ - { - route: "/overview", - targetId: "overview-kpis", - title: "Start with the world pulse", - description: - "These metrics summarize the full simulation (all companies), not just your company." - }, - { - route: "/overview", - targetId: "overview-integrity", - title: "Watch system integrity", - description: "If there are issues here, investigate before scaling operations." - }, - { - route: "/market", - targetId: "market-order-placement", - title: "Place buy and sell orders", - description: "This is where you create market orders for the active company." - }, - { - route: "/market", - targetId: "market-order-book", - title: "Read the order book", - description: "Use this table to inspect current prices, quantity, and market depth." - }, - { - route: "/production", - targetId: "production-start", - title: "Start production runs", - description: "Pick a recipe and quantity, then launch jobs from this panel." - }, - { - route: "/production", - targetId: "production-recipes", - title: "Review recipes first", - description: "Check output, duration, and required inputs before committing runs." - }, - { - route: "/inventory", - targetId: "inventory-filters", - title: "Filter your inventory view", - description: "Search and region filters help you focus the exact stock you need." - }, - { - route: "/inventory", - targetId: "inventory-table", - title: "Track available stock", - description: "Use quantity, reserved, and available values to avoid production stalls." - } -]; +import { GUIDED_TUTORIAL_STEPS } from "./guided-tutorial-steps"; const SPOTLIGHT_PADDING_PX = 8; const CARD_ESTIMATED_HEIGHT_PX = 220; diff --git a/apps/web/src/components/tutorial/guided-tutorial-steps.ts b/apps/web/src/components/tutorial/guided-tutorial-steps.ts new file mode 100644 index 00000000..c69d61f2 --- /dev/null +++ b/apps/web/src/components/tutorial/guided-tutorial-steps.ts @@ -0,0 +1,65 @@ +export interface GuidedTutorialStep { + route: string; + targetId: string; + title: string; + description: string; +} + +export const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [ + { + route: "/overview", + targetId: "overview-company", + title: "Confirm your active company", + description: + "This card shows your current company context. These values are company-specific." + }, + { + route: "/overview", + targetId: "overview-kpis", + title: "Read the world pulse", + description: + "These metrics summarize the full simulation (all companies), not just your company." + }, + { + route: "/overview", + targetId: "overview-integrity", + title: "Watch system integrity", + description: "If there are issues here, investigate before scaling operations." + }, + { + route: "/market", + targetId: "market-order-placement", + title: "Place buy and sell orders", + description: "This is where you create market orders for the active company." + }, + { + route: "/market", + targetId: "market-order-book", + title: "Read the order book", + description: "Use this table to inspect current prices, quantity, and market depth." + }, + { + route: "/production", + targetId: "production-start", + title: "Start production runs", + description: "Pick a recipe and quantity, then launch jobs from this panel." + }, + { + route: "/production", + targetId: "production-recipes", + title: "Review recipes first", + description: "Check output, duration, and required inputs before committing runs." + }, + { + route: "/inventory", + targetId: "inventory-filters", + title: "Filter your inventory view", + description: "Search and region filters help you focus the exact stock you need." + }, + { + route: "/inventory", + targetId: "inventory-table", + title: "Track available stock", + description: "Use quantity, reserved, and available values to avoid production stalls." + } +]; diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx index e29f7275..11286b25 100644 --- a/apps/web/src/components/views/overview/overview-view.tsx +++ b/apps/web/src/components/views/overview/overview-view.tsx @@ -5,14 +5,16 @@ import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { useActiveCompany } from "@/components/company/active-company-provider"; import { useWorldHealth } from "@/components/layout/world-health-provider"; import { formatCents, formatInt } from "@/lib/format"; import { fetchDiscordServerUrlFromMeta, getDiscordServerUrl } from "@/lib/public-links"; import { UI_CADENCE_TERMS } from "@/lib/ui-terms"; -import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy"; +import { formatCodeLabel, getDocumentationUrl, UI_COPY } from "@/lib/ui-copy"; export function OverviewView() { const { health } = useWorldHealth(); + const { activeCompany } = useActiveCompany(); const [discordServerUrl, setDiscordServerUrl] = useState(() => getDiscordServerUrl()); useEffect(() => { @@ -48,6 +50,34 @@ export function OverviewView() { return (
+ + + Active Company Snapshot + + +
+

Company

+

{activeCompany?.name ?? UI_COPY.common.noCompanySelected}

+
+
+

Region

+

{activeCompany?.regionName ?? "-"}

+
+
+

Specialization

+

+ {activeCompany ? formatCodeLabel(activeCompany.specialization) : "-"} +

+
+
+

Company Cash

+

+ {activeCompany?.cashCents ? formatCents(activeCompany.cashCents) : "Hidden/Unavailable"} +

+
+
+
+
{kpis.map((kpi) => ( From 6353f2858a3db91753aa9c043b1bfc54b8ea408b Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 18:45:29 +0100 Subject: [PATCH 41/52] fix(sim): prevent zero-trade stalls from static bot books --- ...iquidity-bot-orders-and-add-determinist.md | 7 + packages/sim/src/bots/bot-runner.ts | 141 +++++++++++++++++- .../sim/src/bots/strategies/liquidity-bot.ts | 138 ++++++++++++++--- packages/sim/tests/liquidity-bot.test.ts | 115 +++++++++++++- 4 files changed, 374 insertions(+), 27 deletions(-) create mode 100644 .releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md diff --git a/.releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md b/.releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md new file mode 100644 index 00000000..9302e396 --- /dev/null +++ b/.releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md @@ -0,0 +1,7 @@ +--- +type: patch +area: sim +summary: Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls +--- + +- Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls diff --git a/packages/sim/src/bots/bot-runner.ts b/packages/sim/src/bots/bot-runner.ts index 6992af8d..4b188dc7 100644 --- a/packages/sim/src/bots/bot-runner.ts +++ b/packages/sim/src/bots/bot-runner.ts @@ -60,11 +60,12 @@ * - Invalid state gracefully skipped * - DomainInvariantError for configuration validation */ -import { OrderSide, OrderStatus, Prisma } from "@prisma/client"; +import { BuildingStatus, BuildingType, OrderSide, OrderStatus, Prisma } from "@prisma/client"; import { resolveIconItemFallbackPriceCents } from "@corpsim/shared"; import { DomainInvariantError } from "../domain/errors"; import { availableCash } from "../domain/reservations"; -import { placeMarketOrderWithTx } from "../services/market-orders"; +import { cancelMarketOrderWithTx, placeMarketOrderWithTx } from "../services/market-orders"; +import { calculateRegionalStorageCapacity } from "../services/buildings"; import { runProducerBot } from "./strategies/producer-bot"; import { LiquidityBotConfig, @@ -89,6 +90,7 @@ export interface BotCompanySnapshot { companyCode: string; strategy: "LIQUIDITY" | "PRODUCER"; availableCashCents: bigint; + availableStorageUnits: number; items: LiquidityBotState["items"]; } @@ -208,6 +210,7 @@ export function planBotActions( const planned = planLiquidityOrders( { availableCashCents: company.availableCashCents, + availableStorageUnits: company.availableStorageUnits, items: company.items }, liquidityConfig @@ -319,8 +322,34 @@ export async function runBotsForTick( const companyIds = companies.map((entry) => entry.id); const itemIds = items.map((entry) => entry.id); + const liquidityCompanyIds = companies + .filter((entry) => inferStrategy(entry.code) === "LIQUIDITY") + .map((entry) => entry.id); - const [inventories, openOrders, bookOrders, latestTrades] = await Promise.all([ + if (liquidityCompanyIds.length > 0) { + const openLiquidityOrders = await tx.marketOrder.findMany({ + where: { + companyId: { in: liquidityCompanyIds }, + itemId: { in: itemIds }, + status: OrderStatus.OPEN + }, + orderBy: [{ id: "asc" }], + select: { + id: true, + companyId: true + } + }); + + for (const order of openLiquidityOrders) { + await cancelMarketOrderWithTx(tx, { + orderId: order.id, + companyId: order.companyId, + tick + }); + } + } + + const [inventories, openOrders, bookOrders, latestTrades, regionalInventoryTotals, regionalWarehouses] = await Promise.all([ tx.inventory.findMany({ where: { companyId: { in: companyIds }, @@ -353,6 +382,7 @@ export async function runBotsForTick( status: OrderStatus.OPEN }, select: { + companyId: true, regionId: true, itemId: true, side: true, @@ -367,6 +397,26 @@ export async function runBotsForTick( itemId: true, unitPriceCents: true } + }), + tx.inventory.groupBy({ + by: ["companyId", "regionId"], + where: { + companyId: { in: companyIds } + }, + _sum: { + quantity: true + } + }), + tx.building.groupBy({ + by: ["companyId", "regionId"], + where: { + companyId: { in: companyIds }, + buildingType: BuildingType.WAREHOUSE, + status: BuildingStatus.ACTIVE + }, + _count: { + _all: true + } }) ]); @@ -378,12 +428,40 @@ export async function runBotsForTick( const openOrderKeySet = new Set( openOrders.map((entry) => `${entry.companyId}:${entry.regionId}:${entry.itemId}:${entry.side}`) ); + const regionalInventoryTotalByCompanyRegion = new Map( + regionalInventoryTotals.map((entry) => [ + `${entry.companyId}:${entry.regionId}`, + entry._sum.quantity ?? 0 + ]) + ); + const regionalWarehouseCountByCompanyRegion = new Map( + regionalWarehouses.map((entry) => [ + `${entry.companyId}:${entry.regionId}`, + entry._count._all + ]) + ); const bestBuyPriceByRegionItem = new Map(); const bestSellPriceByRegionItem = new Map(); + const bookOrdersByRegionItem = new Map< + string, + Array<{ + companyId: string; + side: OrderSide; + unitPriceCents: bigint; + }> + >(); for (const order of bookOrders) { const key = `${order.regionId}:${order.itemId}`; + const byRegionItem = bookOrdersByRegionItem.get(key) ?? []; + byRegionItem.push({ + companyId: order.companyId, + side: order.side, + unitPriceCents: order.unitPriceCents + }); + bookOrdersByRegionItem.set(key, byRegionItem); + if (order.side === OrderSide.BUY) { const current = bestBuyPriceByRegionItem.get(key); if (current === undefined || order.unitPriceCents > current) { @@ -409,6 +487,11 @@ export async function runBotsForTick( const referencePriceByCompanyId = new Map>(); const companySnapshots: BotCompanySnapshot[] = companies.map((company) => { + const regionKey = `${company.id}:${company.regionId}`; + const regionalInventoryTotal = regionalInventoryTotalByCompanyRegion.get(regionKey) ?? 0; + const regionalWarehouseCount = regionalWarehouseCountByCompanyRegion.get(regionKey) ?? 0; + const regionalStorageCapacity = calculateRegionalStorageCapacity(regionalWarehouseCount); + const availableStorageUnits = Math.max(0, regionalStorageCapacity - regionalInventoryTotal); const referencePriceByItemId = new Map( items.map((item) => [ item.id, @@ -432,6 +515,51 @@ export async function runBotsForTick( 0, (companyInventory?.quantity ?? 0) - (companyInventory?.reservedQuantity ?? 0) ); + const externalBookOrders = + bookOrdersByRegionItem.get(`${company.regionId}:${item.id}`) ?? []; + let bestExternalBuyPriceCents: bigint | null = null; + let bestExternalSellPriceCents: bigint | null = null; + let bestOwnBuyPriceCents: bigint | null = null; + let bestOwnSellPriceCents: bigint | null = null; + + for (const order of externalBookOrders) { + if (order.companyId === company.id) { + if (order.side === OrderSide.BUY) { + if ( + bestOwnBuyPriceCents === null || + order.unitPriceCents > bestOwnBuyPriceCents + ) { + bestOwnBuyPriceCents = order.unitPriceCents; + } + continue; + } + + if ( + bestOwnSellPriceCents === null || + order.unitPriceCents < bestOwnSellPriceCents + ) { + bestOwnSellPriceCents = order.unitPriceCents; + } + continue; + } + + if (order.side === OrderSide.BUY) { + if ( + bestExternalBuyPriceCents === null || + order.unitPriceCents > bestExternalBuyPriceCents + ) { + bestExternalBuyPriceCents = order.unitPriceCents; + } + continue; + } + + if ( + bestExternalSellPriceCents === null || + order.unitPriceCents < bestExternalSellPriceCents + ) { + bestExternalSellPriceCents = order.unitPriceCents; + } + } return { itemId: item.id, @@ -443,7 +571,11 @@ export async function runBotsForTick( ), hasOpenSellOrder: openOrderKeySet.has( `${company.id}:${company.regionId}:${item.id}:${OrderSide.SELL}` - ) + ), + bestOwnBuyPriceCents, + bestOwnSellPriceCents, + bestExternalBuyPriceCents, + bestExternalSellPriceCents }; }); @@ -455,6 +587,7 @@ export async function runBotsForTick( cashCents: company.cashCents, reservedCashCents: company.reservedCashCents }), + availableStorageUnits, items: itemsForCompany }; }); diff --git a/packages/sim/src/bots/strategies/liquidity-bot.ts b/packages/sim/src/bots/strategies/liquidity-bot.ts index 2e588e6f..c6956353 100644 --- a/packages/sim/src/bots/strategies/liquidity-bot.ts +++ b/packages/sim/src/bots/strategies/liquidity-bot.ts @@ -7,10 +7,15 @@ export interface LiquidityItemState { availableInventory: number; hasOpenBuyOrder: boolean; hasOpenSellOrder: boolean; + bestOwnBuyPriceCents?: bigint | null; + bestOwnSellPriceCents?: bigint | null; + bestExternalBuyPriceCents?: bigint | null; + bestExternalSellPriceCents?: bigint | null; } export interface LiquidityBotState { availableCashCents: bigint; + availableStorageUnits?: number | null; items: LiquidityItemState[]; } @@ -27,6 +32,8 @@ export interface PlannedLiquidityOrder { unitPriceCents: bigint; } +const AGGRESSIVE_CROSS_QUANTITY = 1; + function calculateBuyPrice(referencePriceCents: bigint, spreadBps: number): bigint { const price = (referencePriceCents * BigInt(10_000 - spreadBps)) / 10_000n; return price > 0n ? price : 1n; @@ -55,23 +62,48 @@ export function planLiquidityOrders( } const orders: PlannedLiquidityOrder[] = []; - let remainingNotional = config.maxNotionalPerTickCents; + let remainingBuyNotional = config.maxNotionalPerTickCents; + let remainingSellNotional = config.maxNotionalPerTickCents; let remainingCash = state.availableCashCents; + let remainingStorageUnits = + Number.isInteger(state.availableStorageUnits) && (state.availableStorageUnits ?? 0) >= 0 + ? (state.availableStorageUnits as number) + : Number.POSITIVE_INFINITY; const sortedItems = [...state.items].sort((left, right) => left.itemCode.localeCompare(right.itemCode)); for (const item of sortedItems) { - if (remainingNotional <= 0n) { + if (remainingBuyNotional <= 0n && remainingSellNotional <= 0n) { break; } const buyPrice = calculateBuyPrice(item.referencePriceCents, config.spreadBps); const sellPrice = calculateSellPrice(item.referencePriceCents, config.spreadBps); + let placedBuyThisItem = false; + let placedSellThisItem = false; + let placedCrossingBuyThisItem = false; + + if (remainingSellNotional > 0n && !item.hasOpenSellOrder) { + const desired = Math.min(config.targetQuantityPerSide, item.availableInventory); + const quantity = capByNotional(remainingSellNotional, sellPrice, desired); + + if (quantity > 0) { + const notional = BigInt(quantity) * sellPrice; + orders.push({ + itemId: item.itemId, + side: OrderSide.SELL, + quantity, + unitPriceCents: sellPrice + }); + remainingSellNotional -= notional; + placedSellThisItem = true; + } + } if (!item.hasOpenBuyOrder) { const maxByBudget = buyPrice > 0n ? Number(remainingCash / buyPrice) : 0; const desired = Math.min(config.targetQuantityPerSide, maxByBudget); - const quantity = capByNotional(remainingNotional, buyPrice, desired); + const quantity = capByNotional(remainingBuyNotional, buyPrice, desired); if (quantity > 0) { const notional = BigInt(quantity) * buyPrice; @@ -81,27 +113,97 @@ export function planLiquidityOrders( quantity, unitPriceCents: buyPrice }); - remainingNotional -= notional; + remainingBuyNotional -= notional; remainingCash -= notional; + placedBuyThisItem = true; } } - if (remainingNotional <= 0n || item.hasOpenSellOrder) { - continue; + // If only one side is currently open for this bot on this item, place a small + // deterministic taker order against external liquidity to unblock trade flow. + if ( + remainingBuyNotional > 0n && + !placedBuyThisItem && + item.bestExternalSellPriceCents !== null && + item.bestExternalSellPriceCents !== undefined && + ( + item.bestOwnSellPriceCents === null || + item.bestOwnSellPriceCents === undefined || + item.bestOwnSellPriceCents > item.bestExternalSellPriceCents + ) + ) { + const crossBuyPrice = item.bestExternalSellPriceCents; + const maxByBudget = crossBuyPrice > 0n ? Number(remainingCash / crossBuyPrice) : 0; + const desired = Math.min(AGGRESSIVE_CROSS_QUANTITY, maxByBudget, remainingStorageUnits); + const quantity = capByNotional(remainingBuyNotional, crossBuyPrice, desired); + + if (quantity > 0) { + const notional = BigInt(quantity) * crossBuyPrice; + orders.push({ + itemId: item.itemId, + side: OrderSide.BUY, + quantity, + unitPriceCents: crossBuyPrice + }); + remainingBuyNotional -= notional; + remainingCash -= notional; + remainingStorageUnits = Math.max(0, remainingStorageUnits - quantity); + placedBuyThisItem = true; + placedCrossingBuyThisItem = true; + } + } + + if ( + remainingSellNotional > 0n && + !placedSellThisItem && + item.bestExternalBuyPriceCents !== null && + item.bestExternalBuyPriceCents !== undefined && + ( + item.bestOwnBuyPriceCents === null || + item.bestOwnBuyPriceCents === undefined || + item.bestOwnBuyPriceCents < item.bestExternalBuyPriceCents + ) + ) { + const crossSellPrice = item.bestExternalBuyPriceCents; + const desired = Math.min(AGGRESSIVE_CROSS_QUANTITY, item.availableInventory); + const quantity = capByNotional(remainingSellNotional, crossSellPrice, desired); + + if (quantity > 0) { + const notional = BigInt(quantity) * crossSellPrice; + orders.push({ + itemId: item.itemId, + side: OrderSide.SELL, + quantity, + unitPriceCents: crossSellPrice + }); + remainingSellNotional -= notional; + } } - const desired = Math.min(config.targetQuantityPerSide, item.availableInventory); - const quantity = capByNotional(remainingNotional, sellPrice, desired); - - if (quantity > 0) { - const notional = BigInt(quantity) * sellPrice; - orders.push({ - itemId: item.itemId, - side: OrderSide.SELL, - quantity, - unitPriceCents: sellPrice - }); - remainingNotional -= notional; + // Fallback: if no safe external crossing buy is available, allow a tiny self-cross + // against this bot's resting sell liquidity to keep trade tape active. + if (!placedCrossingBuyThisItem && remainingBuyNotional > 0n) { + const ownSellPriceForSelfCross = + item.bestOwnSellPriceCents ?? (placedSellThisItem ? sellPrice : null); + + if (ownSellPriceForSelfCross !== null && ownSellPriceForSelfCross !== undefined) { + const maxByBudget = + ownSellPriceForSelfCross > 0n ? Number(remainingCash / ownSellPriceForSelfCross) : 0; + const desired = Math.min(AGGRESSIVE_CROSS_QUANTITY, maxByBudget); + const quantity = capByNotional(remainingBuyNotional, ownSellPriceForSelfCross, desired); + + if (quantity > 0) { + const notional = BigInt(quantity) * ownSellPriceForSelfCross; + orders.push({ + itemId: item.itemId, + side: OrderSide.BUY, + quantity, + unitPriceCents: ownSellPriceForSelfCross + }); + remainingBuyNotional -= notional; + remainingCash -= notional; + } + } } } diff --git a/packages/sim/tests/liquidity-bot.test.ts b/packages/sim/tests/liquidity-bot.test.ts index 643d919d..71419860 100644 --- a/packages/sim/tests/liquidity-bot.test.ts +++ b/packages/sim/tests/liquidity-bot.test.ts @@ -42,7 +42,7 @@ describe("liquidity bot strategy", () => { expect(totalNotional).toBeLessThanOrEqual(2_000n); }); - it("respects per-tick notional cap", () => { + it("respects per-side notional cap", () => { const orders = planLiquidityOrders( { availableCashCents: 10_000n, @@ -64,10 +64,113 @@ describe("liquidity bot strategy", () => { } ); - const totalNotional = orders.reduce((sum, entry) => { - return sum + BigInt(entry.quantity) * entry.unitPriceCents; - }, 0n); - expect(totalNotional).toBeLessThanOrEqual(300n); + const buyNotional = orders + .filter((entry) => entry.side === OrderSide.BUY) + .reduce((sum, entry) => sum + BigInt(entry.quantity) * entry.unitPriceCents, 0n); + const sellNotional = orders + .filter((entry) => entry.side === OrderSide.SELL) + .reduce((sum, entry) => sum + BigInt(entry.quantity) * entry.unitPriceCents, 0n); + + expect(buyNotional).toBeLessThanOrEqual(300n); + expect(sellNotional).toBeLessThanOrEqual(300n); + }); + + it("places an aggressive crossing order when only one side is open", () => { + const orders = planLiquidityOrders( + { + availableCashCents: 10_000n, + items: [ + { + itemId: "item-iron", + itemCode: "IRON_ORE", + referencePriceCents: 100n, + availableInventory: 0, + hasOpenBuyOrder: true, + hasOpenSellOrder: false, + bestExternalBuyPriceCents: 92n, + bestExternalSellPriceCents: 104n + } + ] + }, + { + spreadBps: 500, + maxNotionalPerTickCents: 10_000n, + targetQuantityPerSide: 5 + } + ); + + const aggressiveBuy = orders.find( + (entry) => entry.side === OrderSide.BUY && entry.unitPriceCents === 104n + ); + + expect(aggressiveBuy).toBeDefined(); + expect(aggressiveBuy?.quantity).toBe(1); + }); + + it("does not place aggressive buy orders when storage is full", () => { + const orders = planLiquidityOrders( + { + availableCashCents: 10_000n, + availableStorageUnits: 0, + items: [ + { + itemId: "item-iron", + itemCode: "IRON_ORE", + referencePriceCents: 100n, + availableInventory: 0, + hasOpenBuyOrder: true, + hasOpenSellOrder: false, + bestExternalBuyPriceCents: 92n, + bestExternalSellPriceCents: 104n + } + ] + }, + { + spreadBps: 500, + maxNotionalPerTickCents: 10_000n, + targetQuantityPerSide: 5 + } + ); + + const aggressiveBuy = orders.find( + (entry) => entry.side === OrderSide.BUY && entry.unitPriceCents === 104n + ); + expect(aggressiveBuy).toBeUndefined(); + }); + + it("can place a safe aggressive buy even when both sides are already open", () => { + const orders = planLiquidityOrders( + { + availableCashCents: 10_000n, + availableStorageUnits: 10, + items: [ + { + itemId: "item-iron", + itemCode: "IRON_ORE", + referencePriceCents: 100n, + availableInventory: 10, + hasOpenBuyOrder: true, + hasOpenSellOrder: true, + bestOwnBuyPriceCents: 95n, + bestOwnSellPriceCents: 110n, + bestExternalBuyPriceCents: 97n, + bestExternalSellPriceCents: 104n + } + ] + }, + { + spreadBps: 500, + maxNotionalPerTickCents: 10_000n, + targetQuantityPerSide: 5 + } + ); + + const aggressiveBuy = orders.find( + (entry) => entry.side === OrderSide.BUY && entry.unitPriceCents === 104n + ); + + expect(aggressiveBuy).toBeDefined(); + expect(aggressiveBuy?.quantity).toBe(1); }); }); @@ -87,6 +190,7 @@ describe("bot runner planning", () => { companyCode: "BOT_TRADER_B", strategy: "LIQUIDITY" as const, availableCashCents: 50_000n, + availableStorageUnits: 500, items: [ { itemId: "item-iron", @@ -103,6 +207,7 @@ describe("bot runner planning", () => { companyCode: "BOT_MINER_A", strategy: "PRODUCER" as const, availableCashCents: 80_000n, + availableStorageUnits: 500, items: [] } ]; From 5b4bcaaa53b8e800c7cdccf0a9a1eec1bf3ead5d Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 19:05:03 +0100 Subject: [PATCH 42/52] fix(api): harden diagnostics missing-items service injection --- .../20260222185800-fix-diagnostics-missing-items-di.md | 7 +++++++ apps/api/src/diagnostics/diagnostics.controller.ts | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md diff --git a/.releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md b/.releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md new file mode 100644 index 00000000..06f904ff --- /dev/null +++ b/.releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md @@ -0,0 +1,7 @@ +--- +type: patch +area: api +summary: Fix diagnostics missing-items endpoint DI so missing item logs can be created +--- + +- Harden diagnostics controller service injection with explicit @Inject assignment to avoid undefined service at runtime. diff --git a/apps/api/src/diagnostics/diagnostics.controller.ts b/apps/api/src/diagnostics/diagnostics.controller.ts index e720f64f..2c4ec945 100644 --- a/apps/api/src/diagnostics/diagnostics.controller.ts +++ b/apps/api/src/diagnostics/diagnostics.controller.ts @@ -1,5 +1,6 @@ import { BadRequestException, + Inject, Controller, ForbiddenException, Get, @@ -89,7 +90,11 @@ function isStaffRole(role: string | string[] | null | undefined): boolean { @Controller("diagnostics") export class DiagnosticsController { - constructor(private readonly diagnosticsService: DiagnosticsService) {} + private readonly diagnosticsService: DiagnosticsService; + + constructor(@Inject(DiagnosticsService) diagnosticsService: DiagnosticsService) { + this.diagnosticsService = diagnosticsService; + } private assertStaffSession(request: RequestWithSession): void { const session = request.session; From 0965f576fbd3d0cc169e71065703d43456d8adef Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 19:23:31 +0100 Subject: [PATCH 43/52] fix(ci): resolve root typecheck failures --- ...pecheck-for-public-links-route-and-bot-cancel-input.md | 8 ++++++++ packages/sim/src/bots/bot-runner.ts | 1 - tsconfig.json | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md diff --git a/.releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md b/.releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md new file mode 100644 index 00000000..3304cdb8 --- /dev/null +++ b/.releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md @@ -0,0 +1,8 @@ +--- +type: patch +area: ci +summary: Fix typecheck failures in web public-links route import and bot order cancellation input +--- + +- Added a root `@/*` path mapping to `apps/web/src/*` so root `tsc` resolves web alias imports used by app route handlers. +- Removed an invalid `companyId` property passed to `cancelMarketOrderWithTx` in bot order cleanup. diff --git a/packages/sim/src/bots/bot-runner.ts b/packages/sim/src/bots/bot-runner.ts index 4b188dc7..95aeb69a 100644 --- a/packages/sim/src/bots/bot-runner.ts +++ b/packages/sim/src/bots/bot-runner.ts @@ -343,7 +343,6 @@ export async function runBotsForTick( for (const order of openLiquidityOrders) { await cancelMarketOrderWithTx(tx, { orderId: order.id, - companyId: order.companyId, tick }); } diff --git a/tsconfig.json b/tsconfig.json index dcdd64de..35ab7ca0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "types": ["node", "vitest/globals"], "baseUrl": ".", + "paths": { + "@/*": ["apps/web/src/*"] + }, "experimentalDecorators": true, "emitDecoratorMetadata": true }, From 4a66e9ae95a1e6b958ff969559b39e03aec133a0 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 19:49:03 +0100 Subject: [PATCH 44/52] fix(web): wrap search params hooks in suspense --- ...p-searchparams-hooks-in-suspense-for-web-build.md | 8 ++++++++ apps/web/src/components/auth/auth-route-gate.tsx | 12 ++++++++++-- .../components/tutorial/guided-tutorial-overlay.tsx | 12 ++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md diff --git a/.releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md b/.releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md new file mode 100644 index 00000000..f0dd07a6 --- /dev/null +++ b/.releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Wrap search-params dependent layout clients in Suspense to fix Next build prerendering +--- + +- Wrapped `AuthRouteGate` and `GuidedTutorialOverlay` with React `Suspense` boundaries so `useSearchParams()` does not fail static prerender checks. +- Preserved existing runtime behavior by keeping current loading/empty fallbacks. diff --git a/apps/web/src/components/auth/auth-route-gate.tsx b/apps/web/src/components/auth/auth-route-gate.tsx index 3dfebfa1..01286229 100644 --- a/apps/web/src/components/auth/auth-route-gate.tsx +++ b/apps/web/src/components/auth/auth-route-gate.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { Suspense, useEffect, useMemo, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { OnboardingStatus } from "@corpsim/shared"; import { getOnboardingStatus } from "@/lib/api"; @@ -36,7 +36,7 @@ function FullscreenMessage({ message }: { message: string }) { ); } -export function AuthRouteGate({ children }: { children: React.ReactNode }) { +function AuthRouteGateContent({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const searchParams = useSearchParams(); const router = useRouter(); @@ -198,3 +198,11 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) { return <>{children}; } + +export function AuthRouteGate({ children }: { children: React.ReactNode }) { + return ( + }> + {children} + + ); +} diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx index 74b578b4..9056b4ff 100644 --- a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx +++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { completeOnboardingTutorial } from "@/lib/api"; import { Button } from "@/components/ui/button"; @@ -31,7 +31,7 @@ function readErrorMessage(error: unknown): string { return "Unable to finish tutorial right now. Please try again."; } -export function GuidedTutorialOverlay() { +function GuidedTutorialOverlayContent() { const pathname = usePathname(); const searchParams = useSearchParams(); const router = useRouter(); @@ -238,3 +238,11 @@ export function GuidedTutorialOverlay() {
); } + +export function GuidedTutorialOverlay() { + return ( + + + + ); +} From ef4321c9f845e677f223b75a801cef492a46efcc Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 20:20:27 +0100 Subject: [PATCH 45/52] fix(ci): run release workflow only on main --- .github/workflows/release.yml | 6 +++++- ...0222202100-restrict-release-workflow-to-main-branch.md | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ec082cb..237b22b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: workflow_run: workflows: ["Verify"] types: [completed] + branches: [main] workflow_dispatch: permissions: @@ -17,7 +18,10 @@ concurrency: jobs: release: if: | - github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/main' + ) || ( github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && diff --git a/.releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md b/.releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md new file mode 100644 index 00000000..2ecdaa93 --- /dev/null +++ b/.releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md @@ -0,0 +1,8 @@ +--- +type: patch +area: ci +summary: Restrict release workflow to main branch for both automatic and manual runs +--- + +- Added a `main` branch filter to the release workflow's `workflow_run` trigger. +- Added a job-level guard so manual dispatch runs only when dispatched from `refs/heads/main`. From ab88c3d41335016dabeea97c9259a9f22fe75fe7 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 21:05:16 +0100 Subject: [PATCH 46/52] fix: stabilize prisma startup and restore phase 1-5 web UX --- ...x-dev-prisma-lock-and-phase-feedback-ux.md | 9 + apps/web/app/workforce/page.tsx | 4 +- apps/web/src/components/layout/app-shell.tsx | 2 + .../layout/research-completion-notifier.tsx | 140 +++++++++++ .../web/src/components/layout/sidebar-nav.tsx | 4 +- .../src/components/research/research-page.tsx | 59 ----- apps/web/src/lib/page-navigation.ts | 12 +- apps/web/src/lib/ui-copy.ts | 3 +- scripts/prisma-generate.mjs | 222 +++++++++++++----- 9 files changed, 326 insertions(+), 129 deletions(-) create mode 100644 .releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md create mode 100644 apps/web/src/components/layout/research-completion-notifier.tsx diff --git a/.releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md b/.releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md new file mode 100644 index 00000000..8f6f9d41 --- /dev/null +++ b/.releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md @@ -0,0 +1,9 @@ +--- +type: patch +area: web +summary: Fix local Prisma lock races and restore Buildings/research feedback visibility +--- + +- Serialize `prisma:generate` runs with a cross-process lock to prevent Windows engine rename collisions when multiple dev scripts start at once. +- Replace deprecated `/workforce` entry points by redirecting to `/buildings` and surfacing Buildings in shared navigation. +- Move research completion toasts into a global app-shell notifier so completion feedback appears even when users are on other pages. diff --git a/apps/web/app/workforce/page.tsx b/apps/web/app/workforce/page.tsx index 54ff86fe..f66bcf24 100644 --- a/apps/web/app/workforce/page.tsx +++ b/apps/web/app/workforce/page.tsx @@ -1,5 +1,5 @@ -import { WorkforcePage } from "@/components/workforce/workforce-page"; +import { redirect } from "next/navigation"; export default function WorkforceRoute() { - return ; + redirect("/buildings"); } diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index ed106b67..1062ca5c 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -10,6 +10,7 @@ import { InventoryPreviewShortcut } from "./inventory-preview-shortcut"; import { PageSearchCommand } from "./page-search-command"; import { ProfilePanel } from "./profile-panel"; import { QuickNavigationShortcuts } from "./quick-navigation-shortcuts"; +import { ResearchCompletionNotifier } from "./research-completion-notifier"; import { ShortcutsHelpShortcut } from "./shortcuts-help-shortcut"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -95,6 +96,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { +
diff --git a/apps/web/src/components/layout/research-completion-notifier.tsx b/apps/web/src/components/layout/research-completion-notifier.tsx new file mode 100644 index 00000000..97a74b8d --- /dev/null +++ b/apps/web/src/components/layout/research-completion-notifier.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useActiveCompany } from "@/components/company/active-company-provider"; +import { useUiSfx } from "@/components/layout/ui-sfx-provider"; +import { useWorldHealth } from "@/components/layout/world-health-provider"; +import { useToast } from "@/components/ui/toast-manager"; +import { ResearchNode, listResearch } from "@/lib/api"; + +const RESEARCH_REFRESH_DEBOUNCE_MS = 500; +const MAX_RECIPES_IN_TOAST = 3; + +function buildResearchCompletionMessage(completedNodes: ResearchNode[]): string { + const unlockedRecipes = completedNodes.flatMap((node) => node.unlockRecipes); + const uniqueRecipeNames = Array.from( + new Set( + unlockedRecipes + .map((recipe) => recipe.recipeName) + .filter((name): name is string => Boolean(name && name.trim())) + ) + ); + + if (uniqueRecipeNames.length === 0) { + return "Research complete!"; + } + + const displayedNames = uniqueRecipeNames.slice(0, MAX_RECIPES_IN_TOAST); + const remainingCount = uniqueRecipeNames.length - displayedNames.length; + const baseList = displayedNames.join(", "); + const summary = remainingCount > 0 ? `${baseList} + ${remainingCount} more` : baseList; + return `Research complete! Unlocked recipes: ${summary}`; +} + +export function ResearchCompletionNotifier() { + const { activeCompanyId } = useActiveCompany(); + const { health } = useWorldHealth(); + const { play } = useUiSfx(); + const { showToast } = useToast(); + + const [nodes, setNodes] = useState([]); + const statusByNodeIdRef = useRef>(new Map()); + const didPrimeStatusesRef = useRef(false); + + const loadResearch = useCallback( + async (options?: { force?: boolean }) => { + if (!activeCompanyId) { + setNodes([]); + return; + } + + try { + const rows = await listResearch(activeCompanyId, { force: options?.force }); + setNodes(rows); + } catch { + // Ignore background notifier fetch failures and retry on next tick. + } + }, + [activeCompanyId] + ); + + useEffect(() => { + statusByNodeIdRef.current = new Map(); + didPrimeStatusesRef.current = false; + + if (!activeCompanyId) { + setNodes([]); + return; + } + + void loadResearch({ force: true }); + }, [activeCompanyId, loadResearch]); + + const nextResearchCompletionTick = useMemo(() => { + const researching = nodes.filter( + (node) => node.status === "RESEARCHING" && node.tickCompletes !== null + ); + + if (researching.length === 0) { + return null; + } + + return researching.reduce( + (minTick, node) => Math.min(minTick, node.tickCompletes ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER + ); + }, [nodes]); + + useEffect(() => { + if ( + !activeCompanyId || + health?.currentTick === undefined || + nextResearchCompletionTick === null || + health.currentTick < nextResearchCompletionTick + ) { + return; + } + + const timeout = setTimeout(() => { + void loadResearch({ force: true }); + }, RESEARCH_REFRESH_DEBOUNCE_MS); + + return () => clearTimeout(timeout); + }, [activeCompanyId, health?.currentTick, loadResearch, nextResearchCompletionTick]); + + const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]); + + useEffect(() => { + const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const)); + if (!didPrimeStatusesRef.current) { + statusByNodeIdRef.current = nextStatusById; + didPrimeStatusesRef.current = true; + return; + } + + const completedNodes: ResearchNode[] = []; + for (const [nodeId, nextStatus] of nextStatusById.entries()) { + const previousStatus = statusByNodeIdRef.current.get(nodeId); + if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") { + const node = nodeById.get(nodeId); + if (node) { + completedNodes.push(node); + } + } + } + + if (completedNodes.length > 0) { + play("event_research_completed"); + showToast({ + title: completedNodes.length === 1 ? completedNodes[0].name : "Research Complete", + description: buildResearchCompletionMessage(completedNodes), + variant: "success", + sound: "none" + }); + } + + statusByNodeIdRef.current = nextStatusById; + }, [nodeById, nodes, play, showToast]); + + return null; +} diff --git a/apps/web/src/components/layout/sidebar-nav.tsx b/apps/web/src/components/layout/sidebar-nav.tsx index 84a8df71..a4d92c16 100644 --- a/apps/web/src/components/layout/sidebar-nav.tsx +++ b/apps/web/src/components/layout/sidebar-nav.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { + Building2, BookOpenText, Box, CircleDollarSign, @@ -19,7 +20,6 @@ import { Wrench, Truck, TrendingUp, - Users } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { SIDEBAR_PAGE_NAVIGATION, APP_PAGE_NAVIGATION } from "@/lib/page-navigation"; @@ -33,9 +33,9 @@ import { isAdminRole, isModeratorRole } from "@/lib/roles"; const NAV_ICON_BY_ROUTE: Record = { "/overview": LayoutDashboard, "/market": TrendingUp, + "/buildings": Building2, "/production": Factory, "/research": FlaskConical, - "/workforce": Users, "/inventory": PackageSearch, "/logistics": Truck, "/contracts": ClipboardList, diff --git a/apps/web/src/components/research/research-page.tsx b/apps/web/src/components/research/research-page.tsx index 5db85ee5..d9c870f7 100644 --- a/apps/web/src/components/research/research-page.tsx +++ b/apps/web/src/components/research/research-page.tsx @@ -109,8 +109,6 @@ export function ResearchPage() { const [nodePageSize, setNodePageSize] = useState<(typeof RESEARCH_NODE_PAGE_SIZE_OPTIONS)[number]>(20); const deferredNodeSearch = useDeferredValue(nodeSearch); - const statusByNodeIdRef = useRef>(new Map()); - const didPrimeStatusesRef = useRef(false); const hasLoadedResearchRef = useRef(false); const loadResearch = useCallback(async (options?: { force?: boolean; showLoadingState?: boolean }) => { @@ -190,65 +188,8 @@ export function ResearchPage() { return () => clearTimeout(timeout); }, [activeCompanyId, health?.currentTick, loadResearch, nextResearchCompletionTick]); - useEffect(() => { - statusByNodeIdRef.current = new Map(); - didPrimeStatusesRef.current = false; - }, [activeCompanyId]); - const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]); - useEffect(() => { - const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const)); - if (!didPrimeStatusesRef.current) { - statusByNodeIdRef.current = nextStatusById; - didPrimeStatusesRef.current = true; - return; - } - - const completedNodes: ResearchNode[] = []; - for (const [nodeId, nextStatus] of nextStatusById.entries()) { - const previousStatus = statusByNodeIdRef.current.get(nodeId); - if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") { - const node = nodeById.get(nodeId); - if (node) { - completedNodes.push(node); - } - } - } - if (completedNodes.length > 0) { - play("event_research_completed"); - - // Show toast notification for completed research - const unlockedRecipes = completedNodes.flatMap((node) => node.unlockRecipes); - const uniqueRecipeNamesSet = new Set( - unlockedRecipes - .map((recipe) => recipe.recipeName) - .filter((name): name is string => Boolean(name && name.trim())) - ); - const uniqueRecipeNames = Array.from(uniqueRecipeNamesSet); - const MAX_RECIPES_IN_TOAST = 3; - let message: string; - - if (uniqueRecipeNames.length > 0) { - const displayedNames = uniqueRecipeNames.slice(0, MAX_RECIPES_IN_TOAST); - const remainingCount = uniqueRecipeNames.length - displayedNames.length; - const baseList = displayedNames.join(", "); - const summary = - remainingCount > 0 ? `${baseList} + ${remainingCount} more` : baseList; - message = `Research complete! Unlocked recipes: ${summary}`; - } else { - message = "Research complete!"; - } - - showToast({ - title: completedNodes.length === 1 ? completedNodes[0].name : "Research Complete", - description: message, - variant: "success" - }); - } - statusByNodeIdRef.current = nextStatusById; - }, [nodes, play, showToast, nodeById]); - const selectedNode = selectedNodeId ? nodeById.get(selectedNodeId) ?? null : null; const filteredNodes = useMemo(() => { diff --git a/apps/web/src/lib/page-navigation.ts b/apps/web/src/lib/page-navigation.ts index 7f0c0546..1cc9a8d7 100644 --- a/apps/web/src/lib/page-navigation.ts +++ b/apps/web/src/lib/page-navigation.ts @@ -20,6 +20,12 @@ export const APP_PAGE_NAVIGATION: AppPageNavigationItem[] = [ keywords: ["orders", "trades", "exchange"], showInSidebar: true }, + { + href: "/buildings", + label: UI_COPY.modules.buildings, + keywords: ["facilities", "expansion", "capacity"], + showInSidebar: true + }, { href: "/production", label: UI_COPY.modules.production, @@ -32,12 +38,6 @@ export const APP_PAGE_NAVIGATION: AppPageNavigationItem[] = [ keywords: ["technology", "nodes", "unlocks"], showInSidebar: true }, - { - href: "/workforce", - label: UI_COPY.modules.workforce, - keywords: ["staff", "allocation", "capacity"], - showInSidebar: true - }, { href: "/inventory", label: UI_COPY.modules.inventory, diff --git a/apps/web/src/lib/ui-copy.ts b/apps/web/src/lib/ui-copy.ts index 1dec4c82..d2c86b5c 100644 --- a/apps/web/src/lib/ui-copy.ts +++ b/apps/web/src/lib/ui-copy.ts @@ -4,6 +4,7 @@ export const UI_COPY = { modules: { overview: "Overview", market: "Market", + buildings: "Buildings", production: "Production", research: "Research", workforce: "Workforce", @@ -105,9 +106,9 @@ export const UI_COPY = { routePaths: { "/overview": "/pages/overview", "/market": "/pages/market", + "/buildings": "/pages/buildings", "/production": "/pages/production", "/research": "/pages/research", - "/workforce": "/pages/workforce", "/inventory": "/pages/inventory", "/logistics": "/pages/logistics", "/contracts": "/pages/contracts", diff --git a/scripts/prisma-generate.mjs b/scripts/prisma-generate.mjs index c28d0dad..617cca1b 100644 --- a/scripts/prisma-generate.mjs +++ b/scripts/prisma-generate.mjs @@ -3,12 +3,27 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const MAX_RETRIES = 6; const BASE_DELAY_MS = 300; +const LOCK_ACQUIRE_TIMEOUT_MS = 120_000; +const LOCK_STALE_MS = 10 * 60 * 1_000; +const LOCK_POLL_INTERVAL_MS = 250; + +class GenerateCommandFailedError extends Error { + constructor(exitCode) { + super(`[prisma:generate] generate:raw failed with exit code ${exitCode}`); + this.name = "GenerateCommandFailedError"; + this.exitCode = exitCode; + } +} + +function isNodeErrorWithCode(error) { + return typeof error === "object" && error !== null && "code" in error; +} function toPosixPath(value) { return value.replace(/\\/g, "/"); @@ -118,6 +133,87 @@ function sleepMs(ms) { }); } +async function isLockFileStale(lockPath) { + try { + const details = await stat(lockPath); + return Date.now() - details.mtimeMs > LOCK_STALE_MS; + } catch (error) { + if (isNodeErrorWithCode(error) && error.code === "ENOENT") { + return false; + } + throw error; + } +} + +async function acquireGenerateLock(repoRoot) { + const lockPath = resolve(repoRoot, ".corpsim/cache/prisma-generate.lock"); + await mkdir(dirname(lockPath), { recursive: true }); + const startedAt = Date.now(); + let hasLoggedWaiting = false; + + for (;;) { + try { + await writeFile( + lockPath, + JSON.stringify( + { + pid: process.pid, + lockedAt: new Date().toISOString() + }, + null, + 2 + ) + "\n", + { + encoding: "utf8", + flag: "wx" + } + ); + + return async () => { + try { + await unlink(lockPath); + } catch (error) { + if (!isNodeErrorWithCode(error) || error.code !== "ENOENT") { + console.warn("[prisma:generate] could not remove lock file", error); + } + } + }; + } catch (error) { + if (!isNodeErrorWithCode(error) || error.code !== "EEXIST") { + throw error; + } + + if (await isLockFileStale(lockPath)) { + try { + await unlink(lockPath); + console.warn("[prisma:generate] removed stale generate lock file"); + continue; + } catch (staleCleanupError) { + if ( + !isNodeErrorWithCode(staleCleanupError) || + (staleCleanupError.code !== "ENOENT" && staleCleanupError.code !== "EPERM") + ) { + throw staleCleanupError; + } + } + } + + if (!hasLoggedWaiting) { + console.warn("[prisma:generate] another prisma:generate process is running; waiting for lock"); + hasLoggedWaiting = true; + } + + if (Date.now() - startedAt > LOCK_ACQUIRE_TIMEOUT_MS) { + throw new Error( + `[prisma:generate] timed out waiting for lock after ${LOCK_ACQUIRE_TIMEOUT_MS}ms` + ); + } + + await sleepMs(LOCK_POLL_INTERVAL_MS); + } + } +} + function normalizeNewlines(value) { return value.replace(/\r\n/g, "\n"); } @@ -238,7 +334,7 @@ async function runGenerateWithRetry(repoRoot, schemaContent) { } flushProcessOutput(result); - process.exit(result.status ?? 1); + throw new GenerateCommandFailedError(result.status ?? 1); } const delayMs = BASE_DELAY_MS * 2 ** (attempt - 1); @@ -252,72 +348,80 @@ async function runGenerateWithRetry(repoRoot, schemaContent) { async function main() { const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = findRepoRoot(resolve(scriptDir, "..")); - const forceGenerate = process.env.PRISMA_GENERATE_FORCE === "1"; - const statePath = resolve(repoRoot, ".corpsim/cache/prisma-generate-state.json"); - const { fingerprint, schemaContent } = await computeFingerprint(repoRoot); - const sentinelPath = getGeneratedClientSentinelPath(repoRoot); - const generatedSchemaPath = getGeneratedSchemaPath(repoRoot); - - if (!forceGenerate && sentinelPath && existsSync(sentinelPath) && existsSync(statePath)) { - try { - const stateRaw = await readFile(statePath, "utf8"); - const state = JSON.parse(stateRaw); - if (state?.fingerprint === fingerprint && hasUsableGeneratedClient(repoRoot)) { - console.log("[prisma:generate] schema unchanged; skipping generate"); - return; + const releaseLock = await acquireGenerateLock(repoRoot); + try { + const forceGenerate = process.env.PRISMA_GENERATE_FORCE === "1"; + const statePath = resolve(repoRoot, ".corpsim/cache/prisma-generate-state.json"); + const { fingerprint, schemaContent } = await computeFingerprint(repoRoot); + const sentinelPath = getGeneratedClientSentinelPath(repoRoot); + const generatedSchemaPath = getGeneratedSchemaPath(repoRoot); + + if (!forceGenerate && sentinelPath && existsSync(sentinelPath) && existsSync(statePath)) { + try { + const stateRaw = await readFile(statePath, "utf8"); + const state = JSON.parse(stateRaw); + if (state?.fingerprint === fingerprint && hasUsableGeneratedClient(repoRoot)) { + console.log("[prisma:generate] schema unchanged; skipping generate"); + return; + } + } catch { + // Ignore state parse/read errors and regenerate. } - } catch { - // Ignore state parse/read errors and regenerate. } - } - if (!forceGenerate && generatedSchemaPath && existsSync(generatedSchemaPath)) { - try { - const generatedSchemaContent = await readFile(generatedSchemaPath, "utf8"); - if ( - canonicalizePrismaSchema(generatedSchemaContent) === - canonicalizePrismaSchema(schemaContent) && - hasUsableGeneratedClient(repoRoot) - ) { - console.log("[prisma:generate] generated client already matches schema; skipping generate"); - await mkdir(dirname(statePath), { recursive: true }); - await writeFile( - statePath, - JSON.stringify( - { - fingerprint, - generatedAt: new Date().toISOString() - }, - null, - 2 - ) + "\n", - "utf8" - ); - return; + if (!forceGenerate && generatedSchemaPath && existsSync(generatedSchemaPath)) { + try { + const generatedSchemaContent = await readFile(generatedSchemaPath, "utf8"); + if ( + canonicalizePrismaSchema(generatedSchemaContent) === + canonicalizePrismaSchema(schemaContent) && + hasUsableGeneratedClient(repoRoot) + ) { + console.log("[prisma:generate] generated client already matches schema; skipping generate"); + await mkdir(dirname(statePath), { recursive: true }); + await writeFile( + statePath, + JSON.stringify( + { + fingerprint, + generatedAt: new Date().toISOString() + }, + null, + 2 + ) + "\n", + "utf8" + ); + return; + } + } catch { + // If we cannot read generated schema, continue with normal generate flow. } - } catch { - // If we cannot read generated schema, continue with normal generate flow. } - } - await runGenerateWithRetry(repoRoot, schemaContent); - - await mkdir(dirname(statePath), { recursive: true }); - await writeFile( - statePath, - JSON.stringify( - { - fingerprint, - generatedAt: new Date().toISOString() - }, - null, - 2 - ) + "\n", - "utf8" - ); + await runGenerateWithRetry(repoRoot, schemaContent); + + await mkdir(dirname(statePath), { recursive: true }); + await writeFile( + statePath, + JSON.stringify( + { + fingerprint, + generatedAt: new Date().toISOString() + }, + null, + 2 + ) + "\n", + "utf8" + ); + } finally { + await releaseLock(); + } } main().catch((error) => { + if (error instanceof GenerateCommandFailedError) { + process.exit(error.exitCode); + } console.error("[prisma:generate] failed", error); process.exit(1); }); From cf21d67503e266fd90ec24224555731963e0b3bc Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 21:43:36 +0100 Subject: [PATCH 47/52] fix(db): run Prisma generate without relying on dotenv-cli shell binary --- ...-prisma-generate-raw-without-dotenv-cli.md | 9 ++ packages/db/package.json | 2 +- scripts/prisma-generate-raw.mjs | 114 ++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md create mode 100644 scripts/prisma-generate-raw.mjs diff --git a/.releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md b/.releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md new file mode 100644 index 00000000..b41e1b87 --- /dev/null +++ b/.releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md @@ -0,0 +1,9 @@ +--- +type: patch +area: db +summary: Run Prisma generate without relying on dotenv-cli shell binary +--- + +- Replace `packages/db` `generate:raw` command with a Node wrapper script. +- Load `.env` directly in the wrapper and invoke `pnpm exec prisma generate`. +- Avoid Windows shell failures when the `dotenv` CLI binary is not linked in `node_modules/.bin`. diff --git a/packages/db/package.json b/packages/db/package.json index ebd30b2c..eb7b061e 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,7 +18,7 @@ }, "scripts": { "generate": "node ../../scripts/prisma-generate.mjs", - "generate:raw": "dotenv -e ../../.env -v PRISMA_GENERATE_SKIP_AUTOINSTALL=1 -- prisma generate --schema prisma/schema.prisma", + "generate:raw": "node ../../scripts/prisma-generate-raw.mjs", "migrate:dev": "dotenv -e ../../.env -- prisma migrate dev --schema prisma/schema.prisma", "migrate:deploy": "dotenv -e ../../.env -- prisma migrate deploy --schema prisma/schema.prisma", "seed": "tsx seed.ts" diff --git a/scripts/prisma-generate-raw.mjs b/scripts/prisma-generate-raw.mjs new file mode 100644 index 00000000..6bca0db7 --- /dev/null +++ b/scripts/prisma-generate-raw.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +function parseDotenv(source) { + const output = {}; + for (const rawLine of source.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line; + const eqIndex = withoutExport.indexOf("="); + if (eqIndex <= 0) { + continue; + } + + const key = withoutExport.slice(0, eqIndex).trim(); + let value = withoutExport.slice(eqIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + output[key] = value; + } + return output; +} + +function resolveRepoRoot() { + const scriptDir = dirname(fileURLToPath(import.meta.url)); + return resolve(scriptDir, ".."); +} + +function resolvePrismaCliPath(repoRoot) { + const directPaths = [ + resolve(repoRoot, "node_modules/prisma/build/index.js"), + resolve(repoRoot, "packages/db/node_modules/prisma/build/index.js") + ]; + + for (const path of directPaths) { + if (existsSync(path)) { + return path; + } + } + + const pnpmDir = resolve(repoRoot, "node_modules/.pnpm"); + if (!existsSync(pnpmDir)) { + return null; + } + + const prismaStoreDirs = readdirSync(pnpmDir).filter((entry) => entry.startsWith("prisma@")); + for (const entry of prismaStoreDirs) { + const candidate = resolve(pnpmDir, entry, "node_modules/prisma/build/index.js"); + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function main() { + const repoRoot = resolveRepoRoot(); + const envPath = resolve(repoRoot, ".env"); + let envFromFile = {}; + try { + envFromFile = parseDotenv(readFileSync(envPath, "utf8")); + } catch { + envFromFile = {}; + } + + const dbDir = resolve(repoRoot, "packages/db"); + const prismaCliPath = resolvePrismaCliPath(repoRoot); + if (!prismaCliPath) { + console.error( + "[prisma:generate:raw] Prisma CLI is not installed. Run `pnpm install` at the repository root." + ); + process.exit(1); + } + + const args = [ + prismaCliPath, + "generate", + "--schema", + "prisma/schema.prisma" + ]; + + const result = spawnSync(process.execPath, args, { + cwd: dbDir, + stdio: "inherit", + env: { + ...process.env, + ...envFromFile, + PRISMA_GENERATE_SKIP_AUTOINSTALL: "1" + } + }); + + if (typeof result.status === "number") { + process.exit(result.status); + } + + process.exit(1); +} + +main(); From 440c28d9dcb88e8012228442e9d227d16c239311 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 22:10:23 +0100 Subject: [PATCH 48/52] fix(web): parse buildings definitions payload correctly --- ...22221000-fix-buildings-definitions-response-parsing.md | 8 ++++++++ apps/web/src/lib/api.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md diff --git a/.releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md b/.releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md new file mode 100644 index 00000000..03397a2f --- /dev/null +++ b/.releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md @@ -0,0 +1,8 @@ +--- +type: patch +area: web +summary: Fix buildings definitions API response parsing in web client +--- + +- Parse `/v1/buildings/definitions` using the `definitions` field from object payloads. +- Prevent runtime error: `Invalid response field "definitions" (expected array)`. diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 00a6f343..2f002be8 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -925,7 +925,7 @@ export async function preflightBuyOrder(input: { export async function getBuildingTypeDefinitions(): Promise { return fetchJson("/v1/buildings/definitions", (value) => - readArray(value, "definitions").map(parseBuildingTypeDefinition) + readArrayPayload(value, "definitions").map(parseBuildingTypeDefinition) ); } From cf4b79fe60ec705bd48ea8f991179d492449ab36 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 22:27:19 +0100 Subject: [PATCH 49/52] fix(web): support legacy buildings definitions payload shape --- ...rt-legacy-buildings-definitions-payload.md | 9 ++++++ apps/web/src/lib/api.ts | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md diff --git a/.releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md b/.releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md new file mode 100644 index 00000000..90e81772 --- /dev/null +++ b/.releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md @@ -0,0 +1,9 @@ +--- +type: patch +area: web +summary: Accept legacy object-shaped buildings definitions payloads +--- + +- Make `getBuildingTypeDefinitions` tolerant to both array and object-map payload shapes. +- Automatically inject `buildingType` from object keys when legacy payload omits it. +- Prevent runtime parsing crashes in acquire/buildings dialogs during mixed-version dev runs. diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 2f002be8..0ae84235 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -194,6 +194,34 @@ function readArrayPayload(value: unknown, field: string): unknown[] { return readArray(value[field], field); } +function readBuildingDefinitionsPayload(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + if (!isRecord(value)) { + throw new Error('Invalid response payload for "definitions"'); + } + + const definitions = value.definitions; + if (Array.isArray(definitions)) { + return definitions; + } + + if (isRecord(definitions)) { + return Object.entries(definitions).map(([buildingType, definition]) => { + if (!isRecord(definition)) { + throw new Error('Invalid response field "definitions" (expected array)'); + } + + return definition.buildingType === undefined + ? { ...definition, buildingType } + : definition; + }); + } + + throw new Error('Invalid response field "definitions" (expected array)'); +} + function parseSupportAccountSummary(value: unknown): SupportAccountSummary { if (!isRecord(value)) { throw new Error("Invalid support account payload"); @@ -925,7 +953,7 @@ export async function preflightBuyOrder(input: { export async function getBuildingTypeDefinitions(): Promise { return fetchJson("/v1/buildings/definitions", (value) => - readArrayPayload(value, "definitions").map(parseBuildingTypeDefinition) + readBuildingDefinitionsPayload(value).map(parseBuildingTypeDefinition) ); } From f4393bd76195342bcfa06088de022f5f9ab0b0d4 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Sun, 22 Feb 2026 23:45:45 +0100 Subject: [PATCH 50/52] chore(release): cut v0.10. 0 --- ...13-fix-github-username-in-release-notes.md | 0 ...218131646-fix-ux-data-visibility-issues.md | 0 ...700-add-building-infrastructure-phase-1.md | 0 ...219091824-fix-github-oauth-redirect-url.md | 0 ...ings-management-ui-with-preflight-valid.md | 0 ...22153300-single-origin-sso-auth-routing.md | 0 ...4900-run-migrations-in-all-role-startup.md | 0 ...e-item-quantity-rendering-and-apply-qua.md | 0 ...t-unknown-item-labels-by-using-global-i.md | 0 ...market-views-to-active-company-tradable.md | 0 ...dokploy-nginx-docs-to-example-domains-a.md | 0 ...gacy-api-subdomain-blocks-from-nginx-sa.md | 0 ...-as-is-disclaimer-next-to-footer-versio.md | 0 ...a-disclaimer-on-version-hover-and-overv.md | 0 ...e-hover-helper-label-from-version-badge.md | 0 ...aintenance-overlay-focus-ring-rectangle.md | 0 ...0-hide-seeded-example-accounts-in-admin.md | 0 ...a-version-tag-clickable-to-discord-and-.md | 0 ...ord-link-from-runtime-meta-config-for-a.md | 0 ...00-allow-admin-developer-read-endpoints.md | 0 ...tatic-onboarding-tutorial-with-guided-c.md | 0 ...le-redacted-company-cash-in-web-parsers.md | 0 ...verview-metrics-and-tutorial-copy-as-wo.md | 0 ...ded-tutorial-with-active-company-snapsh.md | 0 ...enable-admin-developer-research-catalog.md | 0 ...73000-separate-recipe-input-items-in-ui.md | 0 ...iquidity-bot-orders-and-add-determinist.md | 0 ...185800-fix-diagnostics-missing-items-di.md | 0 ...public-links-route-and-bot-cancel-input.md | 0 ...hparams-hooks-in-suspense-for-web-build.md | 0 ...estrict-release-workflow-to-main-branch.md | 0 ...x-dev-prisma-lock-and-phase-feedback-ux.md | 0 ...-prisma-generate-raw-without-dotenv-cli.md | 0 ...-buildings-definitions-response-parsing.md | 0 ...rt-legacy-buildings-definitions-payload.md | 0 CHANGELOG.md | 40 +++++++++++++++++++ SECURITY.md | 2 +- package.json | 2 +- 38 files changed, 42 insertions(+), 2 deletions(-) rename .releases/{unreleased => released/v0.10.0}/20260218092513-fix-github-username-in-release-notes.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260218131646-fix-ux-data-visibility-issues.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260218155700-add-building-infrastructure-phase-1.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260219091824-fix-github-oauth-redirect-url.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260219112748-add-buildings-management-ui-with-preflight-valid.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222153300-single-origin-sso-auth-routing.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222154900-run-migrations-in-all-role-startup.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222162747-restrict-market-views-to-active-company-tradable.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222165119-remove-hover-helper-label-from-version-badge.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222165400-hide-seeded-example-accounts-in-admin.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222170600-allow-admin-developer-read-endpoints.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222171500-handle-redacted-company-cash-in-web-parsers.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222171905-start-guided-tutorial-with-active-company-snapsh.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222172500-enable-admin-developer-research-catalog.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222173000-separate-recipe-input-items-in-ui.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222185800-fix-diagnostics-missing-items-di.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222202100-restrict-release-workflow-to-main-branch.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222221000-fix-buildings-definitions-response-parsing.md (100%) rename .releases/{unreleased => released/v0.10.0}/20260222222800-support-legacy-buildings-definitions-payload.md (100%) diff --git a/.releases/unreleased/20260218092513-fix-github-username-in-release-notes.md b/.releases/released/v0.10.0/20260218092513-fix-github-username-in-release-notes.md similarity index 100% rename from .releases/unreleased/20260218092513-fix-github-username-in-release-notes.md rename to .releases/released/v0.10.0/20260218092513-fix-github-username-in-release-notes.md diff --git a/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md b/.releases/released/v0.10.0/20260218131646-fix-ux-data-visibility-issues.md similarity index 100% rename from .releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md rename to .releases/released/v0.10.0/20260218131646-fix-ux-data-visibility-issues.md diff --git a/.releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md b/.releases/released/v0.10.0/20260218155700-add-building-infrastructure-phase-1.md similarity index 100% rename from .releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md rename to .releases/released/v0.10.0/20260218155700-add-building-infrastructure-phase-1.md diff --git a/.releases/unreleased/20260219091824-fix-github-oauth-redirect-url.md b/.releases/released/v0.10.0/20260219091824-fix-github-oauth-redirect-url.md similarity index 100% rename from .releases/unreleased/20260219091824-fix-github-oauth-redirect-url.md rename to .releases/released/v0.10.0/20260219091824-fix-github-oauth-redirect-url.md diff --git a/.releases/unreleased/20260219112748-add-buildings-management-ui-with-preflight-valid.md b/.releases/released/v0.10.0/20260219112748-add-buildings-management-ui-with-preflight-valid.md similarity index 100% rename from .releases/unreleased/20260219112748-add-buildings-management-ui-with-preflight-valid.md rename to .releases/released/v0.10.0/20260219112748-add-buildings-management-ui-with-preflight-valid.md diff --git a/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md b/.releases/released/v0.10.0/20260222153300-single-origin-sso-auth-routing.md similarity index 100% rename from .releases/unreleased/20260222153300-single-origin-sso-auth-routing.md rename to .releases/released/v0.10.0/20260222153300-single-origin-sso-auth-routing.md diff --git a/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md b/.releases/released/v0.10.0/20260222154900-run-migrations-in-all-role-startup.md similarity index 100% rename from .releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md rename to .releases/released/v0.10.0/20260222154900-run-migrations-in-all-role-startup.md diff --git a/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md b/.releases/released/v0.10.0/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md similarity index 100% rename from .releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md rename to .releases/released/v0.10.0/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md diff --git a/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md b/.releases/released/v0.10.0/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md similarity index 100% rename from .releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md rename to .releases/released/v0.10.0/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md diff --git a/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md b/.releases/released/v0.10.0/20260222162747-restrict-market-views-to-active-company-tradable.md similarity index 100% rename from .releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md rename to .releases/released/v0.10.0/20260222162747-restrict-market-views-to-active-company-tradable.md diff --git a/.releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md b/.releases/released/v0.10.0/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md similarity index 100% rename from .releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md rename to .releases/released/v0.10.0/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md diff --git a/.releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md b/.releases/released/v0.10.0/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md similarity index 100% rename from .releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md rename to .releases/released/v0.10.0/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md diff --git a/.releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md b/.releases/released/v0.10.0/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md similarity index 100% rename from .releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md rename to .releases/released/v0.10.0/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md diff --git a/.releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md b/.releases/released/v0.10.0/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md similarity index 100% rename from .releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md rename to .releases/released/v0.10.0/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md diff --git a/.releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md b/.releases/released/v0.10.0/20260222165119-remove-hover-helper-label-from-version-badge.md similarity index 100% rename from .releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md rename to .releases/released/v0.10.0/20260222165119-remove-hover-helper-label-from-version-badge.md diff --git a/.releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md b/.releases/released/v0.10.0/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md similarity index 100% rename from .releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md rename to .releases/released/v0.10.0/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md diff --git a/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md b/.releases/released/v0.10.0/20260222165400-hide-seeded-example-accounts-in-admin.md similarity index 100% rename from .releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md rename to .releases/released/v0.10.0/20260222165400-hide-seeded-example-accounts-in-admin.md diff --git a/.releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md b/.releases/released/v0.10.0/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md similarity index 100% rename from .releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md rename to .releases/released/v0.10.0/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md diff --git a/.releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md b/.releases/released/v0.10.0/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md similarity index 100% rename from .releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md rename to .releases/released/v0.10.0/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md diff --git a/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md b/.releases/released/v0.10.0/20260222170600-allow-admin-developer-read-endpoints.md similarity index 100% rename from .releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md rename to .releases/released/v0.10.0/20260222170600-allow-admin-developer-read-endpoints.md diff --git a/.releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md b/.releases/released/v0.10.0/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md similarity index 100% rename from .releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md rename to .releases/released/v0.10.0/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md diff --git a/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md b/.releases/released/v0.10.0/20260222171500-handle-redacted-company-cash-in-web-parsers.md similarity index 100% rename from .releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md rename to .releases/released/v0.10.0/20260222171500-handle-redacted-company-cash-in-web-parsers.md diff --git a/.releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md b/.releases/released/v0.10.0/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md similarity index 100% rename from .releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md rename to .releases/released/v0.10.0/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md diff --git a/.releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md b/.releases/released/v0.10.0/20260222171905-start-guided-tutorial-with-active-company-snapsh.md similarity index 100% rename from .releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md rename to .releases/released/v0.10.0/20260222171905-start-guided-tutorial-with-active-company-snapsh.md diff --git a/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md b/.releases/released/v0.10.0/20260222172500-enable-admin-developer-research-catalog.md similarity index 100% rename from .releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md rename to .releases/released/v0.10.0/20260222172500-enable-admin-developer-research-catalog.md diff --git a/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md b/.releases/released/v0.10.0/20260222173000-separate-recipe-input-items-in-ui.md similarity index 100% rename from .releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md rename to .releases/released/v0.10.0/20260222173000-separate-recipe-input-items-in-ui.md diff --git a/.releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md b/.releases/released/v0.10.0/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md similarity index 100% rename from .releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md rename to .releases/released/v0.10.0/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md diff --git a/.releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md b/.releases/released/v0.10.0/20260222185800-fix-diagnostics-missing-items-di.md similarity index 100% rename from .releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md rename to .releases/released/v0.10.0/20260222185800-fix-diagnostics-missing-items-di.md diff --git a/.releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md b/.releases/released/v0.10.0/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md similarity index 100% rename from .releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md rename to .releases/released/v0.10.0/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md diff --git a/.releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md b/.releases/released/v0.10.0/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md similarity index 100% rename from .releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md rename to .releases/released/v0.10.0/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md diff --git a/.releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md b/.releases/released/v0.10.0/20260222202100-restrict-release-workflow-to-main-branch.md similarity index 100% rename from .releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md rename to .releases/released/v0.10.0/20260222202100-restrict-release-workflow-to-main-branch.md diff --git a/.releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md b/.releases/released/v0.10.0/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md similarity index 100% rename from .releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md rename to .releases/released/v0.10.0/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md diff --git a/.releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md b/.releases/released/v0.10.0/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md similarity index 100% rename from .releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md rename to .releases/released/v0.10.0/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md diff --git a/.releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md b/.releases/released/v0.10.0/20260222221000-fix-buildings-definitions-response-parsing.md similarity index 100% rename from .releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md rename to .releases/released/v0.10.0/20260222221000-fix-buildings-definitions-response-parsing.md diff --git a/.releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md b/.releases/released/v0.10.0/20260222222800-support-legacy-buildings-definitions-payload.md similarity index 100% rename from .releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md rename to .releases/released/v0.10.0/20260222222800-support-legacy-buildings-definitions-payload.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aba99d4..05a60b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -261,3 +261,43 @@ All notable changes to CorpSim are documented in this file. - [ops] Add APP_IMAGE_BYPASS_TAG flag to force preview image builds from latest commit - [ops] Fix SSO authentication in production by wiring backend runtime env vars and frontend build-time visibility flags + +## 0.10.0 - 2026-02-22 + +### What's Changed + +- [web] Fix early UX and data visibility issues (forms, time display, workforce clarity, completion feedback) +- [sim] Add building infrastructure domain layer for capital-based production system +- [web,api,sim] Add Buildings Management UI with preflight validation and acquisition flows (Phase 4 & 5) +- [web] Replace static onboarding tutorial with guided cross-page walkthrough +- [ci] Use GitHub's built-in release notes generation +- [web] Fix OAuth callback redirect URLs for GitHub, Microsoft, and Discord by configuring nginx proxy and BETTER_AUTH_URL +- [web, api] Support single-origin SSO by proxying auth routes and preferring web origin for auth base URL +- [ci] Run Prisma migrations before launching services in APP_ROLE=all mode +- [web] Centralize item quantity rendering and apply quantifier labels to recipe outputs +- [web] Fix market unknown item labels by using global item metadata +- [web] Restrict market views to active company tradable items +- [ops] Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs +- [ops] Remove legacy API subdomain blocks from nginx sample +- [web] Add ALPHA as-is disclaimer next to footer version badge +- [web] Show ALPHA disclaimer on version hover and overview with Discord link +- [web] Remove hover helper label from version badge +- [web] Remove maintenance overlay focus ring rectangle +- [web] Hide seeded example.com accounts from admin user listing +- [web] Make ALPHA version tag clickable to Discord and share URL resolver +- [web] Load Discord link from runtime meta config for alpha notices +- [api, web] Allow admin accounts to access developer page read endpoints +- [web] Accept optional redacted company cash fields in API parsers +- [web] Clarify overview metrics and tutorial copy as world-level values +- [web] Start guided tutorial with active company snapshot before world KPIs +- [api, web] Enable admin access to developer research catalog without player ownership +- [web] Improve recipe input readability with explicit separators and quantity labels +- [sim] Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls +- [api] Fix diagnostics missing-items endpoint DI so missing item logs can be created +- [ci] Fix typecheck failures in web public-links route import and bot order cancellation input +- [web] Wrap search-params dependent layout clients in Suspense to fix Next build prerendering +- [ci] Restrict release workflow to main branch for both automatic and manual runs +- [web] Fix local Prisma lock races and restore Buildings/research feedback visibility +- [db] Run Prisma generate without relying on dotenv-cli shell binary +- [web] Fix buildings definitions API response parsing in web client +- [web] Accept legacy object-shaped buildings definitions payloads diff --git a/SECURITY.md b/SECURITY.md index 8bcb1bd0..fe0834ce 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ CorpSim is pre-1.0. Only the latest released version line is supported for secur | Version | Supported | | --- | --- | -| `0.9.x` and newer release lines | Yes | +| `0.10.x` and newer release lines | Yes | | Older versions | No | ## Reporting a Vulnerability diff --git a/package.json b/package.json index c18482e4..92a529ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corpsim", - "version": "0.9.1", + "version": "0.10.0", "private": true, "workspaces": [ "apps/*", From 4fc55d5ac40039476fe2d62da531a8df5f795eb8 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Mon, 23 Feb 2026 00:19:55 +0100 Subject: [PATCH 51/52] fix(db): sync static catalog for production deployments --- ...200-auto-sync-static-catalog-on-startup.md | 9 + package.json | 1 + packages/db/src/seed-world.ts | 239 ++++++++++++++++++ scripts/sim-sync-static.ts | 26 ++ scripts/start-container.sh | 29 +++ 5 files changed, 304 insertions(+) create mode 100644 .releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md create mode 100644 scripts/sim-sync-static.ts diff --git a/.releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md b/.releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md new file mode 100644 index 00000000..d614d151 --- /dev/null +++ b/.releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md @@ -0,0 +1,9 @@ +--- +type: patch +area: db +summary: Add idempotent static catalog sync and run it on container startup +--- + +- Add `syncStaticCatalog` to upsert items, recipes, recipe inputs, research nodes, unlock links, and prerequisites without resetting world state. +- Ensure missing `CompanyRecipe` links are created for existing companies so newly added recipes become available. +- Add `pnpm sim:sync-static` and wire startup to run catalog sync automatically in `APP_ROLE=all` (or when `CORPSIM_SYNC_STATIC_DATA_ON_START=true`). diff --git a/package.json b/package.json index 92a529ae..af61f30a 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "sim:advance": "tsx scripts/sim-advance.ts", "sim:reset": "tsx scripts/sim-reset.ts", "sim:seed": "pnpm -C packages/db seed", + "sim:sync-static": "tsx scripts/sim-sync-static.ts", "sim:stats": "tsx scripts/sim-stats.ts", "release:entry": "node scripts/release-entry.mjs", "release:check": "node scripts/check-release-entry.mjs", diff --git a/packages/db/src/seed-world.ts b/packages/db/src/seed-world.ts index d7746f74..b00b070f 100644 --- a/packages/db/src/seed-world.ts +++ b/packages/db/src/seed-world.ts @@ -577,6 +577,245 @@ function isRecipeAutoUnlocked(recipeCode: string): boolean { ); } +function chunkArray(rows: T[], chunkSize: number): T[][] { + if (rows.length === 0) { + return []; + } + + const chunks: T[][] = []; + for (let index = 0; index < rows.length; index += chunkSize) { + chunks.push(rows.slice(index, index + chunkSize)); + } + return chunks; +} + +export interface SyncStaticCatalogResult { + itemsSynced: number; + recipesSynced: number; + researchNodesSynced: number; + prerequisitesSynced: number; + companyRecipeLinksCreated: number; +} + +export async function syncStaticCatalog(prisma: PrismaClient): Promise { + return prisma.$transaction(async (tx) => { + const itemsByKey: Record = {}; + + for (const definition of ITEM_DEFINITIONS) { + const item = await tx.item.upsert({ + where: { code: definition.code }, + update: { name: definition.name }, + create: { + code: definition.code, + name: definition.name + } + }); + itemsByKey[definition.key] = item; + } + + const recipesByKey: Record = {}; + for (const definition of RECIPE_DEFINITIONS) { + const outputItem = itemsByKey[definition.outputItemKey]; + if (!outputItem) { + throw new Error(`seed recipe ${definition.code} references unknown output item key ${definition.outputItemKey}`); + } + + const recipe = await tx.recipe.upsert({ + where: { code: definition.code }, + update: { + name: definition.name, + durationTicks: definition.durationTicks, + outputItemId: outputItem.id, + outputQuantity: definition.outputQuantity + }, + create: { + code: definition.code, + name: definition.name, + durationTicks: definition.durationTicks, + outputItemId: outputItem.id, + outputQuantity: definition.outputQuantity + } + }); + + recipesByKey[definition.key] = recipe; + + const expectedInputItemIds: string[] = []; + for (const input of definition.inputs) { + const inputItem = itemsByKey[input.itemKey]; + if (!inputItem) { + throw new Error(`seed recipe ${definition.code} references unknown input item key ${input.itemKey}`); + } + + expectedInputItemIds.push(inputItem.id); + + await tx.recipeInput.upsert({ + where: { + recipeId_itemId: { + recipeId: recipe.id, + itemId: inputItem.id + } + }, + update: { + quantity: input.quantity + }, + create: { + recipeId: recipe.id, + itemId: inputItem.id, + quantity: input.quantity + } + }); + } + + if (expectedInputItemIds.length === 0) { + await tx.recipeInput.deleteMany({ + where: { + recipeId: recipe.id + } + }); + } else { + await tx.recipeInput.deleteMany({ + where: { + recipeId: recipe.id, + itemId: { + notIn: expectedInputItemIds + } + } + }); + } + } + + const researchNodesByKey: Record = {}; + for (const definition of RESEARCH_DEFINITIONS) { + const node = await tx.researchNode.upsert({ + where: { code: definition.code }, + update: { + name: definition.name, + description: definition.description, + costCashCents: definition.costCashCents, + durationTicks: definition.durationTicks + }, + create: { + code: definition.code, + name: definition.name, + description: definition.description, + costCashCents: definition.costCashCents, + durationTicks: definition.durationTicks + } + }); + + researchNodesByKey[definition.key] = node; + + const expectedUnlockRecipeIds: string[] = []; + for (const recipeKey of definition.unlockRecipeKeys) { + const recipe = recipesByKey[recipeKey]; + if (!recipe) { + throw new Error(`research node ${definition.code} references unknown recipe key ${recipeKey}`); + } + expectedUnlockRecipeIds.push(recipe.id); + await tx.researchNodeUnlockRecipe.upsert({ + where: { + nodeId_recipeId: { + nodeId: node.id, + recipeId: recipe.id + } + }, + update: {}, + create: { + nodeId: node.id, + recipeId: recipe.id + } + }); + } + + if (expectedUnlockRecipeIds.length === 0) { + await tx.researchNodeUnlockRecipe.deleteMany({ + where: { + nodeId: node.id + } + }); + } else { + await tx.researchNodeUnlockRecipe.deleteMany({ + where: { + nodeId: node.id, + recipeId: { + notIn: expectedUnlockRecipeIds + } + } + }); + } + } + + await tx.researchPrerequisite.deleteMany(); + await tx.researchPrerequisite.createMany({ + data: RESEARCH_PREREQUISITES.map((entry) => { + const node = researchNodesByKey[entry.nodeKey]; + const prerequisiteNode = researchNodesByKey[entry.prerequisiteKey]; + if (!node) { + throw new Error(`research prerequisite references unknown node key ${entry.nodeKey}`); + } + if (!prerequisiteNode) { + throw new Error(`research prerequisite references unknown prerequisite key ${entry.prerequisiteKey}`); + } + return { + nodeId: node.id, + prerequisiteNodeId: prerequisiteNode.id + }; + }) + }); + + const allRecipes = Object.values(recipesByKey); + const autoUnlockedRecipeIdSet = new Set( + allRecipes.filter((recipe) => isRecipeAutoUnlocked(recipe.code)).map((recipe) => recipe.id) + ); + + let companyRecipeLinksCreated = 0; + if (allRecipes.length > 0) { + const companies = await tx.company.findMany({ + select: { id: true } + }); + + for (const company of companies) { + const rows = allRecipes.map((recipe) => ({ + companyId: company.id, + recipeId: recipe.id, + isUnlocked: autoUnlockedRecipeIdSet.has(recipe.id) + })); + + for (const batch of chunkArray(rows, 1000)) { + const created = await tx.companyRecipe.createMany({ + data: batch, + skipDuplicates: true + }); + companyRecipeLinksCreated += created.count; + } + } + + const autoUnlockedRecipeIds = Array.from(autoUnlockedRecipeIdSet); + if (autoUnlockedRecipeIds.length > 0) { + await tx.companyRecipe.updateMany({ + where: { + recipeId: { + in: autoUnlockedRecipeIds + }, + isUnlocked: false + }, + data: { + isUnlocked: true + } + }); + } + } + + return { + itemsSynced: ITEM_DEFINITIONS.length, + recipesSynced: RECIPE_DEFINITIONS.length, + researchNodesSynced: RESEARCH_DEFINITIONS.length, + prerequisitesSynced: RESEARCH_PREREQUISITES.length, + companyRecipeLinksCreated + }; + }); +} + export async function seedWorld( prisma: PrismaClient, options: SeedWorldOptions = {} diff --git a/scripts/sim-sync-static.ts b/scripts/sim-sync-static.ts new file mode 100644 index 00000000..7949ec4b --- /dev/null +++ b/scripts/sim-sync-static.ts @@ -0,0 +1,26 @@ +import { createPrismaClient, syncStaticCatalog } from "@corpsim/db"; + +async function main(): Promise { + const prisma = createPrismaClient(); + + try { + const result = await syncStaticCatalog(prisma); + console.log( + [ + "Static catalog sync complete.", + `Items: ${result.itemsSynced}`, + `Recipes: ${result.recipesSynced}`, + `Research nodes: ${result.researchNodesSynced}`, + `Prerequisites: ${result.prerequisitesSynced}`, + `Company recipe links created: ${result.companyRecipeLinksCreated}` + ].join(" ") + ); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error: unknown) => { + console.error("Static catalog sync failed", error); + process.exitCode = 1; +}); diff --git a/scripts/start-container.sh b/scripts/start-container.sh index 05b58288..599fb48e 100644 --- a/scripts/start-container.sh +++ b/scripts/start-container.sh @@ -36,9 +36,37 @@ apply_migrations() { pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma } +is_truthy() { + local value="${1:-}" + local normalized + normalized="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" + [[ "$normalized" == "1" || "$normalized" == "true" || "$normalized" == "yes" || "$normalized" == "on" ]] +} + +should_sync_static_catalog() { + local configured="${CORPSIM_SYNC_STATIC_DATA_ON_START:-}" + if [[ -n "$configured" ]]; then + is_truthy "$configured" + return $? + fi + + if [[ "$role" == "all" ]]; then + return 0 + fi + + return 1 +} + +sync_static_catalog() { + if should_sync_static_catalog; then + pnpm sim:sync-static + fi +} + run_all() { # Ensure schema is current before starting long-running processes in single-container mode. apply_migrations + sync_static_catalog pnpm --filter @corpsim/api start & api_pid=$! @@ -59,6 +87,7 @@ run_all() { case "$role" in api) + sync_static_catalog run_api ;; web) From ea9c76c21371a0ebddfcf4a71f023d0440afde78 Mon Sep 17 00:00:00 2001 From: BENZOOgataga Date: Mon, 23 Feb 2026 00:21:25 +0100 Subject: [PATCH 52/52] chore(release): cut v0.10.1 --- .../20260222232200-auto-sync-static-catalog-on-startup.md | 0 CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) rename .releases/{unreleased => released/v0.10.1}/20260222232200-auto-sync-static-catalog-on-startup.md (100%) diff --git a/.releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md b/.releases/released/v0.10.1/20260222232200-auto-sync-static-catalog-on-startup.md similarity index 100% rename from .releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md rename to .releases/released/v0.10.1/20260222232200-auto-sync-static-catalog-on-startup.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a60b16..b8e23509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -301,3 +301,9 @@ All notable changes to CorpSim are documented in this file. - [db] Run Prisma generate without relying on dotenv-cli shell binary - [web] Fix buildings definitions API response parsing in web client - [web] Accept legacy object-shaped buildings definitions payloads + +## 0.10.1 - 2026-02-22 + +### What's Changed + +- [db] Add idempotent static catalog sync and run it on container startup diff --git a/package.json b/package.json index af61f30a..ef267bd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corpsim", - "version": "0.10.0", + "version": "0.10.1", "private": true, "workspaces": [ "apps/*",