diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c5bea09 --- /dev/null +++ b/.env.example @@ -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" diff --git a/package.json b/package.json index 69d69a3..906eae9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33d4632..1fff09d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 178b772..0e85b5c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/prisma/seed.mjs b/prisma/seed.mjs new file mode 100644 index 0000000..60b26c9 --- /dev/null +++ b/prisma/seed.mjs @@ -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(); + }); diff --git a/src/app.module.ts b/src/app.module.ts index 867c4b1..e8810f8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/auth/access-token.guard.ts b/src/auth/access-token.guard.ts new file mode 100644 index 0000000..991e29f --- /dev/null +++ b/src/auth/access-token.guard.ts @@ -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; + 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; + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..da40fad --- /dev/null +++ b/src/auth/auth.controller.ts @@ -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); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..98ccd58 --- /dev/null +++ b/src/auth/auth.module.ts @@ -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 {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..bb866f1 --- /dev/null +++ b/src/auth/auth.service.ts @@ -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); + } +} diff --git a/src/auth/current-actor.decorator.ts b/src/auth/current-actor.decorator.ts new file mode 100644 index 0000000..b6cec52 --- /dev/null +++ b/src/auth/current-actor.decorator.ts @@ -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; + }, +); diff --git a/src/auth/roles.decorator.ts b/src/auth/roles.decorator.ts new file mode 100644 index 0000000..7daf938 --- /dev/null +++ b/src/auth/roles.decorator.ts @@ -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); diff --git a/src/auth/roles.guard.ts b/src/auth/roles.guard.ts new file mode 100644 index 0000000..1226d21 --- /dev/null +++ b/src/auth/roles.guard.ts @@ -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(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; + } +} diff --git a/src/common/actor-context.ts b/src/common/actor-context.ts new file mode 100644 index 0000000..d59f3e3 --- /dev/null +++ b/src/common/actor-context.ts @@ -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; +}; diff --git a/src/main.ts b/src/main.ts index c74cd8d..79e74b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module.js'; diff --git a/src/patients/b-patients/b-patients.controller.ts b/src/patients/b-patients/b-patients.controller.ts new file mode 100644 index 0000000..95c9c37 --- /dev/null +++ b/src/patients/b-patients/b-patients.controller.ts @@ -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); + } +} diff --git a/src/patients/c-patients/c-patients.controller.ts b/src/patients/c-patients/c-patients.controller.ts new file mode 100644 index 0000000..a1e1ffc --- /dev/null +++ b/src/patients/c-patients/c-patients.controller.ts @@ -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, + ); + } +} diff --git a/src/patients/dto/family-lifecycle-query.dto.ts b/src/patients/dto/family-lifecycle-query.dto.ts new file mode 100644 index 0000000..6b47cab --- /dev/null +++ b/src/patients/dto/family-lifecycle-query.dto.ts @@ -0,0 +1,4 @@ +export class FamilyLifecycleQueryDto { + phone!: string; + idCardHash!: string; +} diff --git a/src/patients/patient/patient.service.ts b/src/patients/patient/patient.service.ts new file mode 100644 index 0000000..b57b350 --- /dev/null +++ b/src/patients/patient/patient.service.ts @@ -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 = { 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; + } +} diff --git a/src/patients/patients.module.ts b/src/patients/patients.module.ts new file mode 100644 index 0000000..b6131f6 --- /dev/null +++ b/src/patients/patients.module.ts @@ -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 {} diff --git a/src/prisma.module.ts b/src/prisma.module.ts new file mode 100644 index 0000000..efb7a8d --- /dev/null +++ b/src/prisma.module.ts @@ -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 {} diff --git a/src/tasks/b-tasks/b-tasks.controller.ts b/src/tasks/b-tasks/b-tasks.controller.ts new file mode 100644 index 0000000..b1b47ba --- /dev/null +++ b/src/tasks/b-tasks/b-tasks.controller.ts @@ -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); + } +} diff --git a/src/tasks/dto/accept-task.dto.ts b/src/tasks/dto/accept-task.dto.ts new file mode 100644 index 0000000..c07fc8a --- /dev/null +++ b/src/tasks/dto/accept-task.dto.ts @@ -0,0 +1,3 @@ +export class AcceptTaskDto { + taskId!: number; +} diff --git a/src/tasks/dto/cancel-task.dto.ts b/src/tasks/dto/cancel-task.dto.ts new file mode 100644 index 0000000..5c157ef --- /dev/null +++ b/src/tasks/dto/cancel-task.dto.ts @@ -0,0 +1,3 @@ +export class CancelTaskDto { + taskId!: number; +} diff --git a/src/tasks/dto/complete-task.dto.ts b/src/tasks/dto/complete-task.dto.ts new file mode 100644 index 0000000..3447620 --- /dev/null +++ b/src/tasks/dto/complete-task.dto.ts @@ -0,0 +1,3 @@ +export class CompleteTaskDto { + taskId!: number; +} diff --git a/src/tasks/dto/publish-task.dto.ts b/src/tasks/dto/publish-task.dto.ts new file mode 100644 index 0000000..874d516 --- /dev/null +++ b/src/tasks/dto/publish-task.dto.ts @@ -0,0 +1,9 @@ +export class PublishTaskItemDto { + deviceId!: number; + targetPressure!: number; +} + +export class PublishTaskDto { + engineerId?: number; + items!: PublishTaskItemDto[]; +} diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts new file mode 100644 index 0000000..efac764 --- /dev/null +++ b/src/tasks/task.service.ts @@ -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; + } +} diff --git a/src/tasks/tasks.module.ts b/src/tasks/tasks.module.ts new file mode 100644 index 0000000..b00f94b --- /dev/null +++ b/src/tasks/tasks.module.ts @@ -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 {} diff --git a/src/users/b-users/b-users.controller.ts b/src/users/b-users/b-users.controller.ts new file mode 100644 index 0000000..fa95b56 --- /dev/null +++ b/src/users/b-users/b-users.controller.ts @@ -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); + } +} diff --git a/src/users/dto/assign-engineer-hospital.dto.ts b/src/users/dto/assign-engineer-hospital.dto.ts new file mode 100644 index 0000000..cb0cb31 --- /dev/null +++ b/src/users/dto/assign-engineer-hospital.dto.ts @@ -0,0 +1,3 @@ +export class AssignEngineerHospitalDto { + hospitalId!: number; +} diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 0311be1..632ff31 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -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; +} diff --git a/src/users/dto/login.dto.ts b/src/users/dto/login.dto.ts new file mode 100644 index 0000000..344d049 --- /dev/null +++ b/src/users/dto/login.dto.ts @@ -0,0 +1,8 @@ +import { Role } from '../../generated/prisma/enums.js'; + +export class LoginDto { + phone!: string; + password!: string; + role!: Role; + hospitalId?: number; +} diff --git a/src/users/dto/register-user.dto.ts b/src/users/dto/register-user.dto.ts new file mode 100644 index 0000000..acdc588 --- /dev/null +++ b/src/users/dto/register-user.dto.ts @@ -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; +} diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index dfd37fb..912cdc5 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -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) {} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 3eca7eb..c4c9c50 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -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); } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index ecca17a..3c673de 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -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 {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 0a55903..089113b 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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 = {}; + 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) { + 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', + }); } }