测试
This commit is contained in:
parent
ff6739ab68
commit
aa1346f6af
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
DATABASE_URL="postgresql://postgres:lyh1234@192.168.0.180:5432/tyt-api-nest"
|
||||
AUTH_TOKEN_SECRET="replace-with-a-strong-random-secret"
|
||||
SYSTEM_ADMIN_BOOTSTRAP_KEY="replace-with-admin-bootstrap-key"
|
||||
@ -17,10 +17,14 @@
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@prisma/adapter-pg": "^7.5.0",
|
||||
"@prisma/client": "^7.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
@ -29,7 +33,9 @@
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"globals": "^16.0.0",
|
||||
|
||||
163
pnpm-lock.yaml
generated
163
pnpm-lock.yaml
generated
@ -14,6 +14,9 @@ importers:
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/event-emitter':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
'@nestjs/mapped-types':
|
||||
specifier: '*'
|
||||
version: 2.1.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
||||
@ -26,6 +29,15 @@ importers:
|
||||
'@prisma/client':
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
pg:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
@ -45,9 +57,15 @@ importers:
|
||||
'@nestjs/testing':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
|
||||
'@types/bcrypt':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.6
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.10
|
||||
version: 9.0.10
|
||||
'@types/node':
|
||||
specifier: ^22.10.7
|
||||
version: 22.19.15
|
||||
@ -380,6 +398,12 @@ packages:
|
||||
'@nestjs/websockets':
|
||||
optional: true
|
||||
|
||||
'@nestjs/event-emitter@3.0.1':
|
||||
resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||
|
||||
'@nestjs/mapped-types@2.1.0':
|
||||
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
|
||||
peerDependencies:
|
||||
@ -512,6 +536,9 @@ packages:
|
||||
'@tsconfig/node16@1.0.4':
|
||||
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
||||
|
||||
'@types/bcrypt@6.0.0':
|
||||
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
@ -542,9 +569,15 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@22.19.15':
|
||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||
|
||||
@ -730,6 +763,10 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bcrypt@6.0.0:
|
||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
@ -753,6 +790,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@ -967,10 +1007,17 @@ packages:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@17.3.1:
|
||||
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@ -1049,6 +1096,9 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
eventemitter2@6.4.9:
|
||||
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
@ -1279,6 +1329,16 @@ packages:
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
lilconfig@2.1.0:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
@ -1294,6 +1354,27 @@ packages:
|
||||
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
|
||||
engines: {node: '>=6.11.5'}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.isstring@4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
@ -1420,12 +1501,20 @@ packages:
|
||||
node-abort-controller@3.1.1:
|
||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||
|
||||
node-addon-api@8.6.0:
|
||||
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
|
||||
node-emoji@1.11.0:
|
||||
resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==}
|
||||
|
||||
node-fetch-native@1.6.7:
|
||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
node-releases@2.0.36:
|
||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||
|
||||
@ -2350,6 +2439,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
|
||||
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
eventemitter2: 6.4.9
|
||||
|
||||
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@ -2505,6 +2600,10 @@ snapshots:
|
||||
|
||||
'@tsconfig/node16@1.0.4': {}
|
||||
|
||||
'@types/bcrypt@6.0.0':
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
@ -2545,8 +2644,15 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 22.19.15
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@22.19.15':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@ -2751,6 +2857,11 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.0: {}
|
||||
|
||||
bcrypt@6.0.0:
|
||||
dependencies:
|
||||
node-addon-api: 8.6.0
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
@ -2792,6 +2903,8 @@ snapshots:
|
||||
node-releases: 2.0.36
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
@ -2980,12 +3093,18 @@ snapshots:
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dotenv@17.3.1: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
effect@3.18.4:
|
||||
@ -3048,6 +3167,8 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventemitter2@6.4.9: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
express@5.2.1:
|
||||
@ -3309,6 +3430,30 @@ snapshots:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.7.4
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
lilconfig@2.1.0: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
@ -3317,6 +3462,20 @@ snapshots:
|
||||
|
||||
loader-runner@4.3.1: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
@ -3420,12 +3579,16 @@ snapshots:
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
|
||||
node-addon-api@8.6.0: {}
|
||||
|
||||
node-emoji@1.11.0:
|
||||
dependencies:
|
||||
lodash: 4.17.23
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
node-releases@2.0.36: {}
|
||||
|
||||
nypm@0.6.5:
|
||||
|
||||
@ -1,9 +1,3 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
@ -13,18 +7,132 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
name String?
|
||||
posts Post[]
|
||||
enum Role {
|
||||
SYSTEM_ADMIN
|
||||
HOSPITAL_ADMIN
|
||||
DIRECTOR
|
||||
LEADER
|
||||
DOCTOR
|
||||
ENGINEER
|
||||
}
|
||||
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
content String?
|
||||
published Boolean? @default(false)
|
||||
author User? @relation(fields: [authorId], references: [id])
|
||||
authorId Int?
|
||||
enum DeviceStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
model Hospital {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
departments Department[]
|
||||
users User[]
|
||||
patients Patient[]
|
||||
tasks Task[]
|
||||
}
|
||||
|
||||
model Department {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
hospitalId Int
|
||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||
groups Group[]
|
||||
users User[]
|
||||
|
||||
@@index([hospitalId])
|
||||
}
|
||||
|
||||
model Group {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
departmentId Int
|
||||
department Department @relation(fields: [departmentId], references: [id])
|
||||
users User[]
|
||||
|
||||
@@index([departmentId])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
// Backend login password hash (bcrypt).
|
||||
passwordHash String?
|
||||
openId String? @unique
|
||||
role Role
|
||||
hospitalId Int?
|
||||
departmentId Int?
|
||||
groupId Int?
|
||||
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
||||
department Department? @relation(fields: [departmentId], references: [id])
|
||||
group Group? @relation(fields: [groupId], references: [id])
|
||||
doctorPatients Patient[] @relation("DoctorPatients")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
acceptedTasks Task[] @relation("TaskEngineer")
|
||||
|
||||
@@index([phone])
|
||||
@@index([hospitalId, role])
|
||||
@@index([departmentId, role])
|
||||
@@index([groupId, role])
|
||||
}
|
||||
|
||||
model Patient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
idCardHash String
|
||||
hospitalId Int
|
||||
doctorId Int
|
||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
||||
devices Device[]
|
||||
|
||||
@@index([phone, idCardHash])
|
||||
@@index([hospitalId, doctorId])
|
||||
}
|
||||
|
||||
model Device {
|
||||
id Int @id @default(autoincrement())
|
||||
snCode String @unique
|
||||
currentPressure Int
|
||||
status DeviceStatus @default(ACTIVE)
|
||||
patientId Int
|
||||
patient Patient @relation(fields: [patientId], references: [id])
|
||||
taskItems TaskItem[]
|
||||
|
||||
@@index([patientId, status])
|
||||
}
|
||||
|
||||
model Task {
|
||||
id Int @id @default(autoincrement())
|
||||
status TaskStatus @default(PENDING)
|
||||
creatorId Int
|
||||
engineerId Int?
|
||||
hospitalId Int
|
||||
createdAt DateTime @default(now())
|
||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
|
||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||
items TaskItem[]
|
||||
|
||||
@@index([hospitalId, status, createdAt])
|
||||
}
|
||||
|
||||
model TaskItem {
|
||||
id Int @id @default(autoincrement())
|
||||
taskId Int
|
||||
deviceId Int
|
||||
oldPressure Int
|
||||
targetPressure Int
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@index([taskId])
|
||||
@@index([deviceId])
|
||||
}
|
||||
|
||||
202
prisma/seed.mjs
Normal file
202
prisma/seed.mjs
Normal file
@ -0,0 +1,202 @@
|
||||
import 'dotenv/config';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { hash } from 'bcrypt';
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||
import { DeviceStatus, Role } from '../src/generated/prisma/enums.js';
|
||||
|
||||
// Keep the seed executable with the same pg driver adapter used by PrismaService.
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL is required to run seed');
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString }),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
// Default seed login password (plain): Seed@1234
|
||||
const seedPasswordHash = await hash('Seed@1234', 12);
|
||||
|
||||
// Seed a baseline organization tree for local/demo usage.
|
||||
const hospital =
|
||||
(await prisma.hospital.findFirst({ where: { name: 'Demo Hospital' } })) ??
|
||||
(await prisma.hospital.create({
|
||||
data: { name: 'Demo Hospital' },
|
||||
}));
|
||||
|
||||
const department =
|
||||
(await prisma.department.findFirst({
|
||||
where: {
|
||||
hospitalId: hospital.id,
|
||||
name: 'Neurosurgery',
|
||||
},
|
||||
})) ??
|
||||
(await prisma.department.create({
|
||||
data: {
|
||||
hospitalId: hospital.id,
|
||||
name: 'Neurosurgery',
|
||||
},
|
||||
}));
|
||||
|
||||
const group =
|
||||
(await prisma.group.findFirst({
|
||||
where: {
|
||||
departmentId: department.id,
|
||||
name: 'Shift-A',
|
||||
},
|
||||
})) ??
|
||||
(await prisma.group.create({
|
||||
data: {
|
||||
departmentId: department.id,
|
||||
name: 'Shift-A',
|
||||
},
|
||||
}));
|
||||
|
||||
// Use openId as idempotent unique key for seeded users.
|
||||
const systemAdmin = await prisma.user.upsert({
|
||||
where: { openId: 'seed-system-admin-openid' },
|
||||
update: {
|
||||
name: 'System Admin',
|
||||
phone: '13800000000',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.SYSTEM_ADMIN,
|
||||
hospitalId: null,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
},
|
||||
create: {
|
||||
name: 'System Admin',
|
||||
phone: '13800000000',
|
||||
passwordHash: seedPasswordHash,
|
||||
openId: 'seed-system-admin-openid',
|
||||
role: Role.SYSTEM_ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { openId: 'seed-hospital-admin-openid' },
|
||||
update: {
|
||||
name: 'Hospital Admin',
|
||||
phone: '13800000001',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.HOSPITAL_ADMIN,
|
||||
hospitalId: hospital.id,
|
||||
departmentId: department.id,
|
||||
groupId: group.id,
|
||||
},
|
||||
create: {
|
||||
name: 'Hospital Admin',
|
||||
phone: '13800000001',
|
||||
passwordHash: seedPasswordHash,
|
||||
openId: 'seed-hospital-admin-openid',
|
||||
role: Role.HOSPITAL_ADMIN,
|
||||
hospitalId: hospital.id,
|
||||
departmentId: department.id,
|
||||
groupId: group.id,
|
||||
},
|
||||
});
|
||||
|
||||
const doctor = await prisma.user.upsert({
|
||||
where: { openId: 'seed-doctor-openid' },
|
||||
update: {
|
||||
name: 'Doctor Demo',
|
||||
phone: '13800000002',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: hospital.id,
|
||||
departmentId: department.id,
|
||||
groupId: group.id,
|
||||
},
|
||||
create: {
|
||||
name: 'Doctor Demo',
|
||||
phone: '13800000002',
|
||||
passwordHash: seedPasswordHash,
|
||||
openId: 'seed-doctor-openid',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: hospital.id,
|
||||
departmentId: department.id,
|
||||
groupId: group.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { openId: 'seed-engineer-openid' },
|
||||
update: {
|
||||
name: 'Engineer Demo',
|
||||
phone: '13800000009',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.ENGINEER,
|
||||
hospitalId: hospital.id,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
},
|
||||
create: {
|
||||
name: 'Engineer Demo',
|
||||
phone: '13800000009',
|
||||
passwordHash: seedPasswordHash,
|
||||
openId: 'seed-engineer-openid',
|
||||
role: Role.ENGINEER,
|
||||
hospitalId: hospital.id,
|
||||
},
|
||||
});
|
||||
|
||||
const patient =
|
||||
(await prisma.patient.findFirst({
|
||||
where: {
|
||||
hospitalId: hospital.id,
|
||||
phone: '13800000003',
|
||||
idCardHash: 'seed-id-card-hash',
|
||||
},
|
||||
})) ??
|
||||
(await prisma.patient.create({
|
||||
data: {
|
||||
hospitalId: hospital.id,
|
||||
doctorId: doctor.id,
|
||||
name: 'Patient Demo',
|
||||
phone: '13800000003',
|
||||
idCardHash: 'seed-id-card-hash',
|
||||
},
|
||||
}));
|
||||
|
||||
await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-001' },
|
||||
update: {
|
||||
patientId: patient.id,
|
||||
currentPressure: 110,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-001',
|
||||
patientId: patient.id,
|
||||
currentPressure: 110,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
hospitalId: hospital.id,
|
||||
departmentId: department.id,
|
||||
groupId: group.id,
|
||||
systemAdminId: systemAdmin.id,
|
||||
doctorId: doctor.id,
|
||||
patientId: patient.id,
|
||||
seedPasswordPlain: 'Seed@1234',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error('Seed failed:', error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@ -1,7 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { PrismaModule } from './prisma.module.js';
|
||||
import { UsersModule } from './users/users.module.js';
|
||||
import { TasksModule } from './tasks/tasks.module.js';
|
||||
import { PatientsModule } from './patients/patients.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
UsersModule,
|
||||
TasksModule,
|
||||
PatientsModule,
|
||||
AuthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
86
src/auth/access-token.guard.ts
Normal file
86
src/auth/access-token.guard.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { ActorContext } from '../common/actor-context.js';
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<
|
||||
{
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
actor?: unknown;
|
||||
}
|
||||
>();
|
||||
|
||||
const authorization = request.headers.authorization;
|
||||
const headerValue = Array.isArray(authorization)
|
||||
? authorization[0]
|
||||
: authorization;
|
||||
|
||||
if (!headerValue || !headerValue.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
|
||||
const token = headerValue.slice('Bearer '.length).trim();
|
||||
request.actor = this.verifyAndExtractActor(token);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private verifyAndExtractActor(token: string): ActorContext {
|
||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException('AUTH_TOKEN_SECRET is not configured');
|
||||
}
|
||||
|
||||
let payload: string | jwt.JwtPayload;
|
||||
try {
|
||||
payload = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'tyt-api-nest',
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
|
||||
if (typeof payload !== 'object') {
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
|
||||
const role = payload.role;
|
||||
if (typeof role !== 'string' || !Object.values(Role).includes(role as Role)) {
|
||||
throw new UnauthorizedException('Invalid role in token');
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.asInt(payload.id, 'id'),
|
||||
role: role as Role,
|
||||
hospitalId: this.asNullableInt(payload.hospitalId, 'hospitalId'),
|
||||
departmentId: this.asNullableInt(payload.departmentId, 'departmentId'),
|
||||
groupId: this.asNullableInt(payload.groupId, 'groupId'),
|
||||
};
|
||||
}
|
||||
|
||||
private asInt(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new UnauthorizedException(`Invalid ${field} in token`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private asNullableInt(value: unknown, field: string): number | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new UnauthorizedException(`Invalid ${field} in token`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
28
src/auth/auth.controller.ts
Normal file
28
src/auth/auth.controller.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||
import { LoginDto } from '../users/dto/login.dto.js';
|
||||
import { AccessTokenGuard } from './access-token.guard.js';
|
||||
import { CurrentActor } from './current-actor.decorator.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterUserDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AccessTokenGuard)
|
||||
me(@CurrentActor() actor: ActorContext) {
|
||||
return this.authService.me(actor);
|
||||
}
|
||||
}
|
||||
13
src/auth/auth.module.ts
Normal file
13
src/auth/auth.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { UsersModule } from '../users/users.module.js';
|
||||
import { AccessTokenGuard } from './access-token.guard.js';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
providers: [AuthService, AccessTokenGuard],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService, AccessTokenGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
22
src/auth/auth.service.ts
Normal file
22
src/auth/auth.service.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ActorContext } from '../common/actor-context.js';
|
||||
import { UsersService } from '../users/users.service.js';
|
||||
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||
import { LoginDto } from '../users/dto/login.dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
register(dto: RegisterUserDto) {
|
||||
return this.usersService.register(dto);
|
||||
}
|
||||
|
||||
login(dto: LoginDto) {
|
||||
return this.usersService.login(dto);
|
||||
}
|
||||
|
||||
me(actor: ActorContext) {
|
||||
return this.usersService.me(actor);
|
||||
}
|
||||
}
|
||||
9
src/auth/current-actor.decorator.ts
Normal file
9
src/auth/current-actor.decorator.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { ActorContext } from '../common/actor-context.js';
|
||||
|
||||
export const CurrentActor = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): ActorContext => {
|
||||
const request = context.switchToHttp().getRequest<{ actor: ActorContext }>();
|
||||
return request.actor;
|
||||
},
|
||||
);
|
||||
5
src/auth/roles.decorator.ts
Normal file
5
src/auth/roles.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
33
src/auth/roles.guard.ts
Normal file
33
src/auth/roles.guard.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { ROLES_KEY } from './roles.decorator.js';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>();
|
||||
const actorRole = request.actor?.role;
|
||||
if (!actorRole || !requiredRoles.includes(actorRole)) {
|
||||
throw new ForbiddenException('Role is not allowed for this endpoint');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
src/common/actor-context.ts
Normal file
9
src/common/actor-context.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
|
||||
export type ActorContext = {
|
||||
id: number;
|
||||
role: Role;
|
||||
hospitalId: number | null;
|
||||
departmentId: number | null;
|
||||
groupId: number | null;
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.js';
|
||||
|
||||
|
||||
32
src/patients/b-patients/b-patients.controller.ts
Normal file
32
src/patients/b-patients/b-patients.controller.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||
import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { PatientService } from '../patient/patient.service.js';
|
||||
|
||||
@Controller('b/patients')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BPatientsController {
|
||||
constructor(private readonly patientService: PatientService) {}
|
||||
|
||||
@Get()
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
findVisiblePatients(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Query('hospitalId') hospitalId?: string,
|
||||
) {
|
||||
const requestedHospitalId =
|
||||
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
|
||||
|
||||
return this.patientService.findPatientsForB(actor, requestedHospitalId);
|
||||
}
|
||||
}
|
||||
16
src/patients/c-patients/c-patients.controller.ts
Normal file
16
src/patients/c-patients/c-patients.controller.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { PatientService } from '../patient/patient.service.js';
|
||||
import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
|
||||
|
||||
@Controller('c/patients')
|
||||
export class CPatientsController {
|
||||
constructor(private readonly patientService: PatientService) {}
|
||||
|
||||
@Get('lifecycle')
|
||||
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
||||
return this.patientService.getFamilyLifecycleByIdentity(
|
||||
query.phone,
|
||||
query.idCardHash,
|
||||
);
|
||||
}
|
||||
}
|
||||
4
src/patients/dto/family-lifecycle-query.dto.ts
Normal file
4
src/patients/dto/family-lifecycle-query.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export class FamilyLifecycleQueryDto {
|
||||
phone!: string;
|
||||
idCardHash!: string;
|
||||
}
|
||||
154
src/patients/patient/patient.service.ts
Normal file
154
src/patients/patient/patient.service.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../../prisma.service.js';
|
||||
import { ActorContext } from '../../common/actor-context.js';
|
||||
|
||||
@Injectable()
|
||||
export class PatientService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findPatientsForB(actor: ActorContext, requestedHospitalId?: number) {
|
||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||||
|
||||
const where: Record<string, unknown> = { hospitalId };
|
||||
switch (actor.role) {
|
||||
case Role.DOCTOR:
|
||||
where.doctorId = actor.id;
|
||||
break;
|
||||
case Role.LEADER:
|
||||
if (!actor.groupId) {
|
||||
throw new BadRequestException('Actor groupId is required for LEADER');
|
||||
}
|
||||
where.doctor = {
|
||||
groupId: actor.groupId,
|
||||
role: Role.DOCTOR,
|
||||
};
|
||||
break;
|
||||
case Role.DIRECTOR:
|
||||
if (!actor.departmentId) {
|
||||
throw new BadRequestException(
|
||||
'Actor departmentId is required for DIRECTOR',
|
||||
);
|
||||
}
|
||||
where.doctor = {
|
||||
departmentId: actor.departmentId,
|
||||
role: Role.DOCTOR,
|
||||
};
|
||||
break;
|
||||
case Role.HOSPITAL_ADMIN:
|
||||
case Role.SYSTEM_ADMIN:
|
||||
break;
|
||||
default:
|
||||
throw new ForbiddenException('Role cannot query B-side patient list');
|
||||
}
|
||||
|
||||
return this.prisma.patient.findMany({
|
||||
where,
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getFamilyLifecycleByIdentity(phone: string, idCardHash: string) {
|
||||
if (!phone || !idCardHash) {
|
||||
throw new BadRequestException('phone and idCardHash are required');
|
||||
}
|
||||
|
||||
const patients = await this.prisma.patient.findMany({
|
||||
where: {
|
||||
phone,
|
||||
idCardHash,
|
||||
},
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
devices: {
|
||||
include: {
|
||||
taskItems: {
|
||||
include: {
|
||||
task: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lifecycle = patients
|
||||
.flatMap((patient) =>
|
||||
patient.devices.flatMap((device) =>
|
||||
device.taskItems.map((taskItem) => ({
|
||||
eventType: 'TASK_PRESSURE_ADJUSTMENT',
|
||||
occurredAt: taskItem.task.createdAt,
|
||||
hospital: patient.hospital,
|
||||
patient: {
|
||||
id: patient.id,
|
||||
name: patient.name,
|
||||
phone: patient.phone,
|
||||
},
|
||||
device: {
|
||||
id: device.id,
|
||||
snCode: device.snCode,
|
||||
status: device.status,
|
||||
currentPressure: device.currentPressure,
|
||||
},
|
||||
task: {
|
||||
id: taskItem.task.id,
|
||||
status: taskItem.task.status,
|
||||
creatorId: taskItem.task.creatorId,
|
||||
engineerId: taskItem.task.engineerId,
|
||||
hospitalId: taskItem.task.hospitalId,
|
||||
createdAt: taskItem.task.createdAt,
|
||||
},
|
||||
taskItem: {
|
||||
id: taskItem.id,
|
||||
oldPressure: taskItem.oldPressure,
|
||||
targetPressure: taskItem.targetPressure,
|
||||
},
|
||||
})),
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
phone,
|
||||
idCardHash,
|
||||
patientCount: patients.length,
|
||||
lifecycle,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveHospitalId(
|
||||
actor: ActorContext,
|
||||
requestedHospitalId?: number,
|
||||
): number {
|
||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||
const normalizedHospitalId = requestedHospitalId;
|
||||
if (
|
||||
normalizedHospitalId == null ||
|
||||
!Number.isInteger(normalizedHospitalId)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'SYSTEM_ADMIN must pass hospitalId query parameter',
|
||||
);
|
||||
}
|
||||
return normalizedHospitalId;
|
||||
}
|
||||
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException('Actor hospitalId is required');
|
||||
}
|
||||
|
||||
return actor.hospitalId;
|
||||
}
|
||||
}
|
||||
13
src/patients/patients.module.ts
Normal file
13
src/patients/patients.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PatientService } from './patient/patient.service.js';
|
||||
import { BPatientsController } from './b-patients/b-patients.controller.js';
|
||||
import { CPatientsController } from './c-patients/c-patients.controller.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
|
||||
@Module({
|
||||
providers: [PatientService, AccessTokenGuard, RolesGuard],
|
||||
controllers: [BPatientsController, CPatientsController],
|
||||
exports: [PatientService],
|
||||
})
|
||||
export class PatientsModule {}
|
||||
9
src/prisma.module.ts
Normal file
9
src/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
42
src/tasks/b-tasks/b-tasks.controller.ts
Normal file
42
src/tasks/b-tasks/b-tasks.controller.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||
import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { TaskService } from '../task.service.js';
|
||||
import { PublishTaskDto } from '../dto/publish-task.dto.js';
|
||||
import { AcceptTaskDto } from '../dto/accept-task.dto.js';
|
||||
import { CompleteTaskDto } from '../dto/complete-task.dto.js';
|
||||
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
|
||||
|
||||
@Controller('b/tasks')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BTasksController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
|
||||
@Post('publish')
|
||||
@Roles(Role.DOCTOR)
|
||||
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
||||
return this.taskService.publishTask(actor, dto);
|
||||
}
|
||||
|
||||
@Post('accept')
|
||||
@Roles(Role.ENGINEER)
|
||||
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
||||
return this.taskService.acceptTask(actor, dto);
|
||||
}
|
||||
|
||||
@Post('complete')
|
||||
@Roles(Role.ENGINEER)
|
||||
complete(@CurrentActor() actor: ActorContext, @Body() dto: CompleteTaskDto) {
|
||||
return this.taskService.completeTask(actor, dto);
|
||||
}
|
||||
|
||||
@Post('cancel')
|
||||
@Roles(Role.DOCTOR)
|
||||
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
||||
return this.taskService.cancelTask(actor, dto);
|
||||
}
|
||||
}
|
||||
3
src/tasks/dto/accept-task.dto.ts
Normal file
3
src/tasks/dto/accept-task.dto.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class AcceptTaskDto {
|
||||
taskId!: number;
|
||||
}
|
||||
3
src/tasks/dto/cancel-task.dto.ts
Normal file
3
src/tasks/dto/cancel-task.dto.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class CancelTaskDto {
|
||||
taskId!: number;
|
||||
}
|
||||
3
src/tasks/dto/complete-task.dto.ts
Normal file
3
src/tasks/dto/complete-task.dto.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class CompleteTaskDto {
|
||||
taskId!: number;
|
||||
}
|
||||
9
src/tasks/dto/publish-task.dto.ts
Normal file
9
src/tasks/dto/publish-task.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export class PublishTaskItemDto {
|
||||
deviceId!: number;
|
||||
targetPressure!: number;
|
||||
}
|
||||
|
||||
export class PublishTaskDto {
|
||||
engineerId?: number;
|
||||
items!: PublishTaskItemDto[];
|
||||
}
|
||||
263
src/tasks/task.service.ts
Normal file
263
src/tasks/task.service.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import { ActorContext } from '../common/actor-context.js';
|
||||
import { PublishTaskDto } from './dto/publish-task.dto.js';
|
||||
import { AcceptTaskDto } from './dto/accept-task.dto.js';
|
||||
import { CompleteTaskDto } from './dto/complete-task.dto.js';
|
||||
import { CancelTaskDto } from './dto/cancel-task.dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
||||
this.assertRole(actor, [Role.DOCTOR]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
||||
throw new BadRequestException('items is required');
|
||||
}
|
||||
|
||||
const deviceIds = Array.from(
|
||||
new Set(
|
||||
dto.items.map((item) => {
|
||||
if (!Number.isInteger(item.deviceId)) {
|
||||
throw new BadRequestException(`Invalid deviceId: ${item.deviceId}`);
|
||||
}
|
||||
if (!Number.isInteger(item.targetPressure)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid targetPressure: ${item.targetPressure}`,
|
||||
);
|
||||
}
|
||||
return item.deviceId;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const devices = await this.prisma.device.findMany({
|
||||
where: {
|
||||
id: { in: deviceIds },
|
||||
status: DeviceStatus.ACTIVE,
|
||||
patient: { hospitalId },
|
||||
},
|
||||
select: { id: true, currentPressure: true },
|
||||
});
|
||||
|
||||
if (devices.length !== deviceIds.length) {
|
||||
throw new NotFoundException('Some devices are not found in actor hospital');
|
||||
}
|
||||
|
||||
if (dto.engineerId != null) {
|
||||
const engineer = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: dto.engineerId,
|
||||
role: Role.ENGINEER,
|
||||
hospitalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!engineer) {
|
||||
throw new BadRequestException('engineerId must be a valid local engineer');
|
||||
}
|
||||
}
|
||||
|
||||
const pressureByDeviceId = new Map(
|
||||
devices.map((device) => [device.id, device.currentPressure] as const),
|
||||
);
|
||||
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
status: TaskStatus.PENDING,
|
||||
creatorId: actor.id,
|
||||
engineerId: dto.engineerId ?? null,
|
||||
hospitalId,
|
||||
items: {
|
||||
create: dto.items.map((item) => ({
|
||||
deviceId: item.deviceId,
|
||||
oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0,
|
||||
targetPressure: item.targetPressure,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
await this.eventEmitter.emitAsync('task.published', {
|
||||
taskId: task.id,
|
||||
hospitalId: task.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: task.status,
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
||||
this.assertRole(actor, [Role.ENGINEER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
const task = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
id: dto.taskId,
|
||||
hospitalId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
hospitalId: true,
|
||||
engineerId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found in actor hospital');
|
||||
}
|
||||
if (task.status !== TaskStatus.PENDING) {
|
||||
throw new ConflictException('Only pending task can be accepted');
|
||||
}
|
||||
if (task.engineerId != null && task.engineerId !== actor.id) {
|
||||
throw new ForbiddenException('Task already assigned to another engineer');
|
||||
}
|
||||
|
||||
const updatedTask = await this.prisma.task.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: TaskStatus.ACCEPTED,
|
||||
engineerId: actor.id,
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
await this.eventEmitter.emitAsync('task.accepted', {
|
||||
taskId: updatedTask.id,
|
||||
hospitalId: updatedTask.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: updatedTask.status,
|
||||
});
|
||||
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
|
||||
this.assertRole(actor, [Role.ENGINEER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
const task = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
id: dto.taskId,
|
||||
hospitalId,
|
||||
},
|
||||
include: {
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found in actor hospital');
|
||||
}
|
||||
if (task.status !== TaskStatus.ACCEPTED) {
|
||||
throw new ConflictException('Only accepted task can be completed');
|
||||
}
|
||||
if (task.engineerId !== actor.id) {
|
||||
throw new ForbiddenException('Only the assigned engineer can complete task');
|
||||
}
|
||||
|
||||
const completedTask = await this.prisma.$transaction(async (tx) => {
|
||||
const nextTask = await tx.task.update({
|
||||
where: { id: task.id },
|
||||
data: { status: TaskStatus.COMPLETED },
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
task.items.map((item) =>
|
||||
tx.device.update({
|
||||
where: { id: item.deviceId },
|
||||
data: { currentPressure: item.targetPressure },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return nextTask;
|
||||
});
|
||||
|
||||
await this.eventEmitter.emitAsync('task.completed', {
|
||||
taskId: completedTask.id,
|
||||
hospitalId: completedTask.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: completedTask.status,
|
||||
});
|
||||
|
||||
return completedTask;
|
||||
}
|
||||
|
||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||
this.assertRole(actor, [Role.DOCTOR]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
const task = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
id: dto.taskId,
|
||||
hospitalId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
creatorId: true,
|
||||
hospitalId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found in actor hospital');
|
||||
}
|
||||
if (task.creatorId !== actor.id) {
|
||||
throw new ForbiddenException('Only task creator can cancel task');
|
||||
}
|
||||
if (
|
||||
task.status !== TaskStatus.PENDING &&
|
||||
task.status !== TaskStatus.ACCEPTED
|
||||
) {
|
||||
throw new ConflictException('Only pending or accepted task can be cancelled');
|
||||
}
|
||||
|
||||
const cancelledTask = await this.prisma.task.update({
|
||||
where: { id: task.id },
|
||||
data: { status: TaskStatus.CANCELLED },
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
await this.eventEmitter.emitAsync('task.cancelled', {
|
||||
taskId: cancelledTask.id,
|
||||
hospitalId: cancelledTask.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: cancelledTask.status,
|
||||
});
|
||||
|
||||
return cancelledTask;
|
||||
}
|
||||
|
||||
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
|
||||
if (!allowedRoles.includes(actor.role)) {
|
||||
throw new ForbiddenException('Actor role is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
private requireHospitalId(actor: ActorContext): number {
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException('Actor hospitalId is required');
|
||||
}
|
||||
return actor.hospitalId;
|
||||
}
|
||||
}
|
||||
12
src/tasks/tasks.module.ts
Normal file
12
src/tasks/tasks.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BTasksController } from './b-tasks/b-tasks.controller.js';
|
||||
import { TaskService } from './task.service.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
|
||||
@Module({
|
||||
controllers: [BTasksController],
|
||||
providers: [TaskService, AccessTokenGuard, RolesGuard],
|
||||
exports: [TaskService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
25
src/users/b-users/b-users.controller.ts
Normal file
25
src/users/b-users/b-users.controller.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||
import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { UsersService } from '../users.service.js';
|
||||
import { AssignEngineerHospitalDto } from '../dto/assign-engineer-hospital.dto.js';
|
||||
|
||||
@Controller('b/users')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BUsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Patch(':id/assign-engineer-hospital')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
assignEngineerHospital(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AssignEngineerHospitalDto,
|
||||
) {
|
||||
return this.usersService.assignEngineerHospital(actor, Number(id), dto);
|
||||
}
|
||||
}
|
||||
3
src/users/dto/assign-engineer-hospital.dto.ts
Normal file
3
src/users/dto/assign-engineer-hospital.dto.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class AssignEngineerHospitalDto {
|
||||
hospitalId!: number;
|
||||
}
|
||||
@ -1 +1,12 @@
|
||||
export class CreateUserDto {}
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
|
||||
export class CreateUserDto {
|
||||
name!: string;
|
||||
phone!: string;
|
||||
password?: string;
|
||||
openId?: string;
|
||||
role!: Role;
|
||||
hospitalId?: number;
|
||||
departmentId?: number;
|
||||
groupId?: number;
|
||||
}
|
||||
|
||||
8
src/users/dto/login.dto.ts
Normal file
8
src/users/dto/login.dto.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
|
||||
export class LoginDto {
|
||||
phone!: string;
|
||||
password!: string;
|
||||
role!: Role;
|
||||
hospitalId?: number;
|
||||
}
|
||||
13
src/users/dto/register-user.dto.ts
Normal file
13
src/users/dto/register-user.dto.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
|
||||
export class RegisterUserDto {
|
||||
name!: string;
|
||||
phone!: string;
|
||||
password!: string;
|
||||
role!: Role;
|
||||
openId?: string;
|
||||
hospitalId?: number;
|
||||
departmentId?: number;
|
||||
groupId?: number;
|
||||
systemAdminBootstrapKey?: string;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import { CreateUserDto } from './create-user.dto.js';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
|
||||
@ -1,33 +1,52 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import {
|
||||
UseGuards,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
} from '@nestjs/common';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { Roles } from '../auth/roles.decorator.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
create(@Body() createUserDto: CreateUserDto) {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.usersService.findOne(+id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.usersService.update(+id, updateUserDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.usersService.remove(+id);
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service.js';
|
||||
import { UsersController } from './users.controller.js';
|
||||
import { BUsersController } from './b-users/b-users.controller.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
controllers: [UsersController, BUsersController],
|
||||
providers: [UsersService, AccessTokenGuard, RolesGuard],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@ -1,26 +1,537 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { compare, hash } from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import { ActorContext } from '../common/actor-context.js';
|
||||
import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js';
|
||||
import { RegisterUserDto } from './dto/register-user.dto.js';
|
||||
import { LoginDto } from './dto/login.dto.js';
|
||||
|
||||
const SAFE_USER_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
openId: true,
|
||||
role: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
groupId: true,
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
create(createUserDto: CreateUserDto) {
|
||||
return 'This action adds a new user';
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async register(dto: RegisterUserDto) {
|
||||
const role = this.normalizeRole(dto.role);
|
||||
const name = this.normalizeRequiredString(dto.name, 'name');
|
||||
const phone = this.normalizePhone(dto.phone);
|
||||
const password = this.normalizePassword(dto.password);
|
||||
const openId = this.normalizeOptionalString(dto.openId);
|
||||
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
|
||||
const departmentId = this.normalizeOptionalInt(
|
||||
dto.departmentId,
|
||||
'departmentId',
|
||||
);
|
||||
const groupId = this.normalizeOptionalInt(dto.groupId, 'groupId');
|
||||
|
||||
this.assertSystemAdminBootstrapKey(role, dto.systemAdminBootstrapKey);
|
||||
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
|
||||
await this.assertOpenIdUnique(openId);
|
||||
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
phone,
|
||||
passwordHash,
|
||||
openId,
|
||||
role,
|
||||
hospitalId,
|
||||
departmentId,
|
||||
groupId,
|
||||
},
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return `This action returns all users`;
|
||||
async login(dto: LoginDto) {
|
||||
const role = this.normalizeRole(dto.role);
|
||||
const phone = this.normalizePhone(dto.phone);
|
||||
const password = this.normalizePassword(dto.password);
|
||||
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: {
|
||||
phone,
|
||||
role,
|
||||
...(hospitalId != null ? { hospitalId } : {}),
|
||||
},
|
||||
select: {
|
||||
...SAFE_USER_SELECT,
|
||||
passwordHash: true,
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new UnauthorizedException('Invalid phone/role/password');
|
||||
}
|
||||
if (users.length > 1 && hospitalId == null) {
|
||||
throw new BadRequestException(
|
||||
'Multiple accounts found. Please specify hospitalId',
|
||||
);
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
if (!user?.passwordHash) {
|
||||
throw new UnauthorizedException('Password login is not enabled');
|
||||
}
|
||||
|
||||
const matched = await compare(password, user.passwordHash);
|
||||
if (!matched) {
|
||||
throw new UnauthorizedException('Invalid phone/role/password');
|
||||
}
|
||||
|
||||
const actor: ActorContext = {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
hospitalId: user.hospitalId,
|
||||
departmentId: user.departmentId,
|
||||
groupId: user.groupId,
|
||||
};
|
||||
|
||||
return {
|
||||
tokenType: 'Bearer',
|
||||
accessToken: this.signAccessToken(actor),
|
||||
actor,
|
||||
user: this.toSafeUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
findOne(id: number) {
|
||||
return `This action returns a #${id} user`;
|
||||
async me(actor: ActorContext) {
|
||||
return this.findOne(actor.id);
|
||||
}
|
||||
|
||||
update(id: number, updateUserDto: UpdateUserDto) {
|
||||
return `This action updates a #${id} user`;
|
||||
async create(createUserDto: CreateUserDto) {
|
||||
const role = this.normalizeRole(createUserDto.role);
|
||||
const name = this.normalizeRequiredString(createUserDto.name, 'name');
|
||||
const phone = this.normalizePhone(createUserDto.phone);
|
||||
const password = createUserDto.password
|
||||
? this.normalizePassword(createUserDto.password)
|
||||
: null;
|
||||
const openId = this.normalizeOptionalString(createUserDto.openId);
|
||||
const hospitalId = this.normalizeOptionalInt(
|
||||
createUserDto.hospitalId,
|
||||
'hospitalId',
|
||||
);
|
||||
const departmentId = this.normalizeOptionalInt(
|
||||
createUserDto.departmentId,
|
||||
'departmentId',
|
||||
);
|
||||
const groupId = this.normalizeOptionalInt(createUserDto.groupId, 'groupId');
|
||||
|
||||
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
|
||||
await this.assertOpenIdUnique(openId);
|
||||
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
phone,
|
||||
passwordHash: password ? await hash(password, 12) : null,
|
||||
openId,
|
||||
role,
|
||||
hospitalId,
|
||||
departmentId,
|
||||
groupId,
|
||||
},
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: number) {
|
||||
return `This action removes a #${id} user`;
|
||||
async findAll() {
|
||||
return this.prisma.user.findMany({
|
||||
select: SAFE_USER_SELECT,
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(id: number, updateUserDto: UpdateUserDto) {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
const current = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
...SAFE_USER_SELECT,
|
||||
passwordHash: true,
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const nextRole =
|
||||
updateUserDto.role != null ? this.normalizeRole(updateUserDto.role) : current.role;
|
||||
const nextHospitalId =
|
||||
updateUserDto.hospitalId !== undefined
|
||||
? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId')
|
||||
: current.hospitalId;
|
||||
const nextDepartmentId =
|
||||
updateUserDto.departmentId !== undefined
|
||||
? this.normalizeOptionalInt(updateUserDto.departmentId, 'departmentId')
|
||||
: current.departmentId;
|
||||
const nextGroupId =
|
||||
updateUserDto.groupId !== undefined
|
||||
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
|
||||
: current.groupId;
|
||||
|
||||
await this.assertOrganizationScope(
|
||||
nextRole,
|
||||
nextHospitalId,
|
||||
nextDepartmentId,
|
||||
nextGroupId,
|
||||
);
|
||||
|
||||
const nextOpenId =
|
||||
updateUserDto.openId !== undefined
|
||||
? this.normalizeOptionalString(updateUserDto.openId)
|
||||
: current.openId;
|
||||
await this.assertOpenIdUnique(nextOpenId, userId);
|
||||
const nextPhone =
|
||||
updateUserDto.phone !== undefined
|
||||
? this.normalizePhone(updateUserDto.phone)
|
||||
: current.phone;
|
||||
await this.assertPhoneRoleScopeUnique(
|
||||
nextPhone,
|
||||
nextRole,
|
||||
nextHospitalId,
|
||||
userId,
|
||||
);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (updateUserDto.name !== undefined) {
|
||||
data.name = this.normalizeRequiredString(updateUserDto.name, 'name');
|
||||
}
|
||||
if (updateUserDto.phone !== undefined) {
|
||||
data.phone = nextPhone;
|
||||
}
|
||||
if (updateUserDto.role !== undefined) {
|
||||
data.role = nextRole;
|
||||
}
|
||||
if (updateUserDto.hospitalId !== undefined) {
|
||||
data.hospitalId = nextHospitalId;
|
||||
}
|
||||
if (updateUserDto.departmentId !== undefined) {
|
||||
data.departmentId = nextDepartmentId;
|
||||
}
|
||||
if (updateUserDto.groupId !== undefined) {
|
||||
data.groupId = nextGroupId;
|
||||
}
|
||||
if (updateUserDto.openId !== undefined) {
|
||||
data.openId = nextOpenId;
|
||||
}
|
||||
if (updateUserDto.password) {
|
||||
data.passwordHash = await hash(
|
||||
this.normalizePassword(updateUserDto.password),
|
||||
12,
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
await this.findOne(userId);
|
||||
|
||||
return this.prisma.user.delete({
|
||||
where: { id: userId },
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
async assignEngineerHospital(
|
||||
actor: ActorContext,
|
||||
targetUserId: number,
|
||||
dto: AssignEngineerHospitalDto,
|
||||
) {
|
||||
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||
throw new ForbiddenException(
|
||||
'Only SYSTEM_ADMIN can bind engineer to hospital',
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(dto.hospitalId)) {
|
||||
throw new BadRequestException('hospitalId must be an integer');
|
||||
}
|
||||
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
where: { id: dto.hospitalId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!hospital) {
|
||||
throw new NotFoundException('Hospital not found');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
if (user.role !== Role.ENGINEER) {
|
||||
throw new BadRequestException('Target user is not ENGINEER');
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: targetUserId },
|
||||
data: {
|
||||
hospitalId: dto.hospitalId,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
},
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
|
||||
const { passwordHash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
|
||||
private assertSystemAdminBootstrapKey(
|
||||
role: Role,
|
||||
providedBootstrapKey?: string,
|
||||
) {
|
||||
if (role !== Role.SYSTEM_ADMIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedBootstrapKey = process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY;
|
||||
if (!expectedBootstrapKey) {
|
||||
throw new ForbiddenException('SYSTEM_ADMIN registration is disabled');
|
||||
}
|
||||
if (providedBootstrapKey !== expectedBootstrapKey) {
|
||||
throw new ForbiddenException('Invalid system admin bootstrap key');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPhoneRoleScopeUnique(
|
||||
phone: string,
|
||||
role: Role,
|
||||
hospitalId: number | null,
|
||||
selfId?: number,
|
||||
) {
|
||||
const exists = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
phone,
|
||||
role,
|
||||
hospitalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists && exists.id !== selfId) {
|
||||
throw new ConflictException(
|
||||
'User with same phone/role/hospital already exists',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
|
||||
if (!openId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = await this.prisma.user.findUnique({
|
||||
where: { openId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists && exists.id !== selfId) {
|
||||
throw new ConflictException('openId already registered');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertOrganizationScope(
|
||||
role: Role,
|
||||
hospitalId: number | null,
|
||||
departmentId: number | null,
|
||||
groupId: number | null,
|
||||
) {
|
||||
if (role === Role.SYSTEM_ADMIN) {
|
||||
if (hospitalId || departmentId || groupId) {
|
||||
throw new BadRequestException(
|
||||
'SYSTEM_ADMIN must not bind hospital/department/group',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hospitalId) {
|
||||
throw new BadRequestException('hospitalId is required');
|
||||
}
|
||||
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
where: { id: hospitalId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!hospital) {
|
||||
throw new BadRequestException('hospitalId does not exist');
|
||||
}
|
||||
|
||||
const needsDepartment =
|
||||
role === Role.DIRECTOR || role === Role.LEADER || role === Role.DOCTOR;
|
||||
if (needsDepartment && !departmentId) {
|
||||
throw new BadRequestException('departmentId is required for role');
|
||||
}
|
||||
|
||||
const needsGroup = role === Role.LEADER || role === Role.DOCTOR;
|
||||
if (needsGroup && !groupId) {
|
||||
throw new BadRequestException('groupId is required for role');
|
||||
}
|
||||
|
||||
if (role === Role.ENGINEER && (departmentId || groupId)) {
|
||||
throw new BadRequestException(
|
||||
'ENGINEER should not bind departmentId/groupId',
|
||||
);
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
const department = await this.prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: { id: true, hospitalId: true },
|
||||
});
|
||||
if (!department || department.hospitalId !== hospitalId) {
|
||||
throw new BadRequestException(
|
||||
'departmentId does not belong to hospitalId',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
if (!departmentId) {
|
||||
throw new BadRequestException('groupId requires departmentId');
|
||||
}
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true, departmentId: true },
|
||||
});
|
||||
if (!group || group.departmentId !== departmentId) {
|
||||
throw new BadRequestException(
|
||||
'groupId does not belong to departmentId',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeRequiredInt(value: unknown, fieldName: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new BadRequestException(`${fieldName} must be an integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private normalizeOptionalInt(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
): number | null {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
return this.normalizeRequiredInt(value, fieldName);
|
||||
}
|
||||
|
||||
private normalizeRequiredString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} must be a string`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(`${fieldName} is required`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private normalizeOptionalString(value: unknown): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException('openId must be a string');
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
private normalizePhone(phone: unknown): string {
|
||||
const normalized = this.normalizeRequiredString(phone, 'phone');
|
||||
if (!/^1\d{10}$/.test(normalized)) {
|
||||
throw new BadRequestException('phone must be a valid CN mobile number');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizePassword(password: unknown): string {
|
||||
const normalized = this.normalizeRequiredString(password, 'password');
|
||||
if (normalized.length < 8) {
|
||||
throw new BadRequestException('password must be at least 8 characters');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeRole(role: unknown): Role {
|
||||
if (typeof role !== 'string') {
|
||||
throw new BadRequestException('role must be a string enum');
|
||||
}
|
||||
|
||||
if (!Object.values(Role).includes(role as Role)) {
|
||||
throw new BadRequestException(`invalid role: ${role}`);
|
||||
}
|
||||
|
||||
return role as Role;
|
||||
}
|
||||
|
||||
private signAccessToken(actor: ActorContext): string {
|
||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException('AUTH_TOKEN_SECRET is not configured');
|
||||
}
|
||||
|
||||
return jwt.sign(actor, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '7d',
|
||||
issuer: 'tyt-api-nest',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user