This commit is contained in:
EL 2026-03-13 00:19:34 +08:00
parent ff6739ab68
commit aa1346f6af
37 changed files with 1900 additions and 43 deletions

3
.env.example Normal file
View 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"

View File

@ -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
View File

@ -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:

View File

@ -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
View 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();
});

View File

@ -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 {}

View 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;
}
}

View 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
View 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
View 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);
}
}

View 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;
},
);

View 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
View 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;
}
}

View 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;
};

View File

@ -1,3 +1,4 @@
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

View 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);
}
}

View 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,
);
}
}

View File

@ -0,0 +1,4 @@
export class FamilyLifecycleQueryDto {
phone!: string;
idCardHash!: string;
}

View 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;
}
}

View 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
View 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 {}

View 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);
}
}

View File

@ -0,0 +1,3 @@
export class AcceptTaskDto {
taskId!: number;
}

View File

@ -0,0 +1,3 @@
export class CancelTaskDto {
taskId!: number;
}

View File

@ -0,0 +1,3 @@
export class CompleteTaskDto {
taskId!: number;
}

View 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
View 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
View 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 {}

View 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);
}
}

View File

@ -0,0 +1,3 @@
export class AssignEngineerHospitalDto {
hospitalId!: number;
}

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
import { Role } from '../../generated/prisma/enums.js';
export class LoginDto {
phone!: string;
password!: string;
role!: Role;
hospitalId?: number;
}

View 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;
}

View File

@ -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) {}

View File

@ -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);
}

View File

@ -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 {}

View File

@ -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',
);
}
findOne(id: number) {
return `This action returns a #${id} user`;
const user = users[0];
if (!user?.passwordHash) {
throw new UnauthorizedException('Password login is not enabled');
}
update(id: number, updateUserDto: UpdateUserDto) {
return `This action updates a #${id} user`;
const matched = await compare(password, user.passwordHash);
if (!matched) {
throw new UnauthorizedException('Invalid phone/role/password');
}
remove(id: number) {
return `This action removes a #${id} user`;
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),
};
}
async me(actor: ActorContext) {
return this.findOne(actor.id);
}
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,
});
}
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',
});
}
}