From 6f9e1d30a252ba0c36ef7ed365b9d0bf1af67755 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 29 Mar 2026 11:29:17 +0800 Subject: [PATCH] =?UTF-8?q?A2/A4/A6=20=E5=AE=9E=E7=8E=B0=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E5=90=8E=E7=AB=AF=E3=80=81=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E6=8E=A5=E5=8F=A3=E4=B8=8E=E5=90=88=E5=90=8C?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/package-lock.json | 536 ++++++++++++++++++ server/package.json | 1 + .../migration.sql | 35 ++ server/prisma/schema.prisma | 150 +++-- server/src/routes/employees.js | 301 +++++++++- server/src/routes/users.js | 2 + server/src/services/organization.js | 154 +++++ server/src/services/presenceHistory.js | 2 + 8 files changed, 1120 insertions(+), 61 deletions(-) create mode 100644 server/prisma/migrations/20260329194000_r43_employee_avatar_contracts/migration.sql create mode 100644 server/src/services/organization.js diff --git a/server/package-lock.json b/server/package-lock.json index 1cd17730..66782164 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -37,6 +37,7 @@ "pg-boss": "^10.3.0", "prisma": "^7.3.0", "prom-client": "^15.1.0", + "sharp": "^0.34.5", "tencentcloud-sdk-nodejs": "^4.1.14", "zod": "^4.3.6", "zxcvbn": "^4.4.2" @@ -1316,6 +1317,23 @@ "@electric-sql/pglite": "0.3.15" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -2160,6 +2178,471 @@ "hono": "^4" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -6448,6 +6931,15 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -9181,6 +9673,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/server/package.json b/server/package.json index 15cbf94d..5603edfd 100644 --- a/server/package.json +++ b/server/package.json @@ -58,6 +58,7 @@ "pg-boss": "^10.3.0", "prisma": "^7.3.0", "prom-client": "^15.1.0", + "sharp": "^0.34.5", "tencentcloud-sdk-nodejs": "^4.1.14", "zod": "^4.3.6", "zxcvbn": "^4.4.2" diff --git a/server/prisma/migrations/20260329194000_r43_employee_avatar_contracts/migration.sql b/server/prisma/migrations/20260329194000_r43_employee_avatar_contracts/migration.sql new file mode 100644 index 00000000..65201474 --- /dev/null +++ b/server/prisma/migrations/20260329194000_r43_employee_avatar_contracts/migration.sql @@ -0,0 +1,35 @@ +-- AlterTable +ALTER TABLE "employees" ADD COLUMN "avatar_path" TEXT; + +-- CreateEnum +CREATE TYPE "EmployeeContractType" AS ENUM ('labor', 'nda', 'non_compete', 'internship'); + +-- CreateTable +CREATE TABLE "employee_contracts" ( + "id" UUID NOT NULL, + "employee_id" UUID NOT NULL, + "contract_no" TEXT NOT NULL, + "contract_type" "EmployeeContractType" NOT NULL, + "start_date" DATE NOT NULL, + "end_date" DATE NOT NULL, + "terminated_date" DATE, + "attachment_path" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_by" UUID, + + CONSTRAINT "employee_contracts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "employee_contracts_employee_contract_no_ux" ON "employee_contracts"("employee_id", "contract_no"); + +-- CreateIndex +CREATE INDEX "idx_employee_contracts_employee_start" ON "employee_contracts"("employee_id", "start_date"); + +-- CreateIndex +CREATE INDEX "idx_employee_contracts_employee_end" ON "employee_contracts"("employee_id", "end_date"); + +-- AddForeignKey +ALTER TABLE "employee_contracts" ADD CONSTRAINT "employee_contracts_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 76c4716f..d740251f 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -343,6 +343,13 @@ enum EmployeePresenceSource { admin_override } +enum EmployeeContractType { + labor + nda + non_compete + internship +} + enum WeeklyReportStatus { draft submitted @@ -497,6 +504,7 @@ model Employee { emergency_contact_name String? emergency_contact_phone String? notes String? + avatar_path String? bank_account_encrypted String bank_name String @@ -563,28 +571,29 @@ model Employee { workflow_tasks_assigned WorkflowTask[] @relation("WorkflowTaskAssignee") workflow_tasks_completed WorkflowTask[] @relation("WorkflowTaskCompleter") - approval_transfers_from ApprovalTransfer[] @relation("TransferFrom") - approval_transfers_to ApprovalTransfer[] @relation("TransferTo") - password_reset_codes PasswordResetCode[] - lifecycle_events EmployeeLifecycleEvent[] - checklist_tasks EmployeeChecklistTask[] - presence_records EmployeePresence[] - weekly_reports WeeklyReport[] - todo_items_assigned TodoItem[] @relation("TodoAssignee") - todo_items_created TodoItem[] @relation("TodoCreator") - wecom_identity_binding WecomIdentityBinding? - owned_projects Project[] @relation("ProjectOwner") - managed_projects Project[] @relation("ProjectManager") - project_memberships ProjectMember[] - owned_project_milestones ProjectMilestone[] @relation("ProjectMilestoneOwner") - assigned_project_tasks ProjectTask[] @relation("ProjectTaskAssignee") - created_project_tasks ProjectTask[] @relation("ProjectTaskCreator") - owned_project_risks ProjectRisk[] @relation("ProjectRiskOwner") - owned_project_delivery_records ProjectDeliveryRecord[] @relation("ProjectDeliveryRecordOwner") - created_project_communication_logs ProjectCommunicationLog[] @relation("ProjectCommunicationLogCreator") + approval_transfers_from ApprovalTransfer[] @relation("TransferFrom") + approval_transfers_to ApprovalTransfer[] @relation("TransferTo") + password_reset_codes PasswordResetCode[] + lifecycle_events EmployeeLifecycleEvent[] + checklist_tasks EmployeeChecklistTask[] + presence_records EmployeePresence[] + weekly_reports WeeklyReport[] + todo_items_assigned TodoItem[] @relation("TodoAssignee") + todo_items_created TodoItem[] @relation("TodoCreator") + wecom_identity_binding WecomIdentityBinding? + owned_projects Project[] @relation("ProjectOwner") + managed_projects Project[] @relation("ProjectManager") + project_memberships ProjectMember[] + owned_project_milestones ProjectMilestone[] @relation("ProjectMilestoneOwner") + assigned_project_tasks ProjectTask[] @relation("ProjectTaskAssignee") + created_project_tasks ProjectTask[] @relation("ProjectTaskCreator") + owned_project_risks ProjectRisk[] @relation("ProjectRiskOwner") + owned_project_delivery_records ProjectDeliveryRecord[] @relation("ProjectDeliveryRecordOwner") + created_project_communication_logs ProjectCommunicationLog[] @relation("ProjectCommunicationLogCreator") completed_project_closure_checklists ProjectClosureChecklistItem[] @relation("ProjectClosureChecklistItemCompleter") - owned_contract_registries ContractRegistry[] @relation("ContractRegistryOwner") - owned_confidential_document_refs ConfidentialDocumentRef[] @relation("ConfidentialDocumentRefOwner") + owned_contract_registries ContractRegistry[] @relation("ContractRegistryOwner") + owned_confidential_document_refs ConfidentialDocumentRef[] @relation("ConfidentialDocumentRefOwner") + contracts EmployeeContract[] @@index([department_id]) @@index([legal_entity_id], map: "idx_employees_legal_entity") @@ -1549,30 +1558,30 @@ model EntityRiskFlag { } model PaymentBatch { - id String @id @default(uuid()) @db.Uuid - batch_no String @unique @db.VarChar(64) - entity_type PaymentBatchEntityType - name String - status PaymentBatchStatus @default(draft) - default_payment_date DateTime? @db.Date - default_payment_channel String? @db.VarChar(20) - voucher_prefix String? @db.VarChar(32) - review_comment String? + id String @id @default(uuid()) @db.Uuid + batch_no String @unique @db.VarChar(64) + entity_type PaymentBatchEntityType + name String + status PaymentBatchStatus @default(draft) + default_payment_date DateTime? @db.Date + default_payment_channel String? @db.VarChar(20) + voucher_prefix String? @db.VarChar(32) + review_comment String? secondary_review_comment String? - execution_summary_json Json? + execution_summary_json Json? - created_at DateTime @default(now()) - created_by String? @db.Uuid - reviewed_at DateTime? - reviewed_by String? @db.Uuid + created_at DateTime @default(now()) + created_by String? @db.Uuid + reviewed_at DateTime? + reviewed_by String? @db.Uuid secondary_reviewed_at DateTime? secondary_reviewed_by String? @db.Uuid - executed_at DateTime? - executed_by String? @db.Uuid - cancelled_at DateTime? - cancelled_by String? @db.Uuid - updated_at DateTime @updatedAt - updated_by String? @db.Uuid + executed_at DateTime? + executed_by String? @db.Uuid + cancelled_at DateTime? + cancelled_by String? @db.Uuid + updated_at DateTime @updatedAt + updated_by String? @db.Uuid items PaymentBatchItem[] @@ -1780,15 +1789,15 @@ model Project { created_at DateTime @default(now()) updated_at DateTime @default(now()) @updatedAt - owner Employee @relation("ProjectOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict) - manager Employee? @relation("ProjectManager", fields: [manager_employee_id], references: [id], onDelete: SetNull) - members ProjectMember[] - milestones ProjectMilestone[] - tasks ProjectTask[] - risks ProjectRisk[] - reimbursements ReimbursementForm[] - travel_requests TravelRequest[] - todo_items TodoItem[] + owner Employee @relation("ProjectOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict) + manager Employee? @relation("ProjectManager", fields: [manager_employee_id], references: [id], onDelete: SetNull) + members ProjectMember[] + milestones ProjectMilestone[] + tasks ProjectTask[] + risks ProjectRisk[] + reimbursements ReimbursementForm[] + travel_requests TravelRequest[] + todo_items TodoItem[] weekly_reports ProjectLinkOnWeeklyReport[] communication_logs ProjectCommunicationLog[] closure_checklist_items ProjectClosureChecklistItem[] @@ -1905,16 +1914,16 @@ model ProjectLinkOnWeeklyReport { } model ProjectCommunicationLog { - id String @id @default(uuid()) @db.Uuid - project_id String @db.Uuid + id String @id @default(uuid()) @db.Uuid + project_id String @db.Uuid happened_at DateTime channel ProjectCommunicationChannel external_contact_name String? external_org_name String? summary String next_action String? - next_follow_up_date DateTime? @db.Date - created_by String @db.Uuid + next_follow_up_date DateTime? @db.Date + created_by String @db.Uuid created_at DateTime @default(now()) updated_at DateTime @default(now()) @updatedAt @@ -1929,15 +1938,15 @@ model ProjectCommunicationLog { } model ProjectClosureChecklistItem { - id String @id @default(uuid()) @db.Uuid - project_id String @db.Uuid - item_key String @db.VarChar(64) + id String @id @default(uuid()) @db.Uuid + project_id String @db.Uuid + item_key String @db.VarChar(64) title String status ProjectClosureChecklistStatus @default(pending) note String? - sort_order Int @default(100) + sort_order Int @default(100) completed_at DateTime? - completed_by String? @db.Uuid + completed_by String? @db.Uuid created_at DateTime @default(now()) updated_at DateTime @default(now()) @updatedAt @@ -1975,6 +1984,29 @@ model ContractRegistry { @@map("contract_registry") } +model EmployeeContract { + id String @id @default(uuid()) @db.Uuid + employee_id String @db.Uuid + contract_no String + contract_type EmployeeContractType + start_date DateTime @db.Date + end_date DateTime @db.Date + terminated_date DateTime? @db.Date + attachment_path String? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @default(now()) @updatedAt + updated_by String? @db.Uuid + + employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade) + + @@unique([employee_id, contract_no], map: "employee_contracts_employee_contract_no_ux") + @@index([employee_id, start_date], map: "idx_employee_contracts_employee_start") + @@index([employee_id, end_date], map: "idx_employee_contracts_employee_end") + @@map("employee_contracts") +} + model ProjectDeliveryRecord { id String @id @default(uuid()) @db.Uuid project_id String @db.Uuid diff --git a/server/src/routes/employees.js b/server/src/routes/employees.js index 85ed0293..5fd6efbb 100644 --- a/server/src/routes/employees.js +++ b/server/src/routes/employees.js @@ -1,14 +1,21 @@ +const sharp = require("sharp"); const { z } = require("zod"); const usersRoutes = require("./users"); +const { currentDateString } = require("../utils/date"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); +const { listOrganizationDepartments } = require("../services/organization"); +const { StorageNotFoundError, openReadStream, writeBuffer } = require("../services/storage"); const UUID_SCHEMA = z.string().uuid(); const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const REPORTING_CHAIN_MAX_DEPTH = 12; +const AVATAR_ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp"]); +const AVATAR_MAX_BYTES = 2 * 1024 * 1024; +const CONTRACT_TYPE_SCHEMA = z.enum(["labor", "nda", "non_compete", "internship"]); const PROFILE_PATCH_SCHEMA = z .object({ departmentId: UUID_SCHEMA.optional(), @@ -50,6 +57,19 @@ const OFFBOARDING_SCHEMA = z.object({ notes: z.string().trim().max(500).nullable().optional(), }).strict(); +const CONTRACT_CREATE_SCHEMA = z.object({ + contractNo: z.string().trim().min(1).max(100), + contractType: CONTRACT_TYPE_SCHEMA, + startDate: DATE_SCHEMA, + endDate: DATE_SCHEMA, + terminatedDate: DATE_SCHEMA.nullable().optional(), + attachmentPath: z.string().trim().max(500).nullable().optional(), +}).strict(); + +const CONTRACT_PATCH_SCHEMA = CONTRACT_CREATE_SCHEMA.partial() + .strict() + .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); + function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } @@ -91,10 +111,67 @@ function canManageProfile(fastify, request, employeeId) { return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view"); } +function canViewPeopleDirectory(fastify, request) { + return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view"); +} + function canManageLifecycle(fastify, request) { return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*"); } +function canUploadAvatar(fastify, request, employeeId) { + return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users"); +} + +function buildAvatarUrl(employeeId, avatarPath) { + return avatarPath ? `/api/avatars/${employeeId}` : null; +} + +function parseDateOnly(value) { + return value ? new Date(`${value}T00:00:00.000Z`) : null; +} + +function validateContractDates(value) { + if (value.startDate && value.endDate && value.startDate > value.endDate) { + throw buildValidationError({ endDate: "must_be_after_or_equal_startDate" }); + } + if (value.startDate && value.terminatedDate && value.terminatedDate < value.startDate) { + throw buildValidationError({ terminatedDate: "must_be_after_or_equal_startDate" }); + } +} + +function deriveEmployeeContractStatus(contract, timeZone) { + if (contract.terminated_date) { + return "terminated"; + } + + const today = currentDateString(timeZone ?? "Asia/Shanghai"); + const endDate = contract.end_date?.toISOString?.()?.slice(0, 10) ?? null; + if (endDate && endDate < today) { + return "expired"; + } + + return "active"; +} + +function mapEmployeeContract(contract, timeZone) { + return { + id: contract.id, + employeeId: contract.employee_id, + contractNo: contract.contract_no, + contractType: contract.contract_type, + startDate: contract.start_date?.toISOString?.()?.slice(0, 10) ?? null, + endDate: contract.end_date?.toISOString?.()?.slice(0, 10) ?? null, + terminatedDate: contract.terminated_date?.toISOString?.()?.slice(0, 10) ?? null, + attachmentPath: contract.attachment_path ?? null, + status: deriveEmployeeContractStatus(contract, timeZone), + createdAt: contract.created_at?.toISOString?.() ?? null, + createdBy: contract.created_by ?? null, + updatedAt: contract.updated_at?.toISOString?.() ?? null, + updatedBy: contract.updated_by ?? null, + }; +} + function mapChecklistTask(item) { return { id: item.id, @@ -130,6 +207,7 @@ function mapOrganizationNode(item) { name: item.name, title: item.title, status: item.status, + avatarUrl: buildAvatarUrl(item.id, item.avatar_path), department: item.department ? { id: item.department.id, name: item.department.name } : null, }; } @@ -154,6 +232,16 @@ async function ensureEmployeeExists(prisma, employeeId) { return employee; } +async function ensureEmployeeContractExists(prisma, employeeId, contractId) { + const contract = await prisma.employeeContract.findFirst({ + where: { id: contractId, employee_id: employeeId }, + }); + if (!contract) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + return contract; +} + function buildDefaultChecklist(scene, employeeId, actorId) { const common = { employee_id: employeeId }; if (scene === "onboarding") { @@ -231,6 +319,7 @@ async function buildEmployeeOrganization(prisma, employee) { name: true, title: true, status: true, + avatar_path: true, direct_manager_id: true, department: { select: { id: true, name: true } }, }, @@ -255,6 +344,7 @@ async function buildEmployeeOrganization(prisma, employee) { name: true, title: true, status: true, + avatar_path: true, department: { select: { id: true, name: true } }, }, }); @@ -287,10 +377,11 @@ async function buildEmployeeProfile(prisma, employeeId) { emergency_contact_name: true, emergency_contact_phone: true, notes: true, + avatar_path: true, archived_at: true, roles: { select: { role_code: true } }, department: { select: { id: true, name: true } }, - direct_manager: { select: { id: true, employee_no: true, name: true } }, + direct_manager: { select: { id: true, employee_no: true, name: true, avatar_path: true } }, }, }), prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }), @@ -328,10 +419,18 @@ async function buildEmployeeProfile(prisma, employeeId) { emergencyContactName: employee.emergency_contact_name ?? null, emergencyContactPhone: employee.emergency_contact_phone ?? null, notes: employee.notes ?? null, + avatarUrl: buildAvatarUrl(employee.id, employee.avatar_path), archivedAt: employee.archived_at?.toISOString?.() ?? null, roleCodes: employee.roles.map((item) => item.role_code), department: employee.department ? { id: employee.department.id, name: employee.department.name } : null, - directManager: employee.direct_manager ? { id: employee.direct_manager.id, employeeNo: employee.direct_manager.employee_no, name: employee.direct_manager.name } : null, + directManager: employee.direct_manager + ? { + id: employee.direct_manager.id, + employeeNo: employee.direct_manager.employee_no, + name: employee.direct_manager.name, + avatarUrl: buildAvatarUrl(employee.direct_manager.id, employee.direct_manager.avatar_path), + } + : null, }, lifecycle: { events: lifecycleEvents.map(mapLifecycleEvent), @@ -352,6 +451,104 @@ async function buildEmployeeProfile(prisma, employeeId) { async function employeesRoutes(fastify) { await usersRoutes(fastify, { basePath: "/employees", allowDepartmentFilter: true }); + fastify.get("/avatars/:employeeId", async (request, reply) => { + const employeeId = parseId(request.params?.employeeId); + const employee = await fastify.prisma.employee.findUnique({ + where: { id: employeeId }, + select: { avatar_path: true }, + }); + + if (!employee?.avatar_path) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + + try { + const opened = await openReadStream({ + config: fastify.config, + storagePath: employee.avatar_path, + }); + reply.header("cache-control", "no-store"); + return reply.type("image/webp").send(opened.stream); + } catch (error) { + if (error instanceof StorageNotFoundError) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + throw error; + } + }); + + fastify.put("/employees/:id/avatar", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request); + const employeeId = parseId(request.params?.id); + if (!canUploadAvatar(fastify, request, employeeId)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.avatar.write" } }); + } + + await ensureEmployeeExists(fastify.prisma, employeeId); + if (!request.isMultipart()) { + throw buildValidationError({ file: "invalid_multipart" }); + } + + const filePart = await request.file(); + if (!filePart) { + throw buildValidationError({ file: "required" }); + } + if (filePart.fieldname !== "file") { + throw buildValidationError({ file: "invalid_field" }); + } + if (!AVATAR_ALLOWED_MIME_TYPES.has(filePart.mimetype)) { + throw buildValidationError({ file: "invalid_type" }); + } + + const rawBuffer = await filePart.toBuffer(); + if (!rawBuffer.length) { + throw buildValidationError({ file: "empty" }); + } + if (rawBuffer.length > AVATAR_MAX_BYTES) { + throw buildValidationError({ file: "file_too_large" }); + } + + let normalizedBuffer; + try { + normalizedBuffer = await sharp(rawBuffer) + .resize(200, 200, { fit: "cover", position: "centre" }) + .webp({ quality: 90 }) + .toBuffer(); + } catch { + throw buildValidationError({ file: "invalid_image" }); + } + + const avatarPath = await writeBuffer({ + config: fastify.config, + key: `avatars/${employeeId}.webp`, + buffer: normalizedBuffer, + contentType: "image/webp", + }); + + await fastify.prisma.employee.update({ + where: { id: employeeId }, + data: { + avatar_path: avatarPath, + updated_by: request.employee.id, + }, + }); + + return sendData(reply, request, { + employeeId, + avatarUrl: buildAvatarUrl(employeeId, avatarPath), + }); + }); + + fastify.get("/employees/organization", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request); + if (!canViewPeopleDirectory(fastify, request)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.organization.view" } }); + } + + const items = await listOrganizationDepartments(fastify.prisma); + return sendData(reply, request, { items }); + }); + fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); @@ -362,6 +559,104 @@ async function employeesRoutes(fastify) { return sendData(reply, request, profile); }); + fastify.get("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request); + if (!fastify.hasPermission(request.employee, "system.users")) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); + } + + const employeeId = parseId(request.params?.id); + await ensureEmployeeExists(fastify.prisma, employeeId); + + const items = await fastify.prisma.employeeContract.findMany({ + where: { employee_id: employeeId }, + orderBy: [{ start_date: "desc" }, { created_at: "desc" }], + }); + + return sendData(reply, request, { + items: items.map((item) => mapEmployeeContract(item, fastify.config.appTimezone)), + }); + }); + + fastify.post("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request); + if (!fastify.hasPermission(request.employee, "system.users")) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); + } + + const employeeId = parseId(request.params?.id); + const body = parseBody(CONTRACT_CREATE_SCHEMA, request.body); + validateContractDates(body); + await ensureEmployeeExists(fastify.prisma, employeeId); + + try { + const contract = await fastify.prisma.employeeContract.create({ + data: { + employee_id: employeeId, + contract_no: body.contractNo, + contract_type: body.contractType, + start_date: parseDateOnly(body.startDate), + end_date: parseDateOnly(body.endDate), + terminated_date: parseDateOnly(body.terminatedDate ?? null), + attachment_path: body.attachmentPath ?? null, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + }); + + return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone)); + } catch (error) { + if (error?.code === "P2002") { + throw buildValidationError({ contractNo: "duplicate" }); + } + throw error; + } + }); + + fastify.patch("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => { + await ensureFeatureEnabled(fastify, request); + if (!fastify.hasPermission(request.employee, "system.users")) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); + } + + const employeeId = parseId(request.params?.id); + const contractId = parseId(request.params?.contractId); + const body = parseBody(CONTRACT_PATCH_SCHEMA, request.body); + await ensureEmployeeExists(fastify.prisma, employeeId); + + const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId); + validateContractDates({ + startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10), + endDate: body.endDate ?? current.end_date?.toISOString?.()?.slice(0, 10), + terminatedDate: + body.terminatedDate !== undefined + ? body.terminatedDate + : current.terminated_date?.toISOString?.()?.slice(0, 10), + }); + + try { + const contract = await fastify.prisma.employeeContract.update({ + where: { id: contractId }, + data: { + ...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}), + ...(body.contractType !== undefined ? { contract_type: body.contractType } : {}), + ...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}), + ...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate) } : {}), + ...(body.terminatedDate !== undefined ? { terminated_date: parseDateOnly(body.terminatedDate ?? null) } : {}), + ...(body.attachmentPath !== undefined ? { attachment_path: body.attachmentPath ?? null } : {}), + updated_by: request.employee.id, + }, + }); + + return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone)); + } catch (error) { + if (error?.code === "P2002") { + throw buildValidationError({ contractNo: "duplicate" }); + } + throw error; + } + }); + fastify.patch("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); @@ -599,5 +894,7 @@ module.exports = employeesRoutes; module.exports.__testables = { buildEmployeeOrganization, buildEmployeeProfile, + deriveEmployeeContractStatus, + mapEmployeeContract, mapOrganizationNode, }; diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 89aea07d..79a10c90 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -186,6 +186,7 @@ function mapEmployeeSummary(row) { status: row.status, departmentId: row.department_id, title: row.title, + avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null, roleCodes: row.roles.map((item) => item.role_code), createdAt: row.created_at?.toISOString?.() ?? null, updatedAt: row.updated_at?.toISOString?.() ?? null, @@ -785,6 +786,7 @@ async function usersRoutes(fastify, opts = {}) { status: true, department_id: true, title: true, + avatar_path: true, created_at: true, updated_at: true, roles: { select: { role_code: true } }, diff --git a/server/src/services/organization.js b/server/src/services/organization.js new file mode 100644 index 00000000..f9bfee64 --- /dev/null +++ b/server/src/services/organization.js @@ -0,0 +1,154 @@ +function buildAvatarUrl(employeeId, avatarPath) { + return avatarPath ? `/api/avatars/${employeeId}` : null; +} + +function compareByEmployeeNo(left, right) { + return left.employee_no.localeCompare(right.employee_no, "en"); +} + +function mapDepartmentSummary(department) { + if (!department) { + return null; + } + return { + id: department.id, + name: department.name, + }; +} + +function mapExternalManager(employee, departmentId, activeEmployeeById) { + if (!employee.direct_manager_id) { + return null; + } + + const manager = activeEmployeeById.get(employee.direct_manager_id) ?? null; + if (!manager) { + return null; + } + + if (manager.department_id === departmentId) { + return null; + } + + return { + id: manager.id, + employeeNo: manager.employee_no, + name: manager.name, + department: mapDepartmentSummary(manager.department), + }; +} + +function mapOrganizationNode(employee, departmentId, activeEmployeeById, childrenByManagerId, stack) { + const nextStack = new Set(stack); + nextStack.add(employee.id); + + const children = (childrenByManagerId.get(employee.id) ?? []) + .slice() + .sort(compareByEmployeeNo) + .filter((item) => !nextStack.has(item.id)) + .map((item) => mapOrganizationNode(item, departmentId, activeEmployeeById, childrenByManagerId, nextStack)); + + return { + id: employee.id, + employeeNo: employee.employee_no, + name: employee.name, + title: employee.title, + status: employee.status, + avatarUrl: buildAvatarUrl(employee.id, employee.avatar_path), + department: mapDepartmentSummary(employee.department), + externalManager: mapExternalManager(employee, departmentId, activeEmployeeById), + children, + }; +} + +function buildDepartmentSection(department, employees, activeEmployeeById) { + const departmentId = department.id; + const departmentEmployeeById = new Map(employees.map((item) => [item.id, item])); + const childrenByManagerId = new Map(); + + for (const employee of employees) { + childrenByManagerId.set(employee.id, []); + } + + for (const employee of employees) { + if (!employee.direct_manager_id) { + continue; + } + const manager = activeEmployeeById.get(employee.direct_manager_id); + if (!manager || manager.department_id !== departmentId) { + continue; + } + if (!departmentEmployeeById.has(manager.id)) { + continue; + } + childrenByManagerId.get(manager.id).push(employee); + } + + const roots = employees + .filter((employee) => { + if (!employee.direct_manager_id) { + return true; + } + const manager = activeEmployeeById.get(employee.direct_manager_id); + return !manager || manager.department_id !== departmentId; + }) + .slice() + .sort(compareByEmployeeNo) + .map((employee) => mapOrganizationNode(employee, departmentId, activeEmployeeById, childrenByManagerId, new Set())); + + return { + department: mapDepartmentSummary(department), + roots, + }; +} + +async function listOrganizationDepartments(prisma) { + const employees = await prisma.employee.findMany({ + where: { status: "active" }, + select: { + id: true, + employee_no: true, + name: true, + title: true, + status: true, + avatar_path: true, + department_id: true, + direct_manager_id: true, + department: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + const activeEmployeeById = new Map(employees.map((item) => [item.id, item])); + const departmentGroups = new Map(); + + for (const employee of employees) { + const departmentId = employee.department?.id ?? employee.department_id; + if (!departmentId || !employee.department) { + continue; + } + if (!departmentGroups.has(departmentId)) { + departmentGroups.set(departmentId, { + department: employee.department, + employees: [], + }); + } + departmentGroups.get(departmentId).employees.push(employee); + } + + return [...departmentGroups.values()] + .sort((left, right) => left.department.name.localeCompare(right.department.name, "zh-CN")) + .map((group) => buildDepartmentSection(group.department, group.employees, activeEmployeeById)); +} + +module.exports = { + listOrganizationDepartments, + __testables: { + buildDepartmentSection, + mapOrganizationNode, + }, +}; diff --git a/server/src/services/presenceHistory.js b/server/src/services/presenceHistory.js index e59dc322..1a81846d 100644 --- a/server/src/services/presenceHistory.js +++ b/server/src/services/presenceHistory.js @@ -108,6 +108,7 @@ function mapPresenceEmployee(row) { employeeNo: row.employee_no, name: row.name, title: row.title, + avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null, employmentStatus: row.employment_status, department: row.department ? { id: row.department.id, name: row.department.name } : null, }; @@ -309,6 +310,7 @@ async function listActivePresenceEmployees(prisma, filters = {}) { employee_no: true, name: true, title: true, + avatar_path: true, employment_status: true, department: { select: { id: true, name: true } }, },