diff --git a/docs/devices.md b/docs/devices.md index 53492c3..d96385c 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -13,20 +13,20 @@ 核心字段: -- `snCode`:设备唯一标识 - `patientId`:归属患者 - `surgeryId`:归属手术,可为空 - `implantCatalogId`:型号字典 ID,可为空 - `implantModel` / `implantManufacturer` / `implantName`:历史快照 - `isPressureAdjustable`:是否可调压 - `isAbandoned`:是否弃用 -- `currentPressure`:当前压力 +- `currentPressure`:当前压力挡位标签 - `status`:设备状态 补充: - `currentPressure` 不允许在创建/编辑设备实例时手工指定。 -- 新植入设备默认以 `initialPressure`(或系统默认值)作为当前压力起点,后续只允许在调压任务完成时更新。 +- 新植入设备默认以 `initialPressure`(或系统默认值 `0`)作为当前压力起点,后续只允许在调压任务完成时更新。 +- 发布调压任务时不会立刻修改 `currentPressure`,只有任务完成后才会把目标挡位回写到设备。 ## 3. 植入物目录 @@ -35,7 +35,7 @@ - `modelCode`:型号编码,唯一 - `manufacturer`:厂商 - `name`:名称 -- `pressureLevels`:可调压器械的挡位列表 +- `pressureLevels`:可调压器械的挡位字符串标签列表 - `isPressureAdjustable`:是否可调压 - `notes`:目录备注 @@ -45,6 +45,11 @@ - 仅 `SYSTEM_ADMIN` 可做目录 CRUD - 目录是全局共享的,不按医院隔离 +说明: + +- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]` 或 `["10", "20", "30"]`。 +- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`。 + ## 4. 接口 设备实例: @@ -65,7 +70,6 @@ ## 5. 约束 - 设备必须绑定到一个患者。 -- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。 - 删除已被任务明细引用的设备会返回 `409`。 - 删除已被患者手术引用的植入物目录会返回 `409`。 - 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。 diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index bb17bd2..c6dfc69 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -4,17 +4,17 @@ - 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。 - 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。 -- 测试前固定执行数据库重置与 seed,确保结果可重复。 +- 测试前固定执行数据库重置,并通过真实接口全流程建数,确保结果可重复。 ## 2. 风险提示 `pnpm test:e2e` 会执行: 1. `prisma migrate reset --force` -2. `node prisma/seed.mjs` +2. 启动 Jest 后,由测试用例通过真实 HTTP 接口完成基础夹具创建 这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。 -另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。 +另外,接口引导创建的测试账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。 ## 3. 运行命令 @@ -22,7 +22,7 @@ pnpm test:e2e ``` -仅重置数据库并注入 seed: +仅重置数据库并重新生成 Prisma Client: ```bash pnpm test:e2e:prepare @@ -34,7 +34,7 @@ pnpm test:e2e:prepare pnpm test:e2e:watch ``` -## 4. 种子账号(默认密码:`Seed@1234`) +## 4. 接口引导夹具(默认密码:`Seed@1234`) - 系统管理员:`13800001000` - 院管(医院 A):`13800001001` @@ -42,20 +42,37 @@ pnpm test:e2e:watch - 组长(医院 A):`13800001003` - 医生(医院 A):`13800001004` - 工程师(医院 A):`13800001005` +- 院管(医院 B):`13800001011` +- 工程师(医院 B):`13800001015` + +说明: + +- 这些账号不再由 `prisma/seed.mjs` 直写生成。 +- 每次执行 E2E 时,会先创建系统管理员,再通过后台接口依次创建医院、院管、医生、工程师、目录、患者、手术和调压任务。 +- 因为夹具是通过真实业务接口生成的,所以权限、作用域、删除保护和调压链路都能在同一套测试里被覆盖。 ## 5. 用例结构 - `test/e2e/specs/auth.e2e-spec.ts` - `test/e2e/specs/users.e2e-spec.ts` - `test/e2e/specs/organization.e2e-spec.ts` +- `test/e2e/specs/dictionaries.e2e-spec.ts` +- `test/e2e/specs/devices.e2e-spec.ts` - `test/e2e/specs/tasks.e2e-spec.ts` - `test/e2e/specs/patients.e2e-spec.ts` +- `test/e2e/specs/auth-token-revocation.e2e-spec.ts` ## 6. 覆盖策略 - 受保护接口(27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。 - 非受保护接口(3 个):每个接口至少 1 个成功 + 1 个失败。 - 关键行为额外覆盖: + - 从创建系统管理员开始的完整接口建数链路 - 任务状态机冲突(409) + - 调压任务发布后不改当前压力,完成任务后才回写设备当前压力 + - 主刀医生自动跟随患者归属医生,且历史手术保留快照 - 患者 B 端角色可见性 + - 患者创建人返回与展示 + - 跨院工程师隔离 - 组织域院管作用域限制与删除冲突 + - 目录、设备、组织、用户的删除保护 diff --git a/docs/frontend-api-integration.md b/docs/frontend-api-integration.md index 87389a0..445111b 100644 --- a/docs/frontend-api-integration.md +++ b/docs/frontend-api-integration.md @@ -5,10 +5,11 @@ - 登录页:`/auth/login`,支持可选 `hospitalId`。 - 首页看板:按角色拉取组织与患者统计。 - 设备页:新增管理员专用设备 CRUD,复用真实设备接口。 -- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。 +- 任务页:改为只读调压记录页,接入真实任务列表接口。 - 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。 - 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`), - 后端直接保存身份证号原文,不再做哈希转换。 + 后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。 +- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。 ## 2. 接口契约对齐点 @@ -16,23 +17,27 @@ - `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。 - `GET /b/patients` 返回数组,前端已改为本地分页与筛选。 - `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`。 +- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。 +- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。 +- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。 +- `GET /b/tasks/engineers` 返回可选接收工程师列表,患者页发布调压任务前需要先拉取。 - `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。 - 患者表单中的 `idCard` 字段直接传身份证号; 服务端只会做去空格与 `x/X` 标准化,不会转哈希。 -- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。 +- 患者手术、调压任务、设备目录中的压力值全部按字符串挡位标签传输,例如 `0.5`、`1`、`1.5`、`10`。 ## 3. 角色权限提示 - 任务接口权限: - - `DOCTOR/DIRECTOR/LEADER`:发布、取消(仅可取消自己创建的任务) - - `ENGINEER`:接收、完成 + - `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务 + - `ENGINEER`:仅可完成分配给自己的任务 - 患者列表权限: - `SYSTEM_ADMIN` 查询时必须传 `hospitalId` - 用户管理接口: - - `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建 - - `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生 + - `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除 + - `DIRECTOR` 可只读查看本科室下级医生/组长 - 工程师绑定医院仅 `SYSTEM_ADMIN` - - 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生 + - 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户 ## 3.1 结构图页面交互调整 @@ -46,27 +51,29 @@ `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问 - `organization/departments`: 仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 -- `users`:`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR` 可访问 +- `users`:`SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可管理;`DIRECTOR` 可只读查看 - `devices`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 +- `uploads`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 - `organization/hospitals` - 仅 `SYSTEM_ADMIN` 可访问 - `tasks` - - 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问 + - `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问 - `patients` - `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问 +患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口。 + +患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。 + 前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。 ## 3.3 主任/组长组织管理范围 - `DIRECTOR` - - 可查看组织架构、小组列表(限定本科室范围) - - 可创建/编辑/删除本科室下小组 - - 可进入“医生管理”页,创建/维护本科室医生 + - 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理 - `LEADER` - - 可查看组织架构、小组列表(限定本科室/本小组范围) - - 可编辑本小组名称 -- 主任/组长不再显示独立“科室管理”页面。 + - 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理 +- 主任/组长不再显示“科室管理”“小组管理”“用户管理”页面。 - 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。 ## 4. 本地运行 diff --git a/docs/patients.md b/docs/patients.md index 311e931..e6e632f 100644 --- a/docs/patients.md +++ b/docs/patients.md @@ -29,7 +29,8 @@ - `surgeryDate`:手术日期 - `surgeryName`:手术名称 -- `surgeonName`:主刀医生 +- `surgeonId`:主刀医生账号 ID(自动等于患者归属医生) +- `surgeonName`:主刀医生姓名快照 - `preOpPressure`:术前测压,可为空 - `primaryDisease`:原发病 - `hydrocephalusTypes`:脑积水类型,多选 @@ -43,6 +44,12 @@ - `activeDeviceCount`:本次手术仍在用设备数 - `abandonedDeviceCount`:本次手术已弃用设备数 +说明: + +- 新增/修改手术时,前端不再单独提交主刀医生。 +- 后端会直接使用患者当前的 `doctorId` 作为 `surgeonId`,并保存当时的 `surgeonName` 快照。 +- 如果后续患者归属医生发生变化,只会影响后续新建手术;历史手术仍保留创建当时的主刀医生快照。 + ## 4. 植入设备 设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。 @@ -57,7 +64,7 @@ - `proximalPunctureAreas`:近端穿刺区域,最多 2 个 - `valvePlacementSites`:阀门植入部位,最多 2 个 - `distalShuntDirection`:远端分流方向 -- `initialPressure`:初始压力,可为空 +- `initialPressure`:初始压力挡位标签,可为空 - `implantNotes`:植入物备注,可为空 - `labelImageUrl`:植入物标签图片地址,可为空 @@ -68,8 +75,10 @@ - 旧设备弃用后,`TaskItem` 历史不会删除。 - 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。 - 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。 -- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的值。 -- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`,后续仅能由调压任务完成后更新。 +- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`。 +- 挡位标签保存前会自动标准化,例如 `01.0 -> 1`、`1.50 -> 1.5`。 +- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。 +- 术前资料与植入物标签现在支持直接上传;上传成功后,患者详情会按图片/视频做预览,不再只是裸链接。 ## 5. B 端可见性 @@ -93,6 +102,7 @@ - `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。 - 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`。 +- 患者建档时会自动记录 `creator`,列表和详情都会返回创建人信息。 ## 7. C 端生命周期聚合 @@ -111,6 +121,10 @@ - `SURGERY` - `TASK_PRESSURE_ADJUSTMENT` +说明: + +- 生命周期中的 `initialPressure / currentPressure / oldPressure / targetPressure` 均返回字符串挡位标签。 + ## 8. 响应结构 全部接口统一返回: diff --git a/docs/tasks.md b/docs/tasks.md index 4f51bb9..109607e 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -7,34 +7,47 @@ ## 2. 状态机 -- `PENDING -> ACCEPTED -> COMPLETED` -- `PENDING/ACCEPTED -> CANCELLED` +- 当前发布流程:`ACCEPTED -> COMPLETED` +- 当前取消流程:`ACCEPTED -> CANCELLED` +- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录 非法流转会返回 `409` 冲突错误(中文消息)。 ## 3. 角色权限 -- 医生/主任/组长:发布任务、取消自己创建的任务 -- 工程师:接收任务、完成自己接收的任务 +- 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务 +- 工程师:不能再执行“接收”动作,只能完成指派给自己的任务 - 其他角色:默认拒绝 补充: +- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。 +- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。 - `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。 - 当前取消原因仅透传到事件层,数据库暂未持久化该字段。 -## 4. 事件触发 +## 4. 记录列表 + +- 后台任务页不再承担手工发布入口,只展示调压记录。 +- 记录维度按 `TaskItem` 展开,每条记录会携带: + - 任务状态 + - 患者信息 + - 手术名称 + - 设备信息 + - 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签) + - 创建人 / 接收人 / 发布时间 + +## 5. 事件触发 状态变化后会发出事件: - `task.published` -- `task.accepted` - `task.completed` - `task.cancelled` 用于后续接入微信通知或消息中心。 -## 5. 完成任务时的设备同步 +## 6. 完成任务时的设备同步 `completeTask` 在单事务中执行: @@ -43,3 +56,8 @@ 3. 批量更新关联 `Device.currentPressure` 确保任务状态与设备压力一致性。 + +补充: + +- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。 +- 只有工程师完成任务后,目标挡位才会回写到设备实例。 diff --git a/docs/uploads.md b/docs/uploads.md new file mode 100644 index 0000000..ca9d9ff --- /dev/null +++ b/docs/uploads.md @@ -0,0 +1,71 @@ +# 上传资产模块说明(`src/uploads`) + +## 1. 目标 + +- 提供图片、视频、文件的统一上传入口。 +- 为 B 端“影像库/视频库/文件库”页面提供分页查询。 +- 为患者手术表单中的术前资料、植入物标签上传提供复用能力。 + +## 2. 数据模型 + +新增 `UploadAsset` 表,保存上传文件元数据: + +- `hospitalId`:医院归属 +- `creatorId`:上传人 +- `type`:`IMAGE / VIDEO / FILE` +- `originalName`:原始文件名 +- `fileName`:服务端生成文件名 +- `storagePath`:相对存储路径 +- `url`:公开访问地址,前端直接用于预览 +- `mimeType`:文件 MIME 类型 +- `fileSize`:文件大小(字节) + +文件本体默认落盘到: + +- 公开目录:`storage/uploads` +- 临时目录:`storage/tmp-uploads` +- 最终目录规则:`storage/uploads/YYYY/MM/DD` +- 最终文件名规则:`YYYYMMDDHHmmss-原文件名` + - 图片压缩后扩展名统一为 `.webp` + - 视频压缩后扩展名统一为 `.mp4` + - 如同一秒内出现同名文件,会自动追加 `-1`、`-2` 防止覆盖 + +## 3. 接口 + +- `POST /b/uploads` + - 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR` + - 表单字段: + - `file`:二进制文件 + - `hospitalId`:仅 `SYSTEM_ADMIN` 上传时必填 +- `GET /b/uploads` + - 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN` + - 查询参数: + - `keyword` + - `type` + - `hospitalId`:仅 `SYSTEM_ADMIN` 可选 + - `page` + - `pageSize` + +## 4. 使用说明 + +- 患者手术表单中的“术前 CT 影像/资料”支持直接上传,上传成功后自动回填 `type/name/url`。 +- 设备表单中的“植入物标签”支持直接上传图片,上传成功后自动回填 `labelImageUrl`。 +- 患者详情页会直接预览术前图片、视频和设备标签。 +- 单独新增“影像库”页面,按图片/视频/文件分页查看所有上传资产。 + - 页面访问权限仅 `SYSTEM_ADMIN / HOSPITAL_ADMIN` + +## 5. 压缩策略 + +- 图片上传后会自动压缩并统一转成 `webp`: + - 自动纠正旋转方向 + - 最大边限制为 `2560` + - 返回的 `mimeType` 为 `image/webp` +- 视频上传后会自动压缩并统一转成 `mp4`: + - 最大边限制为 `1280` + - 视频编码为 `H.264` + - 音频编码为 `AAC` + - 返回的 `mimeType` 为 `video/mp4` +- 普通文件类型不做转码,按原文件保存。 +- 如果本地 `pnpm install` 屏蔽了依赖安装脚本,`ffmpeg-static` 二进制不会自动落盘,视频压缩会失败。 + - 这种情况下手动执行: + - `node node_modules/.pnpm/ffmpeg-static@5.3.0/node_modules/ffmpeg-static/install.js` diff --git a/docs/users.md b/docs/users.md index 83b17cc..6ccd0f0 100644 --- a/docs/users.md +++ b/docs/users.md @@ -18,8 +18,10 @@ - 医院内数据按 `hospitalId` 强隔离。 - 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。 -- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。 -- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。 +- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可执行用户创建、编辑、删除。 +- `DIRECTOR` 仅可只读查看本科室下级医生/组长。 +- `LEADER` 仅可只读查看本小组医生列表。 +- `HOSPITAL_ADMIN` 仅可操作本院非管理员账号。 - 用户组织字段校验: - 院管/医生/工程师等需有医院归属; - 主任/组长需有科室/小组等必要归属; @@ -32,12 +34,22 @@ - `GET /users`、`GET /users/:id`、`PATCH /users/:id`、`DELETE /users/:id` - `POST /b/users/:id/assign-engineer-hospital` +其中院管侧的常用链路为: + +- `POST /users`:创建本院用户 +- `GET /users/:id`:查看本院用户详情 +- `PATCH /users/:id`:修改本院用户信息 +- `DELETE /users/:id`:删除本院无关联、且非管理员用户 + 其中主任侧的常用链路为: -- `POST /users`:创建本科室医生 -- `GET /users/:id`:查看本科室医生详情 -- `PATCH /users/:id`:修改本科室医生信息 -- `DELETE /users/:id`:删除无关联数据的本科室医生 +- `GET /users`:查看本科室下级医生/组长 +- `GET /users/:id`:查看本科室下级详情 + +其中组长侧的常用链路为: + +- `GET /users`:查看本小组医生列表 +- `GET /users/:id`:查看本小组医生详情 ## 5. 开发改造建议 diff --git a/package.json b/package.json index 23b3d86..2ded8a2 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && node prisma/seed.mjs", + "test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate", "test:e2e": "pnpm test:e2e:prepare && NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --runInBand", "test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --watch" }, @@ -30,10 +30,13 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "dotenv": "^17.3.1", + "ffmpeg-static": "^5.3.0", "jsonwebtoken": "^9.0.3", + "multer": "^2.1.1", "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sharp": "^0.34.5", "swagger-ui-express": "^5.0.1" }, "devDependencies": { @@ -44,6 +47,7 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.1.0", "@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 2c19a0c..b51083e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,15 @@ importers: dotenv: specifier: ^17.3.1 version: 17.3.1 + ffmpeg-static: + specifier: ^5.3.0 + version: 5.3.0 jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + multer: + specifier: ^2.1.1 + version: 2.1.1 pg: specifier: ^8.20.0 version: 8.20.0 @@ -56,6 +62,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + sharp: + specifier: ^0.34.5 + version: 0.34.5 swagger-ui-express: specifier: ^5.0.1 version: 5.0.1(express@5.2.1) @@ -81,6 +90,9 @@ importers: '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 + '@types/multer': + specifier: ^2.1.0 + version: 2.1.0 '@types/node': specifier: ^22.10.7 version: 22.19.15 @@ -342,6 +354,10 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@derhuerst/http-basic@8.2.4': + resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} + engines: {node: '>=6.0.0'} + '@electric-sql/pglite-socket@0.0.20': resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} hasBin: true @@ -371,6 +387,159 @@ packages: peerDependencies: hono: ^4 + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -932,6 +1101,12 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.1.0': + resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} + + '@types/node@10.17.60': + resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} @@ -1147,6 +1322,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1368,6 +1547,9 @@ packages: caniuse-lite@1.0.30001778: resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1573,6 +1755,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1633,6 +1819,10 @@ packages: resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1736,6 +1926,10 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + ffmpeg-static@5.3.0: + resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==} + engines: {node: '>=16'} + file-type@21.3.0: resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} engines: {node: '>=20'} @@ -1896,9 +2090,16 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-response-object@3.0.2: + resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} + http-status-codes@2.3.0: resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2465,6 +2666,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-cache-control@1.0.1: + resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -2639,6 +2843,10 @@ packages: typescript: optional: true + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -2778,6 +2986,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3491,6 +3703,13 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@derhuerst/http-basic@8.2.4': + dependencies: + caseless: 0.12.0 + concat-stream: 2.0.0 + http-response-object: 3.0.2 + parse-cache-control: 1.0.1 + '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: '@electric-sql/pglite': 0.3.15 @@ -3521,6 +3740,102 @@ snapshots: dependencies: hono: 4.11.4 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@22.19.15)': @@ -4247,6 +4562,12 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@2.1.0': + dependencies: + '@types/express': 5.0.6 + + '@types/node@10.17.60': {} + '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -4452,6 +4773,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -4699,6 +5026,8 @@ snapshots: caniuse-lite@1.0.30001778: {} + caseless@0.12.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4864,6 +5193,8 @@ snapshots: destr@2.0.5: {} + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} dezalgo@1.0.4: @@ -4913,6 +5244,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + env-paths@2.2.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -5035,6 +5368,15 @@ snapshots: dependencies: bser: 2.1.1 + ffmpeg-static@5.3.0: + dependencies: + '@derhuerst/http-basic': 8.2.4 + env-paths: 2.2.1 + https-proxy-agent: 5.0.1 + progress: 2.0.3 + transitivePeerDependencies: + - supports-color + file-type@21.3.0: dependencies: '@tokenizer/inflate': 0.4.1 @@ -5229,8 +5571,19 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-response-object@3.0.2: + dependencies: + '@types/node': 10.17.60 + http-status-codes@2.3.0: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.7.2: @@ -5926,6 +6279,8 @@ snapshots: dependencies: callsites: 3.1.0 + parse-cache-control@1.0.1: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -6076,6 +6431,8 @@ snapshots: - react - react-dom + progress@2.0.3: {} + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -6223,6 +6580,37 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..f44389a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - ffmpeg-static + - sharp diff --git a/prisma/migrations/20260319153000_add_patient_creator_fields/migration.sql b/prisma/migrations/20260319153000_add_patient_creator_fields/migration.sql new file mode 100644 index 0000000..156f297 --- /dev/null +++ b/prisma/migrations/20260319153000_add_patient_creator_fields/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "Patient" +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "creatorId" INTEGER; + +-- Backfill +UPDATE "Patient" +SET "creatorId" = "doctorId" +WHERE "creatorId" IS NULL; + +-- AlterTable +ALTER TABLE "Patient" +ALTER COLUMN "creatorId" SET NOT NULL; + +-- CreateIndex +CREATE INDEX "Patient_creatorId_idx" ON "Patient"("creatorId"); + +-- AddForeignKey +ALTER TABLE "Patient" ADD CONSTRAINT "Patient_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260319164000_remove_device_sn_code/migration.sql b/prisma/migrations/20260319164000_remove_device_sn_code/migration.sql new file mode 100644 index 0000000..6e88381 --- /dev/null +++ b/prisma/migrations/20260319164000_remove_device_sn_code/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX IF EXISTS "Device_snCode_key"; + +-- AlterTable +ALTER TABLE "Device" DROP COLUMN "snCode"; diff --git a/prisma/migrations/20260319164701_pressure_label_strings_and_surgeon_refactor/migration.sql b/prisma/migrations/20260319164701_pressure_label_strings_and_surgeon_refactor/migration.sql new file mode 100644 index 0000000..c6c1f32 --- /dev/null +++ b/prisma/migrations/20260319164701_pressure_label_strings_and_surgeon_refactor/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "Device" ALTER COLUMN "currentPressure" SET DATA TYPE TEXT, +ALTER COLUMN "initialPressure" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "ImplantCatalog" ALTER COLUMN "pressureLevels" SET DATA TYPE TEXT[]; + +-- AlterTable +ALTER TABLE "PatientSurgery" ADD COLUMN "surgeonId" INTEGER; + +-- AlterTable +ALTER TABLE "TaskItem" ALTER COLUMN "oldPressure" SET DATA TYPE TEXT, +ALTER COLUMN "targetPressure" SET DATA TYPE TEXT; + +-- CreateIndex +CREATE INDEX "PatientSurgery_surgeonId_idx" ON "PatientSurgery"("surgeonId"); + +-- AddForeignKey +ALTER TABLE "PatientSurgery" ADD CONSTRAINT "PatientSurgery_surgeonId_fkey" FOREIGN KEY ("surgeonId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260319184610_add_upload_assets_library/migration.sql b/prisma/migrations/20260319184610_add_upload_assets_library/migration.sql new file mode 100644 index 0000000..a06bccf --- /dev/null +++ b/prisma/migrations/20260319184610_add_upload_assets_library/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "UploadAssetType" AS ENUM ('IMAGE', 'VIDEO', 'FILE'); + +-- CreateTable +CREATE TABLE "UploadAsset" ( + "id" SERIAL NOT NULL, + "hospitalId" INTEGER NOT NULL, + "creatorId" INTEGER NOT NULL, + "type" "UploadAssetType" NOT NULL, + "originalName" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "storagePath" TEXT NOT NULL, + "url" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "fileSize" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UploadAsset_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UploadAsset_storagePath_key" ON "UploadAsset"("storagePath"); + +-- CreateIndex +CREATE INDEX "UploadAsset_hospitalId_type_createdAt_idx" ON "UploadAsset"("hospitalId", "type", "createdAt"); + +-- CreateIndex +CREATE INDEX "UploadAsset_creatorId_createdAt_idx" ON "UploadAsset"("creatorId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "UploadAsset" ADD CONSTRAINT "UploadAsset_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UploadAsset" ADD CONSTRAINT "UploadAsset_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5233423..38d7018 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,6 +46,13 @@ enum DictionaryType { DISTAL_SHUNT_DIRECTION } +// 上传资产类型:用于图库/视频库分类。 +enum UploadAssetType { + IMAGE + VIDEO + FILE +} + // 医院主表:多租户顶层实体。 model Hospital { id Int @id @default(autoincrement()) @@ -54,6 +61,7 @@ model Hospital { users User[] patients Patient[] tasks Task[] + uploads UploadAsset[] } // 科室表:归属于医院。 @@ -81,25 +89,28 @@ model Group { // 用户表:支持后台密码登录与小程序 openId。 model User { - id Int @id @default(autoincrement()) - name String - phone String + id Int @id @default(autoincrement()) + name String + phone String // 后台登录密码哈希(bcrypt)。 - passwordHash String? + passwordHash String? // 该时间点之前签发的 token 一律失效。 - tokenValidAfter DateTime @default(now()) - 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]) + tokenValidAfter DateTime @default(now()) + 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]) // 小组删除必须先清理成员,避免静默把用户 groupId 置空。 - group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict) - doctorPatients Patient[] @relation("DoctorPatients") - createdTasks Task[] @relation("TaskCreator") - acceptedTasks Task[] @relation("TaskEngineer") + group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict) + doctorPatients Patient[] @relation("DoctorPatients") + createdPatients Patient[] @relation("PatientCreator") + createdTasks Task[] @relation("TaskCreator") + acceptedTasks Task[] @relation("TaskEngineer") + surgeonSurgeries PatientSurgery[] @relation("SurgerySurgeon") + createdUploads UploadAsset[] @relation("UploadCreator") @@unique([phone, role, hospitalId]) @@index([phone]) @@ -112,6 +123,7 @@ model User { model Patient { id Int @id @default(autoincrement()) name String + createdAt DateTime @default(now()) // 住院号:用于院内患者检索与病案关联。 inpatientNo String? // 项目名称:用于区分患者所属项目/课题。 @@ -121,14 +133,17 @@ model Patient { idCard String hospitalId Int doctorId Int + creatorId Int hospital Hospital @relation(fields: [hospitalId], references: [id]) doctor User @relation("DoctorPatients", fields: [doctorId], references: [id]) + creator User @relation("PatientCreator", fields: [creatorId], references: [id]) surgeries PatientSurgery[] devices Device[] @@index([phone, idCard]) @@index([hospitalId, doctorId]) @@index([inpatientNo]) + @@index([creatorId]) } // 患者手术表:保存每次分流/复手术档案。 @@ -137,6 +152,7 @@ model PatientSurgery { patientId Int surgeryDate DateTime surgeryName String + surgeonId Int? surgeonName String // 术前测压:部分患者可为空。 preOpPressure Int? @@ -151,9 +167,11 @@ model PatientSurgery { notes String? createdAt DateTime @default(now()) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + surgeon User? @relation("SurgerySurgeon", fields: [surgeonId], references: [id], onDelete: SetNull) devices Device[] @@index([patientId, surgeryDate]) + @@index([surgeonId]) } // 植入物型号字典:供前端单选型号后自动回填厂家与名称。 @@ -163,7 +181,7 @@ model ImplantCatalog { manufacturer String name String // 可调压器械的可选挡位,由系统管理员维护。 - pressureLevels Int[] @default([]) + pressureLevels String[] @default([]) isPressureAdjustable Boolean @default(true) notes String? createdAt DateTime @default(now()) @@ -185,11 +203,30 @@ model DictionaryItem { @@index([type, enabled, sortOrder]) } +// 上传资产表:保存图片/视频/文件元数据,供图库与患者表单复用。 +model UploadAsset { + id Int @id @default(autoincrement()) + hospitalId Int + creatorId Int + type UploadAssetType + originalName String + fileName String + storagePath String @unique + url String + mimeType String + fileSize Int + createdAt DateTime @default(now()) + hospital Hospital @relation(fields: [hospitalId], references: [id]) + creator User @relation("UploadCreator", fields: [creatorId], references: [id]) + + @@index([hospitalId, type, createdAt]) + @@index([creatorId, createdAt]) +} + // 设备表:每次手术植入的设备实例,保留当前压力与历史调压记录。 model Device { id Int @id @default(autoincrement()) - snCode String @unique - currentPressure Int + currentPressure String status DeviceStatus @default(ACTIVE) patientId Int surgeryId Int? @@ -205,7 +242,7 @@ model Device { proximalPunctureAreas String[] @default([]) valvePlacementSites String[] @default([]) distalShuntDirection String? - initialPressure Int? + initialPressure String? implantNotes String? labelImageUrl String? patient Patient @relation(fields: [patientId], references: [id]) @@ -240,8 +277,8 @@ model TaskItem { id Int @id @default(autoincrement()) taskId Int deviceId Int - oldPressure Int - targetPressure Int + oldPressure String + targetPressure String task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) device Device @relation(fields: [deviceId], references: [id]) diff --git a/prisma/seed.mjs b/prisma/seed.mjs index 03b46b5..fc5eafe 100644 --- a/prisma/seed.mjs +++ b/prisma/seed.mjs @@ -64,6 +64,7 @@ async function upsertUserByOpenId(openId, data) { async function ensurePatient({ hospitalId, doctorId, + creatorId, name, inpatientNo = null, projectName = null, @@ -81,13 +82,14 @@ async function ensurePatient({ if (existing) { if ( existing.doctorId !== doctorId || + existing.creatorId !== creatorId || existing.name !== name || existing.inpatientNo !== inpatientNo || existing.projectName !== projectName ) { return prisma.patient.update({ where: { id: existing.id }, - data: { doctorId, name, inpatientNo, projectName }, + data: { doctorId, creatorId, name, inpatientNo, projectName }, }); } return existing; @@ -97,6 +99,7 @@ async function ensurePatient({ data: { hospitalId, doctorId, + creatorId, name, inpatientNo, projectName, @@ -216,6 +219,63 @@ async function ensurePatientSurgery({ }); } +async function ensureDevice({ + patientId, + surgeryId, + implantCatalogId, + currentPressure, + status, + implantModel, + implantManufacturer, + implantName, + isPressureAdjustable, + isAbandoned, + shuntMode, + proximalPunctureAreas, + valvePlacementSites, + distalShuntDirection, + initialPressure, + implantNotes, + labelImageUrl, +}) { + const existing = await prisma.device.findFirst({ + where: { + patientId, + surgeryId, + implantNotes, + }, + }); + + const data = { + patientId, + surgeryId, + implantCatalogId, + currentPressure, + status, + implantModel, + implantManufacturer, + implantName, + isPressureAdjustable, + isAbandoned, + shuntMode, + proximalPunctureAreas, + valvePlacementSites, + distalShuntDirection, + initialPressure, + implantNotes, + labelImageUrl, + }; + + if (existing) { + return prisma.device.update({ + where: { id: existing.id }, + data, + }); + } + + return prisma.device.create({ data }); +} + async function main() { const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12); @@ -395,6 +455,7 @@ async function main() { const patientA1 = await ensurePatient({ hospitalId: hospitalA.id, doctorId: doctorA.id, + creatorId: doctorA.id, name: 'Seed Patient A1', inpatientNo: 'ZYH-A-0001', projectName: '脑积水随访项目-A', @@ -405,6 +466,7 @@ async function main() { const patientA2 = await ensurePatient({ hospitalId: hospitalA.id, doctorId: doctorA2.id, + creatorId: doctorA2.id, name: 'Seed Patient A2', inpatientNo: 'ZYH-A-0002', projectName: '脑积水随访项目-A', @@ -415,6 +477,7 @@ async function main() { const patientA3 = await ensurePatient({ hospitalId: hospitalA.id, doctorId: doctorA3.id, + creatorId: doctorA3.id, name: 'Seed Patient A3', inpatientNo: 'ZYH-A-0003', projectName: '脑积水随访项目-A', @@ -425,6 +488,7 @@ async function main() { const patientB1 = await ensurePatient({ hospitalId: hospitalB.id, doctorId: doctorB.id, + creatorId: doctorB.id, name: 'Seed Patient B1', inpatientNo: 'ZYH-B-0001', projectName: '脑积水随访项目-B', @@ -510,219 +574,104 @@ async function main() { hydrocephalusTypes: ['高压性'], }); - const deviceA1 = await prisma.device.upsert({ - where: { snCode: 'SEED-SN-A-001' }, - update: { - patientId: patientA1.id, - surgeryId: surgeryA1New.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 118, - status: DeviceStatus.ACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: false, - shuntMode: 'VPS', - proximalPunctureAreas: ['额角'], - valvePlacementSites: ['耳后'], - distalShuntDirection: '腹腔', - initialPressure: 118, - implantNotes: 'Seed A1 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg', - }, - create: { - snCode: 'SEED-SN-A-001', - patientId: patientA1.id, - surgeryId: surgeryA1New.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 118, - status: DeviceStatus.ACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: false, - shuntMode: 'VPS', - proximalPunctureAreas: ['额角'], - valvePlacementSites: ['耳后'], - distalShuntDirection: '腹腔', - initialPressure: 118, - implantNotes: 'Seed A1 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg', - }, + const deviceA1 = await ensureDevice({ + patientId: patientA1.id, + surgeryId: surgeryA1New.id, + implantCatalogId: adjustableCatalog.id, + currentPressure: 118, + status: DeviceStatus.ACTIVE, + implantModel: adjustableCatalog.modelCode, + implantManufacturer: adjustableCatalog.manufacturer, + implantName: adjustableCatalog.name, + isPressureAdjustable: adjustableCatalog.isPressureAdjustable, + isAbandoned: false, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: 118, + implantNotes: 'Seed A1 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg', }); - const deviceA2 = await prisma.device.upsert({ - where: { snCode: 'SEED-SN-A-002' }, - update: { - patientId: patientA2.id, - surgeryId: surgeryA2.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 112, - status: DeviceStatus.ACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: false, - shuntMode: 'VPS', - proximalPunctureAreas: ['枕角'], - valvePlacementSites: ['胸前'], - distalShuntDirection: '腹腔', - initialPressure: 112, - implantNotes: 'Seed A2 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg', - }, - create: { - snCode: 'SEED-SN-A-002', - patientId: patientA2.id, - surgeryId: surgeryA2.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 112, - status: DeviceStatus.ACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: false, - shuntMode: 'VPS', - proximalPunctureAreas: ['枕角'], - valvePlacementSites: ['胸前'], - distalShuntDirection: '腹腔', - initialPressure: 112, - implantNotes: 'Seed A2 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg', - }, + const deviceA2 = await ensureDevice({ + patientId: patientA2.id, + surgeryId: surgeryA2.id, + implantCatalogId: adjustableCatalog.id, + currentPressure: 112, + status: DeviceStatus.ACTIVE, + implantModel: adjustableCatalog.modelCode, + implantManufacturer: adjustableCatalog.manufacturer, + implantName: adjustableCatalog.name, + isPressureAdjustable: adjustableCatalog.isPressureAdjustable, + isAbandoned: false, + shuntMode: 'VPS', + proximalPunctureAreas: ['枕角'], + valvePlacementSites: ['胸前'], + distalShuntDirection: '腹腔', + initialPressure: 112, + implantNotes: 'Seed A2 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg', }); - await prisma.device.upsert({ - where: { snCode: 'SEED-SN-A-003' }, - update: { - patientId: patientA3.id, - surgeryId: surgeryA3.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 109, - status: DeviceStatus.ACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: false, - shuntMode: 'LPS', - proximalPunctureAreas: ['腰穿'], - valvePlacementSites: ['腰背部'], - distalShuntDirection: '腹腔', - initialPressure: 109, - implantNotes: 'Seed A3 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg', - }, - create: { - snCode: 'SEED-SN-A-003', - patientId: patientA3.id, - surgeryId: surgeryA3.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 109, - status: DeviceStatus.ACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: false, - shuntMode: 'LPS', - proximalPunctureAreas: ['腰穿'], - valvePlacementSites: ['腰背部'], - distalShuntDirection: '腹腔', - initialPressure: 109, - implantNotes: 'Seed A3 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg', - }, + await ensureDevice({ + patientId: patientA3.id, + surgeryId: surgeryA3.id, + implantCatalogId: adjustableCatalog.id, + currentPressure: 109, + status: DeviceStatus.ACTIVE, + implantModel: adjustableCatalog.modelCode, + implantManufacturer: adjustableCatalog.manufacturer, + implantName: adjustableCatalog.name, + isPressureAdjustable: adjustableCatalog.isPressureAdjustable, + isAbandoned: false, + shuntMode: 'LPS', + proximalPunctureAreas: ['腰穿'], + valvePlacementSites: ['腰背部'], + distalShuntDirection: '腹腔', + initialPressure: 109, + implantNotes: 'Seed A3 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg', }); - const deviceB1 = await prisma.device.upsert({ - where: { snCode: 'SEED-SN-B-001' }, - update: { - patientId: patientB1.id, - surgeryId: surgeryB1.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 121, - status: DeviceStatus.ACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: false, - shuntMode: 'VPS', - proximalPunctureAreas: ['额角'], - valvePlacementSites: ['耳后'], - distalShuntDirection: '腹腔', - initialPressure: 121, - implantNotes: 'Seed B1 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg', - }, - create: { - snCode: 'SEED-SN-B-001', - patientId: patientB1.id, - surgeryId: surgeryB1.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 121, - status: DeviceStatus.ACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: false, - shuntMode: 'VPS', - proximalPunctureAreas: ['额角'], - valvePlacementSites: ['耳后'], - distalShuntDirection: '腹腔', - initialPressure: 121, - implantNotes: 'Seed B1 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg', - }, + const deviceB1 = await ensureDevice({ + patientId: patientB1.id, + surgeryId: surgeryB1.id, + implantCatalogId: adjustableCatalog.id, + currentPressure: 121, + status: DeviceStatus.ACTIVE, + implantModel: adjustableCatalog.modelCode, + implantManufacturer: adjustableCatalog.manufacturer, + implantName: adjustableCatalog.name, + isPressureAdjustable: adjustableCatalog.isPressureAdjustable, + isAbandoned: false, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: 121, + implantNotes: 'Seed B1 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg', }); - await prisma.device.upsert({ - where: { snCode: 'SEED-SN-A-004' }, - update: { - patientId: patientA1.id, - surgeryId: surgeryA1Old.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 130, - status: DeviceStatus.INACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: true, - shuntMode: 'VPS', - proximalPunctureAreas: ['额角'], - valvePlacementSites: ['耳后'], - distalShuntDirection: '腹腔', - initialPressure: 130, - implantNotes: 'Seed A1 弃用历史设备', - labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg', - }, - create: { - snCode: 'SEED-SN-A-004', - patientId: patientA1.id, - surgeryId: surgeryA1Old.id, - implantCatalogId: adjustableCatalog.id, - currentPressure: 130, - status: DeviceStatus.INACTIVE, - implantModel: adjustableCatalog.modelCode, - implantManufacturer: adjustableCatalog.manufacturer, - implantName: adjustableCatalog.name, - isPressureAdjustable: adjustableCatalog.isPressureAdjustable, - isAbandoned: true, - shuntMode: 'VPS', - proximalPunctureAreas: ['额角'], - valvePlacementSites: ['耳后'], - distalShuntDirection: '腹腔', - initialPressure: 130, - implantNotes: 'Seed A1 弃用历史设备', - labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg', - }, + await ensureDevice({ + patientId: patientA1.id, + surgeryId: surgeryA1Old.id, + implantCatalogId: adjustableCatalog.id, + currentPressure: 130, + status: DeviceStatus.INACTIVE, + implantModel: adjustableCatalog.modelCode, + implantManufacturer: adjustableCatalog.manufacturer, + implantName: adjustableCatalog.name, + isPressureAdjustable: adjustableCatalog.isPressureAdjustable, + isAbandoned: true, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: 130, + implantNotes: 'Seed A1 弃用历史设备', + labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg', }); // 清理与种子设备关联的历史任务,保证 seed 可重复执行且生命周期夹具稳定。 diff --git a/src/app.module.ts b/src/app.module.ts index e25179b..504ad66 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { OrganizationModule } from './organization/organization.module.js'; import { NotificationsModule } from './notifications/notifications.module.js'; import { DevicesModule } from './devices/devices.module.js'; import { DictionariesModule } from './dictionaries/dictionaries.module.js'; +import { UploadsModule } from './uploads/uploads.module.js'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module.js'; NotificationsModule, DevicesModule, DictionariesModule, + UploadsModule, ], }) export class AppModule {} diff --git a/src/common/messages.ts b/src/common/messages.ts index e5d1d06..3facf45 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -58,19 +58,22 @@ export const MESSAGES = { '检测到多个同手机号账号,请传 hospitalId 指定登录医院', CREATE_FORBIDDEN: '当前角色无权限创建该用户', HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号', - DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生账号', + DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号', }, TASK: { ITEMS_REQUIRED: '任务明细 items 不能为空', DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在', + DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院', + ENGINEER_REQUIRED: '接收工程师必选', ENGINEER_INVALID: '工程师必须为当前医院有效工程师', TASK_NOT_FOUND: '任务不存在或不属于当前医院', - ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收', - COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成', - CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消', - ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收', - ENGINEER_ONLY_ASSIGNEE: '仅任务接收工程师可完成任务', + ACCEPT_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师', + ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收', + COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成', + CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消', + ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师', + ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务', CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务', ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', @@ -99,9 +102,7 @@ export const MESSAGES = { DEVICE: { NOT_FOUND: '设备不存在或无权限访问', - SN_CODE_REQUIRED: 'snCode 不能为空', - SN_CODE_DUPLICATE: '设备 SN 已存在', - CURRENT_PRESSURE_INVALID: 'currentPressure 必须为大于等于 0 的整数', + CURRENT_PRESSURE_INVALID: 'currentPressure 必须是合法挡位标签', STATUS_INVALID: '设备状态不合法', PATIENT_REQUIRED: 'patientId 必填且必须为整数', PATIENT_NOT_FOUND: '归属患者不存在', @@ -123,6 +124,17 @@ export const MESSAGES = { SYSTEM_ADMIN_ONLY_MAINTAIN: '仅系统管理员可维护字典', }, + UPLOAD: { + FILE_REQUIRED: '请先选择要上传的文件', + UNSUPPORTED_FILE_TYPE: '仅支持图片、视频、PDF/Office 文档上传', + ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息,无法上传文件', + SYSTEM_ADMIN_HOSPITAL_REQUIRED: + '系统管理员上传文件时必须显式指定 hospitalId', + INVALID_IMAGE_FILE: '上传的图片无法解析或压缩失败', + INVALID_VIDEO_FILE: '上传的视频无法解析或压缩失败', + FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力', + }, + ORG: { HOSPITAL_NOT_FOUND: '医院不存在', DEPARTMENT_NOT_FOUND: '科室不存在', diff --git a/src/common/pressure-level.util.ts b/src/common/pressure-level.util.ts new file mode 100644 index 0000000..151d64a --- /dev/null +++ b/src/common/pressure-level.util.ts @@ -0,0 +1,56 @@ +import { BadRequestException } from '@nestjs/common'; + +/** + * 挡位标签标准化:将输入统一整理为可稳定比较和展示的字符串。 + */ +export function normalizePressureLabel(value: unknown, fieldName: string) { + const raw = + typeof value === 'string' || typeof value === 'number' + ? String(value).trim() + : ''; + + if (!raw) { + throw new BadRequestException(`${fieldName} 不能为空`); + } + if (!/^\d+(\.\d+)?$/.test(raw)) { + throw new BadRequestException(`${fieldName} 必须是合法挡位标签`); + } + + const [integerPart, fractionPart = ''] = raw.split('.'); + const normalizedInteger = integerPart.replace(/^0+(?=\d)/, '') || '0'; + const normalizedFraction = fractionPart.replace(/0+$/, ''); + + return normalizedFraction + ? `${normalizedInteger}.${normalizedFraction}` + : normalizedInteger; +} + +/** + * 挡位标签比较:按数值大小排序,数值相同再按标准字符串比较。 + */ +export function comparePressureLabel(left: string, right: string) { + const leftNumber = Number(left); + const rightNumber = Number(right); + + if (leftNumber !== rightNumber) { + return leftNumber - rightNumber; + } + + return left.localeCompare(right, 'en'); +} + +/** + * 挡位列表标准化:去重、排序。 + */ +export function normalizePressureLabelList( + values: unknown[] | undefined, + fieldName: string, +) { + if (!Array.isArray(values) || values.length === 0) { + return []; + } + + return Array.from( + new Set(values.map((value) => normalizePressureLabel(value, fieldName))), + ).sort(comparePressureLabel); +} diff --git a/src/departments/departments.controller.ts b/src/departments/departments.controller.ts index 43d0149..7274313 100644 --- a/src/departments/departments.controller.ts +++ b/src/departments/departments.controller.ts @@ -55,7 +55,13 @@ export class DepartmentsController { * 查询科室列表。 */ @Get() - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DIRECTOR, + Role.LEADER, + Role.DOCTOR, + ) @ApiOperation({ summary: '查询科室列表' }) @ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' }) findAll( @@ -69,7 +75,13 @@ export class DepartmentsController { * 查询科室详情。 */ @Get(':id') - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DIRECTOR, + Role.LEADER, + Role.DOCTOR, + ) @ApiOperation({ summary: '查询科室详情' }) @ApiParam({ name: 'id', description: '科室 ID' }) findOne( @@ -83,7 +95,7 @@ export class DepartmentsController { * 更新科室。 */ @Patch(':id') - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '更新科室' }) update( @CurrentActor() actor: ActorContext, diff --git a/src/departments/departments.service.ts b/src/departments/departments.service.ts index 3bc484d..ded560c 100644 --- a/src/departments/departments.service.ts +++ b/src/departments/departments.service.ts @@ -48,7 +48,7 @@ export class DepartmentsService { } /** - * 查询科室列表:院管限定本院;主任/组长限定本科室。 + * 查询科室列表:院管限定本院。 */ async findAll(actor: ActorContext, query: OrganizationQueryDto) { this.access.assertRole(actor, [ @@ -56,6 +56,7 @@ export class DepartmentsService { Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER, + Role.DOCTOR, ]); const paging = this.access.resolvePaging(query); const where: Prisma.DepartmentWhereInput = {}; @@ -66,7 +67,11 @@ export class DepartmentsService { if (actor.role === Role.HOSPITAL_ADMIN) { where.hospitalId = this.access.requireActorHospitalId(actor); - } else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) { + } else if ( + actor.role === Role.DIRECTOR || + actor.role === Role.LEADER || + actor.role === Role.DOCTOR + ) { where.id = this.access.requireActorDepartmentId(actor); } else if (query.hospitalId != null) { where.hospitalId = this.access.toInt( @@ -93,7 +98,7 @@ export class DepartmentsService { } /** - * 查询科室详情:院管仅可查看本院;主任/组长仅可查看本科室。 + * 查询科室详情:院管仅可查看本院。 */ async findOne(actor: ActorContext, id: number) { this.access.assertRole(actor, [ @@ -101,6 +106,7 @@ export class DepartmentsService { Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER, + Role.DOCTOR, ]); const departmentId = this.access.toInt( id, @@ -118,18 +124,21 @@ export class DepartmentsService { } if (actor.role === Role.HOSPITAL_ADMIN) { this.access.assertHospitalScope(actor, department.hospitalId); - } - if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) { + } else if ( + actor.role === Role.DIRECTOR || + actor.role === Role.LEADER || + actor.role === Role.DOCTOR + ) { const actorDepartmentId = this.access.requireActorDepartmentId(actor); if (department.id !== actorDepartmentId) { - throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); + throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID); } } return department; } /** - * 更新科室:院管仅可修改本院;主任/组长仅可修改本科室。 + * 更新科室:院管仅可修改本院。 */ async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) { const current = await this.findOne(actor, id); diff --git a/src/devices/devices.service.ts b/src/devices/devices.service.ts index 80224b6..f7eec38 100644 --- a/src/devices/devices.service.ts +++ b/src/devices/devices.service.ts @@ -9,6 +9,10 @@ import { Prisma } from '../generated/prisma/client.js'; import { DeviceStatus, Role } from '../generated/prisma/enums.js'; import type { ActorContext } from '../common/actor-context.js'; import { MESSAGES } from '../common/messages.js'; +import { + normalizePressureLabelList, + normalizePressureLabel, +} from '../common/pressure-level.util.js'; import { PrismaService } from '../prisma.service.js'; import { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.js'; import { CreateDeviceDto } from './dto/create-device.dto.js'; @@ -133,15 +137,12 @@ export class DevicesService { async create(actor: ActorContext, dto: CreateDeviceDto) { this.assertAdmin(actor); - const snCode = this.normalizeSnCode(dto.snCode); const patient = await this.resolveWritablePatient(actor, dto.patientId); - await this.assertSnCodeUnique(snCode); return this.prisma.device.create({ data: { - snCode, // 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。 - currentPressure: 0, + currentPressure: '0', status: dto.status ?? DeviceStatus.ACTIVE, patientId: patient.id, }, @@ -150,17 +151,12 @@ export class DevicesService { } /** - * 更新设备:允许修改 SN、状态和归属患者;当前压力仅由任务完成时更新。 + * 更新设备:允许修改状态和归属患者;当前压力仅由任务完成时更新。 */ async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) { const current = await this.findOne(actor, id); const data: Prisma.DeviceUpdateInput = {}; - if (dto.snCode !== undefined) { - const snCode = this.normalizeSnCode(dto.snCode); - await this.assertSnCodeUnique(snCode, current.id); - data.snCode = snCode; - } if (dto.status !== undefined) { data.status = this.normalizeStatus(dto.status); } @@ -366,12 +362,6 @@ export class DevicesService { if (keyword) { andConditions.push({ OR: [ - { - snCode: { - contains: keyword, - mode: 'insensitive', - }, - }, { implantModel: { contains: keyword, @@ -590,13 +580,6 @@ export class DevicesService { return this.normalizeRequiredString(value, 'modelCode').toUpperCase(); } - /** - * 设备 SN 标准化:统一去空白并转大写,避免大小写重复。 - */ - private normalizeSnCode(value: unknown) { - return this.normalizeRequiredString(value, 'snCode').toUpperCase(); - } - private normalizeRequiredString(value: unknown, fieldName: string) { if (typeof value !== 'string') { throw new BadRequestException(`${fieldName} 必须是字符串`); @@ -622,41 +605,25 @@ export class DevicesService { * 挡位列表标准化:去重、排序,并在非可调压目录下自动清空。 */ private normalizePressureLevels( - pressureLevels: number[] | undefined, + pressureLevels: unknown[] | undefined, isPressureAdjustable: boolean, ) { if (!isPressureAdjustable) { return []; } - if (!Array.isArray(pressureLevels) || pressureLevels.length === 0) { - return []; - } - - return Array.from( - new Set( - pressureLevels.map((level) => { - const normalized = Number(level); - if (!Number.isInteger(normalized) || normalized < 0) { - throw new BadRequestException( - 'pressureLevels 必须为大于等于 0 的整数数组', - ); - } - return normalized; - }), - ), - ).sort((left, right) => left - right); + return normalizePressureLabelList(pressureLevels, 'pressureLevels'); } /** - * 压力值必须是非负整数。 + * 当前压力挡位标签标准化。 */ private normalizePressure(value: unknown) { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0) { + try { + return normalizePressureLabel(value, 'currentPressure'); + } catch { throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID); } - return parsed; } /** @@ -693,18 +660,4 @@ export class DevicesService { } return actor.hospitalId; } - - /** - * 确保设备 SN 唯一;更新时允许命中自身。 - */ - private async assertSnCodeUnique(snCode: string, selfId?: number) { - const existing = await this.prisma.device.findUnique({ - where: { snCode }, - select: { id: true }, - }); - - if (existing && existing.id !== selfId) { - throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE); - } - } } diff --git a/src/devices/dto/create-device.dto.ts b/src/devices/dto/create-device.dto.ts index 9c1605a..9db7ff9 100644 --- a/src/devices/dto/create-device.dto.ts +++ b/src/devices/dto/create-device.dto.ts @@ -1,16 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DeviceStatus } from '../../generated/prisma/enums.js'; import { Type } from 'class-transformer'; -import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { IsEnum, IsInt, IsOptional, Min } from 'class-validator'; /** * 创建设备 DTO。 */ export class CreateDeviceDto { - @ApiProperty({ description: '设备 SN', example: 'TYT-SN-10001' }) - @IsString({ message: 'snCode 必须是字符串' }) - snCode!: string; - @ApiPropertyOptional({ description: '设备状态,默认 ACTIVE', enum: DeviceStatus, diff --git a/src/devices/dto/create-implant-catalog.dto.ts b/src/devices/dto/create-implant-catalog.dto.ts index c15578e..14b99f9 100644 --- a/src/devices/dto/create-implant-catalog.dto.ts +++ b/src/devices/dto/create-implant-catalog.dto.ts @@ -1,14 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsBoolean, - IsInt, IsOptional, IsString, MaxLength, - Min, } from 'class-validator'; import { ToBoolean } from '../../common/transforms/to-boolean.transform.js'; @@ -38,17 +35,15 @@ export class CreateImplantCatalogDto { name!: string; @ApiPropertyOptional({ - description: '可调压器械的挡位列表,按整数录入', - type: [Number], - example: [80, 100, 120, 140], + description: '可调压器械的挡位列表,按字符串挡位标签录入', + type: [String], + example: ['0.5', '1', '1.5'], }) @IsOptional() @IsArray({ message: 'pressureLevels 必须是数组' }) @ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' }) - @Type(() => Number) - @IsInt({ each: true, message: 'pressureLevels 必须为整数数组' }) - @Min(0, { each: true, message: 'pressureLevels 必须大于等于 0' }) - pressureLevels?: number[]; + @IsString({ each: true, message: 'pressureLevels 必须为字符串数组' }) + pressureLevels?: string[]; @ApiPropertyOptional({ description: '是否支持调压,默认 true', diff --git a/src/devices/dto/device-query.dto.ts b/src/devices/dto/device-query.dto.ts index b1f7550..7f0874b 100644 --- a/src/devices/dto/device-query.dto.ts +++ b/src/devices/dto/device-query.dto.ts @@ -9,8 +9,9 @@ import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; */ export class DeviceQueryDto { @ApiPropertyOptional({ - description: '关键词(支持设备 SN / 患者姓名 / 患者手机号)', - example: 'SN-A', + description: + '关键词(支持植入物型号 / 植入物名称 / 患者姓名 / 患者手机号)', + example: '脑室', }) @IsOptional() @IsString({ message: 'keyword 必须是字符串' }) diff --git a/src/groups/groups.controller.ts b/src/groups/groups.controller.ts index 4c77163..e63d7eb 100644 --- a/src/groups/groups.controller.ts +++ b/src/groups/groups.controller.ts @@ -41,7 +41,7 @@ export class GroupsController { * 创建小组。 */ @Post() - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '创建小组' }) create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) { return this.groupsService.create(actor, dto); @@ -51,7 +51,13 @@ export class GroupsController { * 查询小组列表。 */ @Get() - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DIRECTOR, + Role.LEADER, + Role.DOCTOR, + ) @ApiOperation({ summary: '查询小组列表' }) findAll( @CurrentActor() actor: ActorContext, @@ -64,7 +70,13 @@ export class GroupsController { * 查询小组详情。 */ @Get(':id') - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DIRECTOR, + Role.LEADER, + Role.DOCTOR, + ) @ApiOperation({ summary: '查询小组详情' }) @ApiParam({ name: 'id', description: '小组 ID' }) findOne( @@ -78,7 +90,7 @@ export class GroupsController { * 更新小组。 */ @Patch(':id') - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '更新小组' }) update( @CurrentActor() actor: ActorContext, @@ -92,7 +104,7 @@ export class GroupsController { * 删除小组。 */ @Delete(':id') - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '删除小组' }) remove( @CurrentActor() actor: ActorContext, diff --git a/src/groups/groups.service.ts b/src/groups/groups.service.ts index b6f307a..124a7cb 100644 --- a/src/groups/groups.service.ts +++ b/src/groups/groups.service.ts @@ -26,14 +26,10 @@ export class GroupsService { ) {} /** - * 创建小组:系统管理员可跨院;院管仅可在本院;主任仅可在本科室创建。 + * 创建小组:系统管理员可跨院;院管仅可在本院。 */ async create(actor: ActorContext, dto: CreateGroupDto) { - this.access.assertRole(actor, [ - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - ]); + this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]); const departmentId = this.access.toInt( dto.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED, @@ -42,12 +38,6 @@ export class GroupsService { if (actor.role === Role.HOSPITAL_ADMIN) { this.access.assertHospitalScope(actor, department.hospitalId); } - if (actor.role === Role.DIRECTOR) { - const actorDepartmentId = this.access.requireActorDepartmentId(actor); - if (actorDepartmentId !== department.id) { - throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); - } - } return this.prisma.group.create({ data: { @@ -61,7 +51,7 @@ export class GroupsService { } /** - * 查询小组列表:院管限定本院;主任限定本科室;组长限定本组。 + * 查询小组列表:院管限定本院。 */ async findAll(actor: ActorContext, query: OrganizationQueryDto) { this.access.assertRole(actor, [ @@ -69,6 +59,7 @@ export class GroupsService { Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER, + Role.DOCTOR, ]); const paging = this.access.resolvePaging(query); const where: Prisma.GroupWhereInput = {}; @@ -87,10 +78,12 @@ export class GroupsService { where.department = { hospitalId: this.access.requireActorHospitalId(actor), }; - } else if (actor.role === Role.DIRECTOR) { + } else if ( + actor.role === Role.DIRECTOR || + actor.role === Role.LEADER || + actor.role === Role.DOCTOR + ) { where.departmentId = this.access.requireActorDepartmentId(actor); - } else if (actor.role === Role.LEADER) { - where.id = this.access.requireActorGroupId(actor); } else if (query.hospitalId != null) { where.department = { hospitalId: this.access.toInt( @@ -118,7 +111,7 @@ export class GroupsService { } /** - * 查询小组详情:院管仅可查看本院;主任仅可查看本科室;组长仅可查看本组。 + * 查询小组详情:院管仅可查看本院。 */ async findOne(actor: ActorContext, id: number) { this.access.assertRole(actor, [ @@ -126,6 +119,7 @@ export class GroupsService { Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER, + Role.DOCTOR, ]); const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED); const group = await this.prisma.group.findUnique({ @@ -140,24 +134,21 @@ export class GroupsService { } if (actor.role === Role.HOSPITAL_ADMIN) { this.access.assertHospitalScope(actor, group.department.hospital.id); - } - if (actor.role === Role.DIRECTOR) { + } else if ( + actor.role === Role.DIRECTOR || + actor.role === Role.LEADER || + actor.role === Role.DOCTOR + ) { const actorDepartmentId = this.access.requireActorDepartmentId(actor); if (group.department.id !== actorDepartmentId) { - throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); - } - } - if (actor.role === Role.LEADER) { - const actorGroupId = this.access.requireActorGroupId(actor); - if (group.id !== actorGroupId) { - throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); + throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID); } } return group; } /** - * 更新小组:院管仅可修改本院;主任仅可修改本科室;组长仅可修改本组。 + * 更新小组:院管仅可修改本院。 */ async update(actor: ActorContext, id: number, dto: UpdateGroupDto) { const current = await this.findOne(actor, id); @@ -181,14 +172,10 @@ export class GroupsService { } /** - * 删除小组:院管仅可删除本院;主任仅可删除本科室小组。 + * 删除小组:院管仅可删除本院。 */ async remove(actor: ActorContext, id: number) { - this.access.assertRole(actor, [ - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - ]); + this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]); const current = await this.findOne(actor, id); // 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。 diff --git a/src/main.ts b/src/main.ts index fdb7efe..33be212 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,23 @@ import 'dotenv/config'; +import { mkdirSync } from 'node:fs'; import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module.js'; import { HttpExceptionFilter } from './common/http-exception.filter.js'; import { MESSAGES } from './common/messages.js'; import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js'; +import { resolveUploadRootDir } from './uploads/upload-path.util.js'; async function bootstrap() { // 创建应用实例并加载核心模块。 - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); app.enableCors(); + mkdirSync(resolveUploadRootDir(), { recursive: true }); + app.useStaticAssets(resolveUploadRootDir(), { + prefix: '/uploads/', + }); // 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。 app.useGlobalPipes( new ValidationPipe({ diff --git a/src/patients/b-patients/b-patients.service.ts b/src/patients/b-patients/b-patients.service.ts index 6997788..d86a025 100644 --- a/src/patients/b-patients/b-patients.service.ts +++ b/src/patients/b-patients/b-patients.service.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { BadRequestException, ConflictException, @@ -11,6 +10,7 @@ import { DeviceStatus, Role } from '../../generated/prisma/enums.js'; import { PrismaService } from '../../prisma.service.js'; import type { ActorContext } from '../../common/actor-context.js'; import { MESSAGES } from '../../common/messages.js'; +import { normalizePressureLabel } from '../../common/pressure-level.util.js'; import { CreatePatientDto } from '../dto/create-patient.dto.js'; import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js'; import { UpdatePatientDto } from '../dto/update-patient.dto.js'; @@ -33,10 +33,10 @@ const IMPLANT_CATALOG_SELECT = { const PATIENT_LIST_INCLUDE = { hospital: { select: { id: true, name: true } }, doctor: { select: { id: true, name: true, role: true } }, + creator: { select: { id: true, name: true, role: true } }, devices: { select: { id: true, - snCode: true, status: true, currentPressure: true, isAbandoned: true, @@ -52,6 +52,7 @@ const PATIENT_LIST_INCLUDE = { id: true, surgeryDate: true, surgeryName: true, + surgeonId: true, surgeonName: true, }, orderBy: { surgeryDate: 'desc' }, @@ -76,6 +77,13 @@ const PATIENT_DETAIL_INCLUDE = { groupId: true, }, }, + creator: { + select: { + id: true, + name: true, + role: true, + }, + }, devices: { include: { implantCatalog: { @@ -101,6 +109,13 @@ const PATIENT_DETAIL_INCLUDE = { }, orderBy: { id: 'desc' }, }, + surgeon: { + select: { + id: true, + name: true, + role: true, + }, + }, }, orderBy: { surgeryDate: 'desc' }, }, @@ -212,6 +227,7 @@ export class BPatientsService { const patient = await tx.patient.create({ data: { name: this.normalizeRequiredString(dto.name, 'name'), + creatorId: actor.id, inpatientNo: dto.inpatientNo === undefined ? undefined @@ -231,7 +247,9 @@ export class BPatientsService { if (dto.initialSurgery) { await this.createPatientSurgeryRecord( tx, + actor, patient.id, + patient.doctorId, dto.initialSurgery, ); } @@ -255,7 +273,9 @@ export class BPatientsService { return this.prisma.$transaction(async (tx) => { const createdSurgery = await this.createPatientSurgeryRecord( tx, + actor, patient.id, + patient.doctorId, dto, ); @@ -440,6 +460,7 @@ export class BPatientsService { where: { id: normalizedDoctorId }, select: { id: true, + name: true, role: true, hospitalId: true, departmentId: true, @@ -553,7 +574,9 @@ export class BPatientsService { */ private async createPatientSurgeryRecord( prisma: PrismaExecutor, + actor: ActorContext, patientId: number, + patientDoctorId: number, dto: CreatePatientSurgeryDto, ) { if (!Array.isArray(dto.devices) || dto.devices.length === 0) { @@ -571,13 +594,14 @@ export class BPatientsService { new Set(dto.abandonedDeviceIds ?? []), ); - const [catalogMap, latestSurgery] = await Promise.all([ + const [catalogMap, latestSurgery, surgeon] = await Promise.all([ this.resolveImplantCatalogMap(prisma, catalogIds), prisma.patientSurgery.findFirst({ where: { patientId }, orderBy: { surgeryDate: 'desc' }, select: { surgeryDate: true }, }), + this.resolveWritableDoctor(actor, patientDoctorId), ]); if (abandonedDeviceIds.length > 0) { @@ -596,7 +620,7 @@ export class BPatientsService { } } - const deviceDrafts = dto.devices.map((device, index) => { + const deviceDrafts = dto.devices.map((device) => { const catalog = catalogMap.get(device.implantCatalogId); if (!catalog) { throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND); @@ -607,7 +631,7 @@ export class BPatientsService { ? null : this.assertPressureLevelAllowed( catalog, - this.normalizeNonNegativeInteger( + this.normalizePressureLevel( device.initialPressure, 'initialPressure', ), @@ -615,18 +639,17 @@ export class BPatientsService { const fallbackPressureLevel = catalog.isPressureAdjustable && catalog.pressureLevels.length > 0 ? catalog.pressureLevels[0] - : 0; + : '0'; const currentPressure = catalog.isPressureAdjustable ? this.assertPressureLevelAllowed( catalog, initialPressure ?? fallbackPressureLevel, ) - : 0; + : '0'; return { patient: { connect: { id: patientId } }, implantCatalog: { connect: { id: catalog.id } }, - snCode: this.resolveDeviceSnCode(device.snCode, patientId, index), currentPressure, status: DeviceStatus.ACTIVE, implantModel: catalog.modelCode, @@ -662,11 +685,6 @@ export class BPatientsService { }; }); - await this.assertSnCodesUnique( - prisma, - deviceDrafts.map((device) => device.snCode), - ); - const surgery = await prisma.patientSurgery.create({ data: { patientId, @@ -675,10 +693,8 @@ export class BPatientsService { dto.surgeryName, 'surgeryName', ), - surgeonName: this.normalizeRequiredString( - dto.surgeonName, - 'surgeonName', - ), + surgeonId: surgeon.id, + surgeonName: surgeon.name, preOpPressure: dto.preOpPressure == null ? null @@ -765,9 +781,9 @@ export class BPatientsService { private assertPressureLevelAllowed( catalog: { isPressureAdjustable: boolean; - pressureLevels: number[]; + pressureLevels: string[]; }, - pressure: number, + pressure: string, ) { if ( catalog.isPressureAdjustable && @@ -898,6 +914,10 @@ export class BPatientsService { return parsed; } + private normalizePressureLevel(value: unknown, fieldName: string) { + return normalizePressureLabel(value, fieldName); + } + private normalizeStringArray(value: unknown, fieldName: string) { if (!Array.isArray(value) || value.length === 0) { throw new BadRequestException(`${fieldName} 必须为非空数组`); @@ -927,39 +947,6 @@ export class BPatientsService { })) as Prisma.InputJsonArray; } - private resolveDeviceSnCode( - snCode: string | undefined, - patientId: number, - index: number, - ) { - if (snCode) { - return this.normalizeRequiredString(snCode, 'snCode').toUpperCase(); - } - - return `SURG-${patientId}-${Date.now()}-${index + 1}-${randomUUID() - .slice(0, 8) - .toUpperCase()}`; - } - - private async assertSnCodesUnique(prisma: PrismaExecutor, snCodes: string[]) { - const uniqueSnCodes = Array.from(new Set(snCodes)); - if (uniqueSnCodes.length !== snCodes.length) { - throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE); - } - - const existing = await prisma.device.findMany({ - where: { - snCode: { in: uniqueSnCodes }, - }, - select: { id: true }, - take: 1, - }); - - if (existing.length > 0) { - throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE); - } - } - private toInt(value: unknown, fieldName: string) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts index 7f98ed7..970f90a 100644 --- a/src/patients/c-patients/c-patients.service.ts +++ b/src/patients/c-patients/c-patients.service.ts @@ -110,11 +110,10 @@ export class CPatientsService { }, devices: surgery.devices.map((device) => ({ id: this.toJsonNumber(device.id), - snCode: device.snCode, status: device.status, isAbandoned: device.isAbandoned, - currentPressure: this.toJsonNumber(device.currentPressure), - initialPressure: this.toJsonNumber(device.initialPressure), + currentPressure: device.currentPressure, + initialPressure: device.initialPressure, implantModel: device.implantModel, implantManufacturer: device.implantManufacturer, implantName: device.implantName, @@ -149,10 +148,9 @@ export class CPatientsService { }, device: { id: this.toJsonNumber(device.id), - snCode: device.snCode, status: device.status, isAbandoned: device.isAbandoned, - currentPressure: this.toJsonNumber(device.currentPressure), + currentPressure: device.currentPressure, implantModel: device.implantModel, implantManufacturer: device.implantManufacturer, implantName: device.implantName, @@ -172,8 +170,8 @@ export class CPatientsService { }, taskItem: { id: this.toJsonNumber(taskItem.id), - oldPressure: this.toJsonNumber(taskItem.oldPressure), - targetPressure: this.toJsonNumber(taskItem.targetPressure), + oldPressure: taskItem.oldPressure, + targetPressure: taskItem.targetPressure, }, }, ]; diff --git a/src/patients/dto/create-patient-surgery.dto.ts b/src/patients/dto/create-patient-surgery.dto.ts index 8983621..741532f 100644 --- a/src/patients/dto/create-patient-surgery.dto.ts +++ b/src/patients/dto/create-patient-surgery.dto.ts @@ -32,13 +32,6 @@ export class CreatePatientSurgeryDto { @IsString({ message: 'surgeryName 必须是字符串' }) surgeryName!: string; - @ApiProperty({ - description: '主刀医生', - example: '张主任', - }) - @IsString({ message: 'surgeonName 必须是字符串' }) - surgeonName!: string; - @ApiPropertyOptional({ description: '术前测压,可为空', example: 22, diff --git a/src/patients/dto/create-surgery-device.dto.ts b/src/patients/dto/create-surgery-device.dto.ts index f1c48ed..d1f3343 100644 --- a/src/patients/dto/create-surgery-device.dto.ts +++ b/src/patients/dto/create-surgery-device.dto.ts @@ -23,14 +23,6 @@ export class CreateSurgeryDeviceDto { @Min(1, { message: 'implantCatalogId 必须大于 0' }) implantCatalogId!: number; - @ApiPropertyOptional({ - description: '设备 SN,可不传;不传时系统自动生成', - example: 'TYT-SHUNT-001', - }) - @IsOptional() - @IsString({ message: 'snCode 必须是字符串' }) - snCode?: string; - @ApiProperty({ description: '分流方式', example: 'VPS', @@ -68,14 +60,12 @@ export class CreateSurgeryDeviceDto { distalShuntDirection!: string; @ApiPropertyOptional({ - description: '初始压力,可为空', - example: 120, + description: '初始压力挡位,可为空', + example: '1.5', }) @IsOptional() - @Type(() => Number) - @IsInt({ message: 'initialPressure 必须是整数' }) - @Min(0, { message: 'initialPressure 必须大于等于 0' }) - initialPressure?: number; + @IsString({ message: 'initialPressure 必须是字符串' }) + initialPressure?: string; @ApiPropertyOptional({ description: '植入物备注', diff --git a/src/tasks/b-tasks/b-tasks.controller.ts b/src/tasks/b-tasks/b-tasks.controller.ts index 0e7f8a8..6ed7ae5 100644 --- a/src/tasks/b-tasks/b-tasks.controller.ts +++ b/src/tasks/b-tasks/b-tasks.controller.ts @@ -1,5 +1,10 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; import type { ActorContext } from '../../common/actor-context.js'; import { CurrentActor } from '../../auth/current-actor.decorator.js'; import { AccessTokenGuard } from '../../auth/access-token.guard.js'; @@ -11,6 +16,8 @@ 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'; +import { TaskRecordQueryDto } from '../dto/task-record-query.dto.js'; +import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto.js'; /** * B 端任务控制器:封装调压任务状态流转接口。 @@ -23,21 +30,78 @@ export class BTasksController { constructor(private readonly taskService: TaskService) {} /** - * 医生/主任/组长发布调压任务。 + * 查询当前角色可指定的接收工程师列表。 + */ + @Get('engineers') + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + ) + @ApiOperation({ summary: '查询可选接收工程师列表' }) + @ApiQuery({ + name: 'hospitalId', + required: false, + description: '系统管理员可按医院筛选', + }) + findAssignableEngineers( + @CurrentActor() actor: ActorContext, + @Query() query: AssignableEngineerQueryDto, + ) { + return this.taskService.findAssignableEngineers(actor, query.hospitalId); + } + + /** + * 查询当前角色可见的调压记录列表。 + */ + @Get() + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + Role.ENGINEER, + ) + @ApiOperation({ summary: '查询调压记录列表' }) + @ApiQuery({ + name: 'hospitalId', + required: false, + description: '系统管理员可按医院筛选', + }) + findRecords( + @CurrentActor() actor: ActorContext, + @Query() query: TaskRecordQueryDto, + ) { + return this.taskService.findTaskRecords(actor, query); + } + + /** + * 系统管理员/医院管理员/医生/主任/组长发布调压任务。 */ @Post('publish') - @Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER) - @ApiOperation({ summary: '发布任务(DOCTOR/DIRECTOR/LEADER)' }) + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + ) + @ApiOperation({ + summary: '发布任务(SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER)', + }) publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) { return this.taskService.publishTask(actor, dto); } /** - * 工程师接收调压任务。 + * 工程师接收调压任务(当前流程已停用)。 */ @Post('accept') @Roles(Role.ENGINEER) - @ApiOperation({ summary: '接收任务(ENGINEER)' }) + @ApiOperation({ summary: '接收任务(已停用)' }) accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) { return this.taskService.acceptTask(actor, dto); } @@ -53,11 +117,19 @@ export class BTasksController { } /** - * 医生/主任/组长取消调压任务(仅任务创建者)。 + * 系统管理员/医院管理员/医生/主任/组长取消调压任务(仅任务创建者)。 */ @Post('cancel') - @Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER) - @ApiOperation({ summary: '取消任务(DOCTOR/DIRECTOR/LEADER)' }) + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + ) + @ApiOperation({ + summary: '取消任务(SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER)', + }) cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) { return this.taskService.cancelTask(actor, dto); } diff --git a/src/tasks/dto/assignable-engineer-query.dto.ts b/src/tasks/dto/assignable-engineer-query.dto.ts new file mode 100644 index 0000000..cf6a1e4 --- /dev/null +++ b/src/tasks/dto/assignable-engineer-query.dto.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Min } from 'class-validator'; +import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js'; + +/** + * 可指派工程师查询 DTO:系统管理员可显式传医院范围。 + */ +export class AssignableEngineerQueryDto { + @ApiPropertyOptional({ + description: '医院 ID,仅系统管理员可选传', + example: 1, + }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'hospitalId 必须是整数' }) + @Min(1, { message: 'hospitalId 必须大于 0' }) + hospitalId?: number; +} diff --git a/src/tasks/dto/publish-task.dto.ts b/src/tasks/dto/publish-task.dto.ts index 28a1cd6..4e3c81d 100644 --- a/src/tasks/dto/publish-task.dto.ts +++ b/src/tasks/dto/publish-task.dto.ts @@ -1,11 +1,10 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js'; import { ArrayMinSize, IsArray, IsInt, - IsOptional, + IsString, Min, ValidateNested, } from 'class-validator'; @@ -20,23 +19,20 @@ export class PublishTaskItemDto { @Min(1, { message: 'deviceId 必须大于 0' }) deviceId!: number; - @ApiProperty({ description: '目标压力值', example: 120 }) - @Type(() => Number) - @IsInt({ message: 'targetPressure 必须是整数' }) - targetPressure!: number; + @ApiProperty({ description: '目标挡位标签', example: '1.5' }) + @IsString({ message: 'targetPressure 必须是字符串' }) + targetPressure!: string; } /** * 发布任务 DTO。 */ export class PublishTaskDto { - @ApiPropertyOptional({ description: '指定工程师 ID(可选)', example: 2 }) - @IsOptional() - @EmptyStringToUndefined() + @ApiProperty({ description: '接收工程师 ID', example: 2 }) @Type(() => Number) @IsInt({ message: 'engineerId 必须是整数' }) @Min(1, { message: 'engineerId 必须大于 0' }) - engineerId?: number; + engineerId!: number; @ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' }) @IsArray({ message: 'items 必须是数组' }) diff --git a/src/tasks/dto/task-record-query.dto.ts b/src/tasks/dto/task-record-query.dto.ts new file mode 100644 index 0000000..756cd67 --- /dev/null +++ b/src/tasks/dto/task-record-query.dto.ts @@ -0,0 +1,63 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js'; +import { TaskStatus } from '../../generated/prisma/enums.js'; + +/** + * 调压记录查询 DTO:用于后台任务记录页筛选。 + */ +export class TaskRecordQueryDto { + @ApiPropertyOptional({ + description: '关键词(支持患者姓名/住院号/手机号/植入物名称/植入物型号)', + example: '张三', + }) + @IsOptional() + @IsString({ message: 'keyword 必须是字符串' }) + keyword?: string; + + @ApiPropertyOptional({ + description: '任务状态', + enum: TaskStatus, + example: TaskStatus.PENDING, + }) + @IsOptional() + @IsEnum(TaskStatus, { message: 'status 枚举值不合法' }) + status?: TaskStatus; + + @ApiPropertyOptional({ + description: '医院 ID,仅系统管理员可选传', + example: 1, + }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'hospitalId 必须是整数' }) + @Min(1, { message: 'hospitalId 必须大于 0' }) + hospitalId?: number; + + @ApiPropertyOptional({ + description: '页码(默认 1)', + example: 1, + default: 1, + }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'page 必须是整数' }) + @Min(1, { message: 'page 最小为 1' }) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量(默认 20,最大 100)', + example: 20, + default: 20, + }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'pageSize 必须是整数' }) + @Min(1, { message: 'pageSize 最小为 1' }) + @Max(100, { message: 'pageSize 最大为 100' }) + pageSize?: number = 20; +} diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts index 70cc53b..b404b8f 100644 --- a/src/tasks/task.service.ts +++ b/src/tasks/task.service.ts @@ -6,6 +6,7 @@ import { NotFoundException, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Prisma } from '../generated/prisma/client.js'; import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js'; import { PrismaService } from '../prisma.service.js'; import type { ActorContext } from '../common/actor-context.js'; @@ -13,7 +14,9 @@ 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'; +import { TaskRecordQueryDto } from './dto/task-record-query.dto.js'; import { MESSAGES } from '../common/messages.js'; +import { normalizePressureLabel } from '../common/pressure-level.util.js'; /** * 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。 @@ -26,11 +29,162 @@ export class TaskService { ) {} /** - * 发布任务:医生/主任/组长创建主任务与明细,状态初始化为 PENDING。 + * 查询当前角色可指定的接收工程师列表。 + */ + async findAssignableEngineers( + actor: ActorContext, + requestedHospitalId?: number, + ) { + this.assertRole(actor, [ + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + ]); + + const hospitalId = this.resolveAssignableHospitalId( + actor, + requestedHospitalId, + ); + return this.prisma.user.findMany({ + where: { + role: Role.ENGINEER, + hospitalId, + }, + select: { + id: true, + name: true, + phone: true, + hospitalId: true, + }, + orderBy: { id: 'desc' }, + }); + } + + /** + * 查询当前角色可见的调压记录列表。 + */ + async findTaskRecords(actor: ActorContext, query: TaskRecordQueryDto) { + this.assertRole(actor, [ + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + Role.ENGINEER, + ]); + + const hospitalId = this.resolveListHospitalId(actor, query.hospitalId); + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + const where = this.buildTaskRecordWhere(query, hospitalId); + + const [total, items] = await Promise.all([ + this.prisma.taskItem.count({ where }), + this.prisma.taskItem.findMany({ + where, + skip, + take: pageSize, + orderBy: { id: 'desc' }, + select: { + id: true, + oldPressure: true, + targetPressure: true, + task: { + select: { + id: true, + status: true, + createdAt: true, + hospital: { + select: { + id: true, + name: true, + }, + }, + creator: { + select: { + id: true, + name: true, + role: true, + }, + }, + engineer: { + select: { + id: true, + name: true, + role: true, + }, + }, + }, + }, + device: { + select: { + id: true, + currentPressure: true, + implantModel: true, + implantManufacturer: true, + implantName: true, + patient: { + select: { + id: true, + name: true, + inpatientNo: true, + phone: true, + }, + }, + surgery: { + select: { + id: true, + surgeryName: true, + surgeryDate: true, + }, + }, + }, + }, + }, + }), + ]); + + return { + list: items.map((item) => ({ + id: item.id, + oldPressure: item.oldPressure, + targetPressure: item.targetPressure, + currentPressure: item.device.currentPressure, + taskId: item.task.id, + status: item.task.status, + createdAt: item.task.createdAt, + hospital: item.task.hospital, + creator: item.task.creator, + engineer: item.task.engineer, + patient: item.device.patient, + surgery: item.device.surgery, + device: { + id: item.device.id, + implantModel: item.device.implantModel, + implantManufacturer: item.device.implantManufacturer, + implantName: item.device.implantName, + }, + })), + total, + page, + pageSize, + }; + } + + /** + * 发布任务:管理员或临床角色创建主任务与明细,并直接指定接收工程师。 */ async publishTask(actor: ActorContext, dto: PublishTaskDto) { - this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]); - const hospitalId = this.requireHospitalId(actor); + this.assertRole(actor, [ + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + ]); if (!Array.isArray(dto.items) || dto.items.length === 0) { throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED); @@ -42,27 +196,32 @@ export class TaskService { if (!Number.isInteger(item.deviceId)) { throw new BadRequestException(`deviceId 非法: ${item.deviceId}`); } - if (!Number.isInteger(item.targetPressure)) { - throw new BadRequestException( - `targetPressure 非法: ${item.targetPressure}`, - ); - } return item.deviceId; }), ), ); + const scopedHospitalId = this.resolveScopedHospitalId(actor); const devices = await this.prisma.device.findMany({ where: { id: { in: deviceIds }, status: DeviceStatus.ACTIVE, isAbandoned: false, isPressureAdjustable: true, - patient: { hospitalId }, + patient: scopedHospitalId + ? { + hospitalId: scopedHospitalId, + } + : undefined, }, select: { id: true, currentPressure: true, + patient: { + select: { + hospitalId: true, + }, + }, implantCatalog: { select: { pressureLevels: true, @@ -75,18 +234,21 @@ export class TaskService { throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND); } - 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(MESSAGES.TASK.ENGINEER_INVALID); - } + const hospitalId = this.resolveTaskHospitalId( + actor, + devices.map((device) => device.patient.hospitalId), + ); + + const engineer = await this.prisma.user.findFirst({ + where: { + id: dto.engineerId, + role: Role.ENGINEER, + hospitalId, + }, + select: { id: true }, + }); + if (!engineer) { + throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID); } const pressureByDeviceId = new Map( @@ -102,25 +264,30 @@ export class TaskService { ); dto.items.forEach((item) => { + const normalizedTargetPressure = this.normalizeTargetPressure( + item.targetPressure, + ); const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? []; if ( pressureLevels.length > 0 && - !pressureLevels.includes(item.targetPressure) + !pressureLevels.includes(normalizedTargetPressure) ) { throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID); } + + item.targetPressure = normalizedTargetPressure; }); const task = await this.prisma.task.create({ data: { - status: TaskStatus.PENDING, + status: TaskStatus.ACCEPTED, creatorId: actor.id, - engineerId: dto.engineerId ?? null, + engineerId: engineer.id, hospitalId, items: { create: dto.items.map((item) => ({ deviceId: item.deviceId, - oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0, + oldPressure: pressureByDeviceId.get(item.deviceId) ?? '0', targetPressure: item.targetPressure, })), }, @@ -139,68 +306,10 @@ export class TaskService { } /** - * 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。 + * 接收任务:当前流程已停用,统一改为发布时直接指定接收工程师。 */ - 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(MESSAGES.TASK.TASK_NOT_FOUND); - } - if (task.status !== TaskStatus.PENDING) { - throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING); - } - if (task.engineerId != null && task.engineerId !== actor.id) { - throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED); - } - - const accepted = await this.prisma.task.updateMany({ - where: { - id: task.id, - hospitalId, - status: TaskStatus.PENDING, - OR: [{ engineerId: null }, { engineerId: actor.id }], - }, - data: { - status: TaskStatus.ACCEPTED, - engineerId: actor.id, - }, - }); - - if (accepted.count !== 1) { - throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING); - } - - const updatedTask = await this.prisma.task.findUnique({ - where: { id: task.id }, - include: { items: true }, - }); - if (!updatedTask) { - throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND); - } - - await this.eventEmitter.emitAsync('task.accepted', { - taskId: updatedTask.id, - hospitalId: updatedTask.hospitalId, - actorId: actor.id, - status: updatedTask.status, - }); - - return updatedTask; + async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) { + throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED); } /** @@ -263,13 +372,19 @@ export class TaskService { * 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。 */ async cancelTask(actor: ActorContext, dto: CancelTaskDto) { - this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]); - const hospitalId = this.requireHospitalId(actor); + this.assertRole(actor, [ + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DOCTOR, + Role.DIRECTOR, + Role.LEADER, + ]); + const scopedHospitalId = this.resolveScopedHospitalId(actor); const task = await this.prisma.task.findFirst({ where: { id: dto.taskId, - hospitalId, + hospitalId: scopedHospitalId ?? undefined, }, select: { id: true, @@ -320,7 +435,154 @@ export class TaskService { } /** - * 校验并返回 hospitalId(B 端强依赖租户隔离)。 + * 返回角色可见的医院范围。系统管理员可不绑定医院,按目标设备自动归院。 + */ + private resolveScopedHospitalId(actor: ActorContext): number | null { + if (actor.role === Role.SYSTEM_ADMIN) { + return actor.hospitalId ?? null; + } + if (!actor.hospitalId) { + throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED); + } + return actor.hospitalId; + } + + /** + * 解析本次任务归属医院,确保同一批设备不跨院。 + */ + private resolveTaskHospitalId( + actor: ActorContext, + hospitalIds: number[], + ): number { + const uniqueHospitalIds = Array.from( + new Set(hospitalIds.filter((hospitalId) => Number.isInteger(hospitalId))), + ); + + if (uniqueHospitalIds.length !== 1) { + throw new BadRequestException(MESSAGES.TASK.DEVICE_MULTI_HOSPITAL); + } + + const [hospitalId] = uniqueHospitalIds; + if (actor.hospitalId && actor.hospitalId !== hospitalId) { + throw new ForbiddenException(MESSAGES.TASK.DEVICE_NOT_FOUND); + } + + return hospitalId; + } + + /** + * 解析工程师指派范围。系统管理员可显式指定医院,其余角色固定本院。 + */ + private resolveAssignableHospitalId( + actor: ActorContext, + requestedHospitalId?: number, + ) { + if (actor.role === Role.SYSTEM_ADMIN) { + if (requestedHospitalId !== undefined) { + return requestedHospitalId; + } + + return this.requireHospitalId(actor); + } + + return this.requireHospitalId(actor); + } + + /** + * 列表查询的医院范围解析。系统管理员可按查询条件切院,其余角色固定本院。 + */ + private resolveListHospitalId( + actor: ActorContext, + requestedHospitalId?: number, + ) { + if (actor.role === Role.SYSTEM_ADMIN) { + return requestedHospitalId ?? actor.hospitalId ?? undefined; + } + + return this.requireHospitalId(actor); + } + + /** + * 构造调压记录查询条件。 + */ + private buildTaskRecordWhere( + query: TaskRecordQueryDto, + hospitalId?: number, + ): Prisma.TaskItemWhereInput { + const keyword = query.keyword?.trim(); + + const where: Prisma.TaskItemWhereInput = { + task: { + hospitalId, + status: query.status, + }, + }; + + if (!keyword) { + return where; + } + + where.OR = [ + { + device: { + patient: { + name: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + }, + { + device: { + patient: { + inpatientNo: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + }, + { + device: { + patient: { + phone: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + }, + { + device: { + implantName: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + { + device: { + implantModel: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + ]; + + return where; + } + + /** + * 调压目标挡位标准化。 + */ + private normalizeTargetPressure(value: unknown) { + return normalizePressureLabel(value, 'targetPressure'); + } + + /** + * 工程师侧任务流转仍要求明确的院内身份。 */ private requireHospitalId(actor: ActorContext): number { if (!actor.hospitalId) { diff --git a/src/uploads/b-uploads/b-uploads.controller.ts b/src/uploads/b-uploads/b-uploads.controller.ts new file mode 100644 index 0000000..d277e20 --- /dev/null +++ b/src/uploads/b-uploads/b-uploads.controller.ts @@ -0,0 +1,133 @@ +import { + BadRequestException, + Body, + Controller, + Get, + ParseIntPipe, + Post, + Query, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AccessTokenGuard } from '../../auth/access-token.guard.js'; +import { CurrentActor } from '../../auth/current-actor.decorator.js'; +import { Roles } from '../../auth/roles.decorator.js'; +import { RolesGuard } from '../../auth/roles.guard.js'; +import type { ActorContext } from '../../common/actor-context.js'; +import { Role } from '../../generated/prisma/enums.js'; +import { MESSAGES } from '../../common/messages.js'; +import { UploadAssetQueryDto } from '../dto/upload-asset-query.dto.js'; +import { ensureUploadDirectories, resolveUploadTempDir } from '../upload-path.util.js'; +import { UploadsService } from '../uploads.service.js'; +import { diskStorage } from 'multer'; +import { extname } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +const MAX_UPLOAD_SIZE = 1024 * 1024 * 200; + +function isAllowedMimeType(mimeType: string) { + return ( + mimeType.startsWith('image/') || + mimeType.startsWith('video/') || + [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ].includes(mimeType) + ); +} + +@ApiTags('上传资产(B端)') +@ApiBearerAuth('bearer') +@Controller('b/uploads') +@UseGuards(AccessTokenGuard, RolesGuard) +export class BUploadsController { + constructor(private readonly uploadsService: UploadsService) {} + + /** + * 上传图片/视频/文件。 + */ + @Post() + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DIRECTOR, + Role.LEADER, + Role.DOCTOR, + ) + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: (_req, _file, cb) => { + ensureUploadDirectories(); + cb(null, resolveUploadTempDir()); + }, + filename: (_req, file, cb) => { + cb(null, `${randomUUID()}${extname(file.originalname)}`); + }, + }), + limits: { fileSize: MAX_UPLOAD_SIZE }, + fileFilter: (_req, file, cb) => { + if (!isAllowedMimeType(file.mimetype)) { + cb( + new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE), + false, + ); + return; + } + cb(null, true); + }, + }), + ) + @ApiOperation({ summary: '上传图片/视频/文件' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + hospitalId: { + type: 'integer', + }, + }, + required: ['file'], + }, + }) + create( + @CurrentActor() actor: ActorContext, + @UploadedFile() file?: Express.Multer.File, + @Body('hospitalId') hospitalId?: string, + ) { + const requestedHospitalId = + hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId); + + return this.uploadsService.createUpload(actor, file, requestedHospitalId); + } + + /** + * 查询图库/视频库/文件库。 + */ + @Get() + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) + @ApiOperation({ summary: '查询上传资产列表' }) + findAll( + @CurrentActor() actor: ActorContext, + @Query() query: UploadAssetQueryDto, + ) { + return this.uploadsService.findAll(actor, query); + } +} diff --git a/src/uploads/dto/upload-asset-query.dto.ts b/src/uploads/dto/upload-asset-query.dto.ts new file mode 100644 index 0000000..b294379 --- /dev/null +++ b/src/uploads/dto/upload-asset-query.dto.ts @@ -0,0 +1,63 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js'; +import { UploadAssetType } from '../../generated/prisma/enums.js'; +import { + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +/** + * 上传资产查询 DTO:用于图库/视频库分页筛选。 + */ +export class UploadAssetQueryDto { + @ApiPropertyOptional({ + description: '关键词(按原始文件名模糊匹配)', + example: 'ct', + }) + @IsOptional() + @IsString({ message: 'keyword 必须是字符串' }) + keyword?: string; + + @ApiPropertyOptional({ + description: '资产类型', + enum: UploadAssetType, + example: UploadAssetType.IMAGE, + }) + @IsOptional() + @IsEnum(UploadAssetType, { message: 'type 枚举值不合法' }) + type?: UploadAssetType; + + @ApiPropertyOptional({ description: '医院 ID(SYSTEM_ADMIN 可选)', example: 1 }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'hospitalId 必须是整数' }) + @Min(1, { message: 'hospitalId 必须大于 0' }) + hospitalId?: number; + + @ApiPropertyOptional({ description: '页码', example: 1, default: 1 }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'page 必须是整数' }) + @Min(1, { message: 'page 最小为 1' }) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量', + example: 20, + default: 20, + }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'pageSize 必须是整数' }) + @Min(1, { message: 'pageSize 最小为 1' }) + @Max(100, { message: 'pageSize 最大为 100' }) + pageSize?: number = 20; +} diff --git a/src/uploads/upload-path.util.ts b/src/uploads/upload-path.util.ts new file mode 100644 index 0000000..8f3f96f --- /dev/null +++ b/src/uploads/upload-path.util.ts @@ -0,0 +1,18 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * 上传目录工具:统一管理公开文件目录与临时目录。 + */ +export function resolveUploadRootDir() { + return join(process.cwd(), 'storage', 'uploads'); +} + +export function resolveUploadTempDir() { + return join(process.cwd(), 'storage', 'tmp-uploads'); +} + +export function ensureUploadDirectories() { + mkdirSync(resolveUploadRootDir(), { recursive: true }); + mkdirSync(resolveUploadTempDir(), { recursive: true }); +} diff --git a/src/uploads/uploads.module.ts b/src/uploads/uploads.module.ts new file mode 100644 index 0000000..e1863dd --- /dev/null +++ b/src/uploads/uploads.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma.module.js'; +import { BUploadsController } from './b-uploads/b-uploads.controller.js'; +import { UploadsService } from './uploads.service.js'; + +@Module({ + imports: [PrismaModule], + controllers: [BUploadsController], + providers: [UploadsService], + exports: [UploadsService], +}) +export class UploadsModule {} diff --git a/src/uploads/uploads.service.spec.ts b/src/uploads/uploads.service.spec.ts new file mode 100644 index 0000000..3dbd0b1 --- /dev/null +++ b/src/uploads/uploads.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UploadsService } from './uploads.service'; + +describe('UploadsService', () => { + let service: UploadsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UploadsService], + }).compile(); + + service = module.get(UploadsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/uploads/uploads.service.ts b/src/uploads/uploads.service.ts new file mode 100644 index 0000000..11221f6 --- /dev/null +++ b/src/uploads/uploads.service.ts @@ -0,0 +1,383 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { spawn } from 'node:child_process'; +import { access, mkdir, rename, stat, unlink } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import ffmpegPath from 'ffmpeg-static'; +import sharp from 'sharp'; +import { Prisma } from '../generated/prisma/client.js'; +import { Role, UploadAssetType } from '../generated/prisma/enums.js'; +import type { ActorContext } from '../common/actor-context.js'; +import { MESSAGES } from '../common/messages.js'; +import { PrismaService } from '../prisma.service.js'; +import { UploadAssetQueryDto } from './dto/upload-asset-query.dto.js'; +import { + ensureUploadDirectories, + resolveUploadRootDir, + resolveUploadTempDir, +} from './upload-path.util.js'; + +@Injectable() +export class UploadsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 创建上传资产记录并落盘到公开目录。 + */ + async createUpload( + actor: ActorContext, + file: Express.Multer.File | undefined, + requestedHospitalId?: number, + ) { + if (!file) { + throw new BadRequestException(MESSAGES.UPLOAD.FILE_REQUIRED); + } + + const hospitalId = await this.resolveHospitalScope(actor, requestedHospitalId); + const type = this.detectAssetType(file.mimetype); + ensureUploadDirectories(); + const prepared = await this.prepareStoredFile(file, type); + const date = new Date(); + const relativeDir = join( + `${date.getFullYear()}`, + `${date.getMonth() + 1}`.padStart(2, '0'), + `${date.getDate()}`.padStart(2, '0'), + ); + const absoluteDir = join(resolveUploadRootDir(), relativeDir); + await mkdir(absoluteDir, { recursive: true }); + + const finalFileName = await this.buildFinalFileName( + absoluteDir, + file.originalname, + prepared.outputExtension, + date, + ); + const relativePath = join(relativeDir, finalFileName); + const absolutePath = join(resolveUploadRootDir(), relativePath); + + try { + await rename(prepared.tempPath, absolutePath); + if (prepared.tempPath !== file.path) { + await this.safeUnlink(file.path); + } + } catch (error) { + await this.safeUnlink(file.path); + await this.safeUnlink(prepared.tempPath); + throw error; + } + + const url = `/${relativePath.replace(/\\/g, '/')}`.replace(/^\/+/, '/uploads/'); + + try { + return await this.prisma.uploadAsset.create({ + data: { + hospitalId, + creatorId: actor.id, + type, + originalName: file.originalname, + fileName: finalFileName, + storagePath: relativePath.replace(/\\/g, '/'), + url, + mimeType: prepared.mimeType, + fileSize: prepared.fileSize, + }, + include: { + hospital: { select: { id: true, name: true } }, + creator: { select: { id: true, name: true, role: true } }, + }, + }); + } catch (error) { + await this.safeUnlink(absolutePath); + throw error; + } + } + + /** + * 查询上传资产列表:按医院作用域隔离。 + */ + async findAll(actor: ActorContext, query: UploadAssetQueryDto) { + const page = query.page && query.page > 0 ? query.page : 1; + const pageSize = + query.pageSize && query.pageSize > 0 && query.pageSize <= 100 + ? query.pageSize + : 20; + + const where: Prisma.UploadAssetWhereInput = {}; + const scopedHospitalId = this.resolveReadableHospitalScope(actor, query.hospitalId); + if (scopedHospitalId != null) { + where.hospitalId = scopedHospitalId; + } + if (query.type) { + where.type = query.type; + } + if (query.keyword?.trim()) { + where.originalName = { + contains: query.keyword.trim(), + mode: 'insensitive', + }; + } + + const [total, list] = await this.prisma.$transaction([ + this.prisma.uploadAsset.count({ where }), + this.prisma.uploadAsset.findMany({ + where, + include: { + hospital: { select: { id: true, name: true } }, + creator: { select: { id: true, name: true, role: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + + return { total, page, pageSize, list }; + } + + private detectAssetType(mimeType: string) { + if (mimeType.startsWith('image/')) { + return UploadAssetType.IMAGE; + } + if (mimeType.startsWith('video/')) { + return UploadAssetType.VIDEO; + } + return UploadAssetType.FILE; + } + + private async resolveHospitalScope( + actor: ActorContext, + requestedHospitalId?: number, + ) { + if (actor.role === Role.SYSTEM_ADMIN) { + if ( + requestedHospitalId == null || + !Number.isInteger(requestedHospitalId) || + requestedHospitalId <= 0 + ) { + throw new BadRequestException( + MESSAGES.UPLOAD.SYSTEM_ADMIN_HOSPITAL_REQUIRED, + ); + } + + const hospital = await this.prisma.hospital.findUnique({ + where: { id: requestedHospitalId }, + select: { id: true }, + }); + if (!hospital) { + throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND); + } + return hospital.id; + } + + if (!actor.hospitalId) { + throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED); + } + return actor.hospitalId; + } + + private async prepareStoredFile( + file: Express.Multer.File, + type: UploadAssetType, + ) { + const baseName = randomUUID(); + + if (type === UploadAssetType.IMAGE) { + const tempPath = join(resolveUploadTempDir(), `${baseName}.webp`); + try { + await sharp(file.path) + .rotate() + .resize({ + width: 2560, + height: 2560, + fit: 'inside', + withoutEnlargement: true, + }) + .webp({ + quality: 82, + effort: 4, + }) + .toFile(tempPath); + } catch { + await this.safeUnlink(tempPath); + throw new BadRequestException(MESSAGES.UPLOAD.INVALID_IMAGE_FILE); + } + + const fileInfo = await stat(tempPath); + return { + tempPath, + outputExtension: '.webp', + mimeType: 'image/webp', + fileSize: fileInfo.size, + }; + } + + if (type === UploadAssetType.VIDEO) { + const tempPath = join(resolveUploadTempDir(), `${baseName}.mp4`); + try { + await this.compressVideo(file.path, tempPath); + } catch (error) { + await this.safeUnlink(tempPath); + if (error instanceof BadRequestException) { + throw error; + } + throw new BadRequestException(MESSAGES.UPLOAD.INVALID_VIDEO_FILE); + } + + const fileInfo = await stat(tempPath); + return { + tempPath, + outputExtension: '.mp4', + mimeType: 'video/mp4', + fileSize: fileInfo.size, + }; + } + + return { + tempPath: file.path, + outputExtension: extname(file.originalname), + mimeType: file.mimetype, + fileSize: file.size, + }; + } + + private async buildFinalFileName( + absoluteDir: string, + originalName: string, + outputExtension: string, + date: Date, + ) { + const timestamp = this.formatDateToSecond(date); + const originalBaseName = + this.normalizeOriginalBaseName(originalName) || 'upload-file'; + const extension = outputExtension || extname(originalName) || ''; + const baseFileName = `${timestamp}-${originalBaseName}`; + let candidate = `${baseFileName}${extension}`; + let duplicateIndex = 1; + + while (await this.fileExists(join(absoluteDir, candidate))) { + candidate = `${baseFileName}-${duplicateIndex}${extension}`; + duplicateIndex += 1; + } + + return candidate; + } + + private normalizeOriginalBaseName(originalName: string) { + const rawBaseName = originalName.replace(/\.[^.]+$/, ''); + const sanitized = rawBaseName + .normalize('NFKC') + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-') + .replace(/\s+/g, ' ') + .trim() + .replace(/-+/g, '-'); + + return sanitized || 'upload-file'; + } + + private formatDateToSecond(date: Date) { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + + return `${year}${month}${day}${hours}${minutes}${seconds}`; + } + + private async fileExists(path: string) { + try { + await access(path); + return true; + } catch { + return false; + } + } + + private async compressVideo(inputPath: string, outputPath: string) { + const command = ffmpegPath as unknown as string | null; + if (!command) { + throw new InternalServerErrorException(MESSAGES.UPLOAD.FFMPEG_NOT_AVAILABLE); + } + + const args = [ + '-y', + '-i', + inputPath, + '-map', + '0:v:0', + '-map', + '0:a?', + '-vf', + "scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(iw,ih),-2,min(1280,ih))'", + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '29', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + '-c:a', + 'aac', + '-b:a', + '128k', + outputPath, + ]; + + await new Promise((resolve, reject) => { + const child = spawn(command, args); + let stderr = ''; + + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject( + new BadRequestException( + stderr.trim() || MESSAGES.UPLOAD.INVALID_VIDEO_FILE, + ), + ); + }); + }); + } + + private resolveReadableHospitalScope( + actor: ActorContext, + requestedHospitalId?: number, + ) { + if (actor.role === Role.SYSTEM_ADMIN) { + return requestedHospitalId && requestedHospitalId > 0 + ? requestedHospitalId + : null; + } + + if (!actor.hospitalId) { + throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED); + } + return actor.hospitalId; + } + + private async safeUnlink(path: string | undefined) { + if (!path) { + return; + } + try { + await unlink(path); + } catch { + // 临时文件清理失败不影响主流程,避免吞掉原始错误。 + } + } +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index faf38ef..1d2328f 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -38,7 +38,7 @@ export class UsersController { * 创建用户。 */ @Post() - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '创建用户' }) create( @CurrentActor() actor: ActorContext, @@ -61,7 +61,7 @@ export class UsersController { * 查询用户详情。 */ @Get(':id') - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '查询用户详情' }) @ApiParam({ name: 'id', description: '用户 ID' }) findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) { @@ -72,7 +72,7 @@ export class UsersController { * 更新用户。 */ @Patch(':id') - @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '更新用户' }) @ApiParam({ name: 'id', description: '用户 ID' }) update( @@ -87,7 +87,7 @@ export class UsersController { * 删除用户。 */ @Delete(':id') - @Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '删除用户' }) @ApiParam({ name: 'id', description: '用户 ID' }) remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) { diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 594de0c..94c0a28 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -30,6 +30,9 @@ const SAFE_USER_SELECT = { groupId: true, } as const; +const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const; +const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const; + @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) {} @@ -202,15 +205,18 @@ export class UsersService { actor.hospitalId, MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, ); - } else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) { - where.hospitalId = this.requireActorScopeInt( - actor.hospitalId, - MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, - ); + } else if (actor.role === Role.DIRECTOR) { where.departmentId = this.requireActorScopeInt( actor.departmentId, MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED, ); + where.role = { in: [...DIRECTOR_VISIBLE_ROLES] }; + } else if (actor.role === Role.LEADER) { + where.groupId = this.requireActorScopeInt( + actor.groupId, + MESSAGES.ORG.ACTOR_GROUP_REQUIRED, + ); + where.role = { in: [...LEADER_VISIBLE_ROLES] }; } else if (actor.role !== Role.SYSTEM_ADMIN) { throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); } @@ -682,36 +688,7 @@ export class UsersService { } if (actor.role !== Role.HOSPITAL_ADMIN) { - if (actor.role !== Role.DIRECTOR) { - throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); - } - - // 科室主任仅允许创建本科室医生。 - if (targetRole !== Role.DOCTOR) { - throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); - } - - const actorHospitalId = this.requireActorScopeInt( - actor.hospitalId, - MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, - ); - const actorDepartmentId = this.requireActorScopeInt( - actor.departmentId, - MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED, - ); - - if (hospitalId != null && hospitalId !== actorHospitalId) { - throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); - } - if (departmentId != null && departmentId !== actorDepartmentId) { - throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); - } - - return { - hospitalId: actorHospitalId, - departmentId: actorDepartmentId, - groupId, - }; + throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); } if ( @@ -740,7 +717,7 @@ export class UsersService { } /** - * 读取权限:系统管理员可读全量;院管可读本院;主任可读本科室医生。 + * 读取权限:系统管理员可读全量;院管可读本院。 */ private assertUserReadable( actor: ActorContext, @@ -749,6 +726,7 @@ export class UsersService { role: Role; hospitalId: number | null; departmentId: number | null; + groupId: number | null; }, ) { if (actor.role === Role.SYSTEM_ADMIN) { @@ -769,30 +747,36 @@ export class UsersService { } if (actor.role === Role.DIRECTOR) { - const actorHospitalId = this.requireActorScopeInt( - actor.hospitalId, - MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, - ); const actorDepartmentId = this.requireActorScopeInt( actor.departmentId, MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED, ); if ( - target.role === Role.DOCTOR && - target.hospitalId === actorHospitalId && - target.departmentId === actorDepartmentId + target.departmentId === actorDepartmentId && + (DIRECTOR_VISIBLE_ROLES as readonly Role[]).includes(target.role) ) { return; } + } - throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN); + if (actor.role === Role.LEADER) { + const actorGroupId = this.requireActorScopeInt( + actor.groupId, + MESSAGES.ORG.ACTOR_GROUP_REQUIRED, + ); + if ( + target.groupId === actorGroupId && + (LEADER_VISIBLE_ROLES as readonly Role[]).includes(target.role) + ) { + return; + } } throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); } /** - * 写权限:院管可写本院非管理员账号;主任仅可写本科室医生。 + * 写权限:院管可写本院非管理员账号。 */ private assertUserWritable( actor: ActorContext, @@ -807,25 +791,6 @@ export class UsersService { return; } - if (actor.role === Role.DIRECTOR) { - const actorHospitalId = this.requireActorScopeInt( - actor.hospitalId, - MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, - ); - const actorDepartmentId = this.requireActorScopeInt( - actor.departmentId, - MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED, - ); - if ( - target.role !== Role.DOCTOR || - target.hospitalId !== actorHospitalId || - target.departmentId !== actorDepartmentId - ) { - throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN); - } - return; - } - if (actor.role !== Role.HOSPITAL_ADMIN) { throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); } @@ -857,10 +822,6 @@ export class UsersService { return; } - if (actor.role === Role.DIRECTOR && nextRole !== Role.DOCTOR) { - throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN); - } - if ( actor.role === Role.HOSPITAL_ADMIN && (nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN) @@ -878,17 +839,6 @@ export class UsersService { actor: ActorContext, hospitalId: number | null, ) { - if (actor.role === Role.DIRECTOR) { - const actorHospitalId = this.requireActorScopeInt( - actor.hospitalId, - MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, - ); - if (hospitalId !== actorHospitalId) { - throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN); - } - return; - } - if (actor.role !== Role.HOSPITAL_ADMIN) { return; } @@ -911,17 +861,9 @@ export class UsersService { actor: ActorContext, departmentId: number | null, ) { - if (actor.role !== Role.DIRECTOR) { + if (actor.role !== Role.HOSPITAL_ADMIN) { return; } - - const actorDepartmentId = this.requireActorScopeInt( - actor.departmentId, - MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED, - ); - if (departmentId !== actorDepartmentId) { - throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN); - } } /** diff --git a/storage/tmp-uploads/0bdc181b-5a5f-4911-89ca-d5c56bee0626.png b/storage/tmp-uploads/0bdc181b-5a5f-4911-89ca-d5c56bee0626.png new file mode 100644 index 0000000..7567f6d Binary files /dev/null and b/storage/tmp-uploads/0bdc181b-5a5f-4911-89ca-d5c56bee0626.png differ diff --git a/storage/tmp-uploads/1f5bd907-8f7f-44e8-b1ed-499f6c5fd604.png b/storage/tmp-uploads/1f5bd907-8f7f-44e8-b1ed-499f6c5fd604.png new file mode 100644 index 0000000..a27279f Binary files /dev/null and b/storage/tmp-uploads/1f5bd907-8f7f-44e8-b1ed-499f6c5fd604.png differ diff --git a/storage/tmp-uploads/23530285-2dce-47e6-bb9a-9b0a5a5a9e4d.png b/storage/tmp-uploads/23530285-2dce-47e6-bb9a-9b0a5a5a9e4d.png new file mode 100644 index 0000000..7567f6d Binary files /dev/null and b/storage/tmp-uploads/23530285-2dce-47e6-bb9a-9b0a5a5a9e4d.png differ diff --git a/storage/tmp-uploads/39d2aeb7-9e1a-4576-8e52-d278aeabe6e0.png b/storage/tmp-uploads/39d2aeb7-9e1a-4576-8e52-d278aeabe6e0.png new file mode 100644 index 0000000..eb0e729 --- /dev/null +++ b/storage/tmp-uploads/39d2aeb7-9e1a-4576-8e52-d278aeabe6e0.png @@ -0,0 +1 @@ +upload-SYSTEM_ADMIN \ No newline at end of file diff --git a/storage/tmp-uploads/59692c02-df26-4da0-bffe-0142feb634e0.png b/storage/tmp-uploads/59692c02-df26-4da0-bffe-0142feb634e0.png new file mode 100644 index 0000000..a27279f Binary files /dev/null and b/storage/tmp-uploads/59692c02-df26-4da0-bffe-0142feb634e0.png differ diff --git a/storage/tmp-uploads/6bb38cd8-3ccf-4d43-8114-32759924c246.png b/storage/tmp-uploads/6bb38cd8-3ccf-4d43-8114-32759924c246.png new file mode 100644 index 0000000..a27279f Binary files /dev/null and b/storage/tmp-uploads/6bb38cd8-3ccf-4d43-8114-32759924c246.png differ diff --git a/storage/tmp-uploads/a789898a-215a-4ab6-b8dc-5bab6d040b90.png b/storage/tmp-uploads/a789898a-215a-4ab6-b8dc-5bab6d040b90.png new file mode 100644 index 0000000..a27279f Binary files /dev/null and b/storage/tmp-uploads/a789898a-215a-4ab6-b8dc-5bab6d040b90.png differ diff --git a/storage/tmp-uploads/b5c90100-1466-47f7-b765-063e05b0b32c.png b/storage/tmp-uploads/b5c90100-1466-47f7-b765-063e05b0b32c.png new file mode 100644 index 0000000..7567f6d Binary files /dev/null and b/storage/tmp-uploads/b5c90100-1466-47f7-b765-063e05b0b32c.png differ diff --git a/storage/tmp-uploads/be5cd629-4d00-4542-82b0-b1b43ce693fe.png b/storage/tmp-uploads/be5cd629-4d00-4542-82b0-b1b43ce693fe.png new file mode 100644 index 0000000..a27279f Binary files /dev/null and b/storage/tmp-uploads/be5cd629-4d00-4542-82b0-b1b43ce693fe.png differ diff --git a/storage/tmp-uploads/ce5177bc-3dfd-4874-b672-4bd90865841a.png b/storage/tmp-uploads/ce5177bc-3dfd-4874-b672-4bd90865841a.png new file mode 100644 index 0000000..7567f6d Binary files /dev/null and b/storage/tmp-uploads/ce5177bc-3dfd-4874-b672-4bd90865841a.png differ diff --git a/storage/tmp-uploads/d1551d21-3fb1-4478-9b86-9f33cd5ad724.png b/storage/tmp-uploads/d1551d21-3fb1-4478-9b86-9f33cd5ad724.png new file mode 100644 index 0000000..eb0e729 --- /dev/null +++ b/storage/tmp-uploads/d1551d21-3fb1-4478-9b86-9f33cd5ad724.png @@ -0,0 +1 @@ +upload-SYSTEM_ADMIN \ No newline at end of file diff --git a/storage/tmp-uploads/e3b5edd7-6087-4f8a-a059-af9f720c26ef.png b/storage/tmp-uploads/e3b5edd7-6087-4f8a-a059-af9f720c26ef.png new file mode 100644 index 0000000..c9d83be --- /dev/null +++ b/storage/tmp-uploads/e3b5edd7-6087-4f8a-a059-af9f720c26ef.png @@ -0,0 +1 @@ +fake-image-content \ No newline at end of file diff --git a/storage/tmp-uploads/ff32fdc5-a0d4-4652-a853-e3bc6115d8d6.png b/storage/tmp-uploads/ff32fdc5-a0d4-4652-a853-e3bc6115d8d6.png new file mode 100644 index 0000000..c9d83be --- /dev/null +++ b/storage/tmp-uploads/ff32fdc5-a0d4-4652-a853-e3bc6115d8d6.png @@ -0,0 +1 @@ +fake-image-content \ No newline at end of file diff --git a/storage/uploads/1/2026/03/20/05761514-b28b-4daf-a1c6-5c07d4bc85a9.webp b/storage/uploads/1/2026/03/20/05761514-b28b-4daf-a1c6-5c07d4bc85a9.webp new file mode 100644 index 0000000..893d611 Binary files /dev/null and b/storage/uploads/1/2026/03/20/05761514-b28b-4daf-a1c6-5c07d4bc85a9.webp differ diff --git a/storage/uploads/1/2026/03/20/476981fb-fc28-4e10-bb96-0d827288d308.webp b/storage/uploads/1/2026/03/20/476981fb-fc28-4e10-bb96-0d827288d308.webp new file mode 100644 index 0000000..669daa6 Binary files /dev/null and b/storage/uploads/1/2026/03/20/476981fb-fc28-4e10-bb96-0d827288d308.webp differ diff --git a/storage/uploads/1/2026/03/20/7f6001a9-0680-42a2-a439-acac7be66b42.png b/storage/uploads/1/2026/03/20/7f6001a9-0680-42a2-a439-acac7be66b42.png new file mode 100644 index 0000000..9b12e81 Binary files /dev/null and b/storage/uploads/1/2026/03/20/7f6001a9-0680-42a2-a439-acac7be66b42.png differ diff --git a/storage/uploads/2026/03/20/20260320031813-ct-image.webp b/storage/uploads/2026/03/20/20260320031813-ct-image.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/2026/03/20/20260320031813-ct-image.webp differ diff --git a/storage/uploads/2026/03/20/20260320031813-ct-video.mp4 b/storage/uploads/2026/03/20/20260320031813-ct-video.mp4 new file mode 100644 index 0000000..c407179 Binary files /dev/null and b/storage/uploads/2026/03/20/20260320031813-ct-video.mp4 differ diff --git a/storage/uploads/2026/03/20/20260320031813-director.webp b/storage/uploads/2026/03/20/20260320031813-director.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/2026/03/20/20260320031813-director.webp differ diff --git a/storage/uploads/2026/03/20/20260320031813-hospital_admin.webp b/storage/uploads/2026/03/20/20260320031813-hospital_admin.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/2026/03/20/20260320031813-hospital_admin.webp differ diff --git a/storage/uploads/2026/03/20/20260320031813-seed-image.webp b/storage/uploads/2026/03/20/20260320031813-seed-image.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/2026/03/20/20260320031813-seed-image.webp differ diff --git a/storage/uploads/2026/03/20/20260320031814-doctor.webp b/storage/uploads/2026/03/20/20260320031814-doctor.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/2026/03/20/20260320031814-doctor.webp differ diff --git a/storage/uploads/2026/03/20/20260320031814-leader.webp b/storage/uploads/2026/03/20/20260320031814-leader.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/2026/03/20/20260320031814-leader.webp differ diff --git a/storage/uploads/2026/03/20/20260320034659-Steins;Gate [Streaming] 2026_3_15 20_18_36.webp b/storage/uploads/2026/03/20/20260320034659-Steins;Gate [Streaming] 2026_3_15 20_18_36.webp new file mode 100644 index 0000000..ed822a9 Binary files /dev/null and b/storage/uploads/2026/03/20/20260320034659-Steins;Gate [Streaming] 2026_3_15 20_18_36.webp differ diff --git a/storage/uploads/2026/03/20/20260320041422-NewBie-Image-Exp0.1_00001_.webp b/storage/uploads/2026/03/20/20260320041422-NewBie-Image-Exp0.1_00001_.webp new file mode 100644 index 0000000..52c03bb Binary files /dev/null and b/storage/uploads/2026/03/20/20260320041422-NewBie-Image-Exp0.1_00001_.webp differ diff --git a/storage/uploads/3/2026/03/20/11b9c47a-1295-4d11-b5ac-0dd50b8c9a55.webp b/storage/uploads/3/2026/03/20/11b9c47a-1295-4d11-b5ac-0dd50b8c9a55.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/3/2026/03/20/11b9c47a-1295-4d11-b5ac-0dd50b8c9a55.webp differ diff --git a/storage/uploads/3/2026/03/20/132830ed-779c-4401-a873-8ea8ef750cac.png b/storage/uploads/3/2026/03/20/132830ed-779c-4401-a873-8ea8ef750cac.png new file mode 100644 index 0000000..c925d39 --- /dev/null +++ b/storage/uploads/3/2026/03/20/132830ed-779c-4401-a873-8ea8ef750cac.png @@ -0,0 +1 @@ +upload-HOSPITAL_ADMIN \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/1f9b399d-f40e-439b-9cfe-c23d7a7c742c.png b/storage/uploads/3/2026/03/20/1f9b399d-f40e-439b-9cfe-c23d7a7c742c.png new file mode 100644 index 0000000..fe08060 --- /dev/null +++ b/storage/uploads/3/2026/03/20/1f9b399d-f40e-439b-9cfe-c23d7a7c742c.png @@ -0,0 +1 @@ +upload-DIRECTOR \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/31bd4f52-1505-4d6b-9096-0c36eaca75f1.webp b/storage/uploads/3/2026/03/20/31bd4f52-1505-4d6b-9096-0c36eaca75f1.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/3/2026/03/20/31bd4f52-1505-4d6b-9096-0c36eaca75f1.webp differ diff --git a/storage/uploads/3/2026/03/20/35e269ed-fc22-461d-b24b-af4fc2c1b1c1.png b/storage/uploads/3/2026/03/20/35e269ed-fc22-461d-b24b-af4fc2c1b1c1.png new file mode 100644 index 0000000..3f15ef0 --- /dev/null +++ b/storage/uploads/3/2026/03/20/35e269ed-fc22-461d-b24b-af4fc2c1b1c1.png @@ -0,0 +1 @@ +upload-LEADER \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/3de8e702-34d2-4dcb-8f91-34aa7911f4bc.webp b/storage/uploads/3/2026/03/20/3de8e702-34d2-4dcb-8f91-34aa7911f4bc.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/3/2026/03/20/3de8e702-34d2-4dcb-8f91-34aa7911f4bc.webp differ diff --git a/storage/uploads/3/2026/03/20/4c993c14-6bbd-489f-a6d3-f131c35f4894.mp4 b/storage/uploads/3/2026/03/20/4c993c14-6bbd-489f-a6d3-f131c35f4894.mp4 new file mode 100644 index 0000000..73551cf --- /dev/null +++ b/storage/uploads/3/2026/03/20/4c993c14-6bbd-489f-a6d3-f131c35f4894.mp4 @@ -0,0 +1 @@ +fake-video-content \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/4e934960-2c0c-42ab-8bf7-3c3b1d3aed77.png b/storage/uploads/3/2026/03/20/4e934960-2c0c-42ab-8bf7-3c3b1d3aed77.png new file mode 100644 index 0000000..c925d39 --- /dev/null +++ b/storage/uploads/3/2026/03/20/4e934960-2c0c-42ab-8bf7-3c3b1d3aed77.png @@ -0,0 +1 @@ +upload-HOSPITAL_ADMIN \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/73638602-5151-453b-97b4-d1ed9fab70b7.png b/storage/uploads/3/2026/03/20/73638602-5151-453b-97b4-d1ed9fab70b7.png new file mode 100644 index 0000000..3f15ef0 --- /dev/null +++ b/storage/uploads/3/2026/03/20/73638602-5151-453b-97b4-d1ed9fab70b7.png @@ -0,0 +1 @@ +upload-LEADER \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/79be072b-4733-48d5-85b5-db921ddf24b2.mp4 b/storage/uploads/3/2026/03/20/79be072b-4733-48d5-85b5-db921ddf24b2.mp4 new file mode 100644 index 0000000..c407179 Binary files /dev/null and b/storage/uploads/3/2026/03/20/79be072b-4733-48d5-85b5-db921ddf24b2.mp4 differ diff --git a/storage/uploads/3/2026/03/20/7d1ecf5f-1787-4c34-bf39-d70a8aa74b54.webp b/storage/uploads/3/2026/03/20/7d1ecf5f-1787-4c34-bf39-d70a8aa74b54.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/3/2026/03/20/7d1ecf5f-1787-4c34-bf39-d70a8aa74b54.webp differ diff --git a/storage/uploads/3/2026/03/20/8132d627-0d0c-4642-abea-fb9183881427.png b/storage/uploads/3/2026/03/20/8132d627-0d0c-4642-abea-fb9183881427.png new file mode 100644 index 0000000..c9d83be --- /dev/null +++ b/storage/uploads/3/2026/03/20/8132d627-0d0c-4642-abea-fb9183881427.png @@ -0,0 +1 @@ +fake-image-content \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/8d450301-8064-4920-b831-f47b1867758a.webp b/storage/uploads/3/2026/03/20/8d450301-8064-4920-b831-f47b1867758a.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/3/2026/03/20/8d450301-8064-4920-b831-f47b1867758a.webp differ diff --git a/storage/uploads/3/2026/03/20/8ef30d54-34a5-4c64-b107-a3c2df7a4b67.png b/storage/uploads/3/2026/03/20/8ef30d54-34a5-4c64-b107-a3c2df7a4b67.png new file mode 100644 index 0000000..70e64bf --- /dev/null +++ b/storage/uploads/3/2026/03/20/8ef30d54-34a5-4c64-b107-a3c2df7a4b67.png @@ -0,0 +1 @@ +upload-DOCTOR \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/92d0fda1-467b-4728-89ea-ab3d745c618b.png b/storage/uploads/3/2026/03/20/92d0fda1-467b-4728-89ea-ab3d745c618b.png new file mode 100644 index 0000000..fe08060 --- /dev/null +++ b/storage/uploads/3/2026/03/20/92d0fda1-467b-4728-89ea-ab3d745c618b.png @@ -0,0 +1 @@ +upload-DIRECTOR \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/939d2b20-9a10-4e0a-84f6-642d0149243e.mp4 b/storage/uploads/3/2026/03/20/939d2b20-9a10-4e0a-84f6-642d0149243e.mp4 new file mode 100644 index 0000000..c407179 Binary files /dev/null and b/storage/uploads/3/2026/03/20/939d2b20-9a10-4e0a-84f6-642d0149243e.mp4 differ diff --git a/storage/uploads/3/2026/03/20/a01f1065-a4c7-4d9f-8662-ea31f5904211.png b/storage/uploads/3/2026/03/20/a01f1065-a4c7-4d9f-8662-ea31f5904211.png new file mode 100644 index 0000000..c9d83be --- /dev/null +++ b/storage/uploads/3/2026/03/20/a01f1065-a4c7-4d9f-8662-ea31f5904211.png @@ -0,0 +1 @@ +fake-image-content \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/c1c8446b-3a45-4d22-aadc-6172f34f3d68.mp4 b/storage/uploads/3/2026/03/20/c1c8446b-3a45-4d22-aadc-6172f34f3d68.mp4 new file mode 100644 index 0000000..73551cf --- /dev/null +++ b/storage/uploads/3/2026/03/20/c1c8446b-3a45-4d22-aadc-6172f34f3d68.mp4 @@ -0,0 +1 @@ +fake-video-content \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/ca3f8d30-3b6c-4da4-8d6d-93a2809a0096.png b/storage/uploads/3/2026/03/20/ca3f8d30-3b6c-4da4-8d6d-93a2809a0096.png new file mode 100644 index 0000000..70e64bf --- /dev/null +++ b/storage/uploads/3/2026/03/20/ca3f8d30-3b6c-4da4-8d6d-93a2809a0096.png @@ -0,0 +1 @@ +upload-DOCTOR \ No newline at end of file diff --git a/storage/uploads/3/2026/03/20/e28ac240-15a6-4f29-8d99-efbfc4063897.webp b/storage/uploads/3/2026/03/20/e28ac240-15a6-4f29-8d99-efbfc4063897.webp new file mode 100644 index 0000000..a5e7684 Binary files /dev/null and b/storage/uploads/3/2026/03/20/e28ac240-15a6-4f29-8d99-efbfc4063897.webp differ diff --git a/test/e2e/helpers/e2e-auth.helper.ts b/test/e2e/helpers/e2e-auth.helper.ts index 8855ea6..7dc4b71 100644 --- a/test/e2e/helpers/e2e-auth.helper.ts +++ b/test/e2e/helpers/e2e-auth.helper.ts @@ -5,13 +5,23 @@ import { type E2ERole, E2E_SEED_CREDENTIALS, } from '../fixtures/e2e-roles.js'; +import type { E2ESeedFixtures } from './e2e-fixtures.helper.js'; import { expectSuccessEnvelope } from './e2e-http.helper.js'; export type E2EAccessTokenMap = Record; +function resolveRoleHospitalId(role: E2ERole, fixtures: E2ESeedFixtures) { + if (role === 'SYSTEM_ADMIN') { + return undefined; + } + + return fixtures.hospitalAId; +} + export async function loginAsRole( app: INestApplication, role: E2ERole, + fixtures: E2ESeedFixtures, ): Promise { const credential = E2E_SEED_CREDENTIALS[role]; const payload: Record = { @@ -19,9 +29,9 @@ export async function loginAsRole( password: credential.password, role: credential.role, }; - - if (credential.hospitalId != null) { - payload.hospitalId = credential.hospitalId; + const hospitalId = resolveRoleHospitalId(role, fixtures); + if (hospitalId != null) { + payload.hospitalId = hospitalId; } const response = await request(app.getHttpServer()) @@ -36,10 +46,11 @@ export async function loginAsRole( export async function loginAllRoles( app: INestApplication, + fixtures: E2ESeedFixtures, ): Promise { const tokenEntries = await Promise.all( E2E_ROLE_LIST.map( - async (role) => [role, await loginAsRole(app, role)] as const, + async (role) => [role, await loginAsRole(app, role, fixtures)] as const, ), ); diff --git a/test/e2e/helpers/e2e-context.helper.ts b/test/e2e/helpers/e2e-context.helper.ts index c98826f..5c17732 100644 --- a/test/e2e/helpers/e2e-context.helper.ts +++ b/test/e2e/helpers/e2e-context.helper.ts @@ -3,7 +3,7 @@ import { PrismaService } from '../../../src/prisma.service.js'; import { loginAllRoles, type E2EAccessTokenMap } from './e2e-auth.helper.js'; import { createE2eApp } from './e2e-app.helper.js'; import { - loadSeedFixtures, + ensureE2EFixtures, type E2ESeedFixtures, } from './e2e-fixtures.helper.js'; @@ -17,8 +17,8 @@ export interface E2EContext { export async function createE2EContext(): Promise { const app = await createE2eApp(); const prisma = app.get(PrismaService); - const fixtures = await loadSeedFixtures(prisma); - const tokens = await loginAllRoles(app); + const fixtures = await ensureE2EFixtures(app, prisma); + const tokens = await loginAllRoles(app, fixtures); return { app, diff --git a/test/e2e/helpers/e2e-fixtures.helper.ts b/test/e2e/helpers/e2e-fixtures.helper.ts index af7cf6d..10f500d 100644 --- a/test/e2e/helpers/e2e-fixtures.helper.ts +++ b/test/e2e/helpers/e2e-fixtures.helper.ts @@ -1,5 +1,13 @@ +import type { INestApplication } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common'; +import request from 'supertest'; +import { Role } from '../../../src/generated/prisma/enums.js'; import { PrismaService } from '../../../src/prisma.service.js'; +import { + E2E_SEED_CREDENTIALS, + E2E_SEED_PASSWORD, +} from '../fixtures/e2e-roles.js'; +import { expectSuccessEnvelope } from './e2e-http.helper.js'; export interface E2ESeedFixtures { hospitalAId: number; @@ -10,6 +18,10 @@ export interface E2ESeedFixtures { groupA1Id: number; groupA2Id: number; groupB1Id: number; + catalogs: { + adjustableValveId: number; + highPressureValveId: number; + }; users: { systemAdminId: number; hospitalAdminAId: number; @@ -44,6 +56,610 @@ interface SeedUserScope { groupId: number | null; } +const OPEN_IDS = { + systemAdmin: 'seed-system-admin-openid', + hospitalAdminA: 'seed-hospital-admin-a-openid', + hospitalAdminB: 'seed-hospital-admin-b-openid', + directorA: 'seed-director-a-openid', + leaderA: 'seed-leader-a-openid', + doctorA: 'seed-doctor-a-openid', + doctorA2: 'seed-doctor-a2-openid', + doctorA3: 'seed-doctor-a3-openid', + doctorB: 'seed-doctor-b-openid', + engineerA: 'seed-engineer-a-openid', + engineerB: 'seed-engineer-b-openid', +} as const; + +const FIXTURE_NAMES = { + hospitalA: 'Seed Hospital A', + hospitalB: 'Seed Hospital B', + departmentA1: 'Neurosurgery-A1', + departmentA2: 'Cardiology-A2', + departmentB1: 'Neurosurgery-B1', + groupA1: 'Shift-A1', + groupA2: 'Shift-A2', + groupB1: 'Shift-B1', + adjustableCatalog: 'SEED-ADJUSTABLE-VALVE', + highPressureCatalog: 'SEED-HIGH-PRESSURE-VALVE', +} as const; + +const EXTRA_PHONES = { + hospitalAdminB: '13800001101', + doctorA2: '13800001204', + doctorA3: '13800001304', + doctorB: '13800001104', + engineerB: '13800001105', +} as const; + +const SHARED_PATIENT_IDENTITY = { + phone: '13800002001', + idCard: '110101199001010011', +} as const; + +export async function ensureE2EFixtures( + app: INestApplication, + prisma: PrismaService, +): Promise { + const existingSystemAdmin = await prisma.user.findFirst({ + where: { + phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone, + role: Role.SYSTEM_ADMIN, + }, + select: { id: true }, + }); + + if (!existingSystemAdmin) { + await bootstrapFixturesViaApi(app); + } + + return loadSeedFixtures(prisma); +} + +async function bootstrapFixturesViaApi(app: INestApplication) { + const server = app.getHttpServer(); + + await createSystemAdmin(server); + const systemAdminToken = await loginByCredential(server, { + phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone, + password: E2E_SEED_PASSWORD, + role: Role.SYSTEM_ADMIN, + }); + + const hospitalA = await createWithToken( + server, + systemAdminToken, + '/b/organization/hospitals', + { name: FIXTURE_NAMES.hospitalA }, + ); + const hospitalB = await createWithToken( + server, + systemAdminToken, + '/b/organization/hospitals', + { name: FIXTURE_NAMES.hospitalB }, + ); + + const hospitalAdminA = await createWithToken(server, systemAdminToken, '/users', { + name: 'Seed Hospital Admin A', + phone: E2E_SEED_CREDENTIALS[Role.HOSPITAL_ADMIN].phone, + password: E2E_SEED_PASSWORD, + role: Role.HOSPITAL_ADMIN, + hospitalId: hospitalA.id, + openId: OPEN_IDS.hospitalAdminA, + }); + await createWithToken(server, systemAdminToken, '/users', { + name: 'Seed Hospital Admin B', + phone: EXTRA_PHONES.hospitalAdminB, + password: E2E_SEED_PASSWORD, + role: Role.HOSPITAL_ADMIN, + hospitalId: hospitalB.id, + openId: OPEN_IDS.hospitalAdminB, + }); + + const engineerA = await createWithToken(server, systemAdminToken, '/users', { + name: 'Seed Engineer A', + phone: E2E_SEED_CREDENTIALS[Role.ENGINEER].phone, + password: E2E_SEED_PASSWORD, + role: Role.ENGINEER, + hospitalId: hospitalA.id, + openId: OPEN_IDS.engineerA, + }); + const engineerB = await createWithToken(server, systemAdminToken, '/users', { + name: 'Seed Engineer B', + phone: EXTRA_PHONES.engineerB, + password: E2E_SEED_PASSWORD, + role: Role.ENGINEER, + hospitalId: hospitalB.id, + openId: OPEN_IDS.engineerB, + }); + + await patchWithToken( + server, + systemAdminToken, + `/b/users/${engineerA.id}/assign-engineer-hospital`, + { hospitalId: hospitalA.id }, + ); + await patchWithToken( + server, + systemAdminToken, + `/b/users/${engineerB.id}/assign-engineer-hospital`, + { hospitalId: hospitalB.id }, + ); + + const hospitalAdminAToken = await loginByCredential(server, { + phone: hospitalAdminA.phone, + password: E2E_SEED_PASSWORD, + role: Role.HOSPITAL_ADMIN, + hospitalId: hospitalA.id, + }); + const hospitalAdminBToken = await loginByCredential(server, { + phone: EXTRA_PHONES.hospitalAdminB, + password: E2E_SEED_PASSWORD, + role: Role.HOSPITAL_ADMIN, + hospitalId: hospitalB.id, + }); + + const departmentA1 = await createWithToken( + server, + hospitalAdminAToken, + '/b/organization/departments', + { + name: FIXTURE_NAMES.departmentA1, + hospitalId: hospitalA.id, + }, + ); + const departmentA2 = await createWithToken( + server, + hospitalAdminAToken, + '/b/organization/departments', + { + name: FIXTURE_NAMES.departmentA2, + hospitalId: hospitalA.id, + }, + ); + const departmentB1 = await createWithToken( + server, + hospitalAdminBToken, + '/b/organization/departments', + { + name: FIXTURE_NAMES.departmentB1, + hospitalId: hospitalB.id, + }, + ); + + const groupA1 = await createWithToken( + server, + hospitalAdminAToken, + '/b/organization/groups', + { + name: FIXTURE_NAMES.groupA1, + departmentId: departmentA1.id, + }, + ); + const groupA2 = await createWithToken( + server, + hospitalAdminAToken, + '/b/organization/groups', + { + name: FIXTURE_NAMES.groupA2, + departmentId: departmentA2.id, + }, + ); + const groupB1 = await createWithToken( + server, + hospitalAdminBToken, + '/b/organization/groups', + { + name: FIXTURE_NAMES.groupB1, + departmentId: departmentB1.id, + }, + ); + + const directorA = await createWithToken(server, hospitalAdminAToken, '/users', { + name: 'Seed Director A', + phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone, + password: E2E_SEED_PASSWORD, + role: Role.DIRECTOR, + hospitalId: hospitalA.id, + departmentId: departmentA1.id, + openId: OPEN_IDS.directorA, + }); + await createWithToken(server, hospitalAdminAToken, '/users', { + name: 'Seed Leader A', + phone: E2E_SEED_CREDENTIALS[Role.LEADER].phone, + password: E2E_SEED_PASSWORD, + role: Role.LEADER, + hospitalId: hospitalA.id, + departmentId: departmentA1.id, + groupId: groupA1.id, + openId: OPEN_IDS.leaderA, + }); + const doctorA = await createWithToken(server, hospitalAdminAToken, '/users', { + name: 'Seed Doctor A', + phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalA.id, + departmentId: departmentA1.id, + groupId: groupA1.id, + openId: OPEN_IDS.doctorA, + }); + const doctorA2 = await createWithToken(server, hospitalAdminAToken, '/users', { + name: 'Seed Doctor A2', + phone: EXTRA_PHONES.doctorA2, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalA.id, + departmentId: departmentA1.id, + groupId: groupA1.id, + openId: OPEN_IDS.doctorA2, + }); + const doctorA3 = await createWithToken(server, hospitalAdminAToken, '/users', { + name: 'Seed Doctor A3', + phone: EXTRA_PHONES.doctorA3, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalA.id, + departmentId: departmentA2.id, + groupId: groupA2.id, + openId: OPEN_IDS.doctorA3, + }); + const doctorB = await createWithToken(server, hospitalAdminBToken, '/users', { + name: 'Seed Doctor B', + phone: EXTRA_PHONES.doctorB, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalB.id, + departmentId: departmentB1.id, + groupId: groupB1.id, + openId: OPEN_IDS.doctorB, + }); + + await bootstrapDictionaries(server, systemAdminToken); + + const adjustableCatalog = await createWithToken( + server, + systemAdminToken, + '/b/devices/catalogs', + { + modelCode: FIXTURE_NAMES.adjustableCatalog, + manufacturer: 'Seed MedTech', + name: 'Seed 可调压分流阀', + pressureLevels: ['0.5', '1', '1.5'], + isPressureAdjustable: true, + notes: 'Seed 全局可调压目录样例', + }, + ); + await createWithToken(server, systemAdminToken, '/b/devices/catalogs', { + modelCode: FIXTURE_NAMES.highPressureCatalog, + manufacturer: 'Seed MedTech', + name: 'Seed 高压挡位阀', + pressureLevels: ['10', '20', '30'], + isPressureAdjustable: true, + notes: 'Seed 高压挡位目录样例', + }); + + const doctorAToken = await loginByCredential(server, { + phone: doctorA.phone, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalA.id, + }); + const doctorA2Token = await loginByCredential(server, { + phone: EXTRA_PHONES.doctorA2, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalA.id, + }); + const doctorA3Token = await loginByCredential(server, { + phone: EXTRA_PHONES.doctorA3, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalA.id, + }); + const doctorBToken = await loginByCredential(server, { + phone: EXTRA_PHONES.doctorB, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalB.id, + }); + const engineerAToken = await loginByCredential(server, { + phone: E2E_SEED_CREDENTIALS[Role.ENGINEER].phone, + password: E2E_SEED_PASSWORD, + role: Role.ENGINEER, + hospitalId: hospitalA.id, + }); + + const patientA1 = await createWithToken(server, doctorAToken, '/b/patients', { + name: 'Seed Patient A1', + inpatientNo: 'ZYH-A-0001', + projectName: '脑积水随访项目-A', + phone: SHARED_PATIENT_IDENTITY.phone, + idCard: SHARED_PATIENT_IDENTITY.idCard, + doctorId: doctorA.id, + initialSurgery: { + surgeryDate: '2024-06-01T08:00:00.000Z', + surgeryName: '首次脑室腹腔分流术', + preOpPressure: 24, + primaryDisease: '先天性脑积水', + hydrocephalusTypes: ['交通性'], + notes: '首台手术', + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A1 弃用历史设备', + labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg', + }, + ], + }, + }); + + const oldDeviceId = patientA1.devices?.[0]?.id; + if (!oldDeviceId) { + throw new Error('failed to create seed old device'); + } + + const revisionA1 = await createWithToken( + server, + doctorAToken, + `/b/patients/${patientA1.id}/surgeries`, + { + surgeryDate: '2025-09-10T08:00:00.000Z', + surgeryName: '分流系统翻修术', + preOpPressure: 18, + primaryDisease: '分流功能障碍', + hydrocephalusTypes: ['交通性', '高压性'], + previousShuntSurgeryDate: '2024-06-01T08:00:00.000Z', + preOpMaterials: [ + { + type: 'IMAGE', + url: 'https://seed.example.com/a1-ct-preop.png', + name: 'Seed A1 术前 CT', + }, + ], + notes: '二次手术,保留原设备历史', + abandonedDeviceIds: [oldDeviceId], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A1 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg', + }, + ], + }, + ); + + const patientA2 = await createWithToken(server, doctorA2Token, '/b/patients', { + name: 'Seed Patient A2', + inpatientNo: 'ZYH-A-0002', + projectName: '脑积水随访项目-A', + phone: '13800002002', + idCard: '110101199002020022', + doctorId: doctorA2.id, + initialSurgery: { + surgeryDate: '2025-12-15T08:00:00.000Z', + surgeryName: '脑室腹腔分流术', + preOpPressure: 20, + primaryDisease: '肿瘤相关脑积水', + hydrocephalusTypes: ['梗阻性'], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['枕角'], + valvePlacementSites: ['胸前'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A2 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg', + }, + ], + }, + }); + + await createWithToken(server, doctorA3Token, '/b/patients', { + name: 'Seed Patient A3', + inpatientNo: 'ZYH-A-0003', + projectName: '脑积水随访项目-A', + phone: '13800002003', + idCard: '110101199003030033', + doctorId: doctorA3.id, + initialSurgery: { + surgeryDate: '2025-11-20T08:00:00.000Z', + surgeryName: '脑室腹腔分流术', + preOpPressure: 21, + primaryDisease: '外伤后脑积水', + hydrocephalusTypes: ['交通性'], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'LPS', + proximalPunctureAreas: ['腰穿'], + valvePlacementSites: ['腰背部'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A3 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg', + }, + ], + }, + }); + + const patientB1 = await createWithToken(server, doctorBToken, '/b/patients', { + name: 'Seed Patient B1', + inpatientNo: 'ZYH-B-0001', + projectName: '脑积水随访项目-B', + phone: SHARED_PATIENT_IDENTITY.phone, + idCard: SHARED_PATIENT_IDENTITY.idCard, + doctorId: doctorB.id, + initialSurgery: { + surgeryDate: '2025-10-05T08:00:00.000Z', + surgeryName: '脑室腹腔分流术', + preOpPressure: 23, + primaryDisease: '出血后脑积水', + hydrocephalusTypes: ['高压性'], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed B1 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg', + }, + ], + }, + }); + + const deviceA1Id = revisionA1.devices?.[0]?.id; + const deviceB1Id = patientB1.devices?.[0]?.id; + if (!deviceA1Id || !deviceB1Id) { + throw new Error('failed to create seed active devices'); + } + + const publishedA = await createWithToken( + server, + doctorAToken, + '/b/tasks/publish', + { + engineerId: engineerA.id, + items: [ + { + deviceId: deviceA1Id, + targetPressure: '1.5', + }, + ], + }, + ); + + await createWithToken(server, engineerAToken, '/b/tasks/complete', { + taskId: publishedA.id, + }); + + await createWithToken(server, doctorBToken, '/b/tasks/publish', { + engineerId: engineerB.id, + items: [ + { + deviceId: deviceB1Id, + targetPressure: '1.5', + }, + ], + }); + + // 防止未使用变量被误删,保留显式访问。 + void patientA2; +} + +async function bootstrapDictionaries( + server: ReturnType, + systemAdminToken: string, +) { + const dictionarySeeds = { + PRIMARY_DISEASE: [ + '先天性脑积水', + '梗阻性脑积水', + '交通性脑积水', + '出血后脑积水', + '肿瘤相关脑积水', + '外伤后脑积水', + '感染后脑积水', + '分流功能障碍', + ], + HYDROCEPHALUS_TYPE: [ + '交通性', + '梗阻性', + '高压性', + '正常压力', + '先天性', + '继发性', + ], + SHUNT_MODE: ['VPS', 'VPLS', 'LPS', '脑室心房分流'], + PROXIMAL_PUNCTURE_AREA: ['额角', '枕角', '三角区', '腰穿', '后角'], + VALVE_PLACEMENT_SITE: ['耳后', '胸前', '锁骨下', '腹壁', '腰背部'], + DISTAL_SHUNT_DIRECTION: ['腹腔', '胸腔', '心房', '腰大池'], + } as const; + + for (const [type, labels] of Object.entries(dictionarySeeds)) { + for (const [index, label] of labels.entries()) { + await createWithToken(server, systemAdminToken, '/b/dictionaries', { + type, + label, + sortOrder: index * 10, + enabled: true, + }); + } + } +} + +async function createSystemAdmin( + server: ReturnType, +) { + const response = await request(server).post('/auth/system-admin').send({ + name: 'Seed System Admin', + phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone, + password: E2E_SEED_PASSWORD, + openId: OPEN_IDS.systemAdmin, + systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY, + }); + + expectSuccessEnvelope(response, 201); +} + +async function loginByCredential( + server: ReturnType, + payload: { + phone: string; + password: string; + role: Role; + hospitalId?: number; + }, +) { + const response = await request(server).post('/auth/login').send(payload); + expectSuccessEnvelope(response, 201); + return response.body.data.accessToken as string; +} + +async function createWithToken( + server: ReturnType, + token: string, + path: string, + payload: Record, +) { + const response = await request(server) + .post(path) + .set('Authorization', `Bearer ${token}`) + .send(payload); + + expectSuccessEnvelope(response, 201); + return response.body.data; +} + +async function patchWithToken( + server: ReturnType, + token: string, + path: string, + payload: Record, +) { + const response = await request(server) + .patch(path) + .set('Authorization', `Bearer ${token}`) + .send(payload); + + expectSuccessEnvelope(response, 200); + return response.body.data; +} + async function requireUserScope( prisma: PrismaService, openId: string, @@ -63,16 +679,30 @@ async function requireUserScope( return user; } +async function requireCatalogId( + prisma: PrismaService, + modelCode: string, +): Promise { + const catalog = await prisma.implantCatalog.findFirst({ + where: { modelCode }, + select: { id: true }, + }); + if (!catalog) { + throw new NotFoundException(`Seed catalog not found: ${modelCode}`); + } + return catalog.id; +} + async function requireDeviceId( prisma: PrismaService, - snCode: string, + implantNotes: string, ): Promise { - const device = await prisma.device.findUnique({ - where: { snCode }, + const device = await prisma.device.findFirst({ + where: { implantNotes }, select: { id: true }, }); if (!device) { - throw new NotFoundException(`Seed device not found: ${snCode}`); + throw new NotFoundException(`Seed device not found: ${implantNotes}`); } return device.id; } @@ -96,22 +726,16 @@ async function requirePatientId( export async function loadSeedFixtures( prisma: PrismaService, ): Promise { - const systemAdmin = await requireUserScope( - prisma, - 'seed-system-admin-openid', - ); - const hospitalAdminA = await requireUserScope( - prisma, - 'seed-hospital-admin-a-openid', - ); - const directorA = await requireUserScope(prisma, 'seed-director-a-openid'); - const leaderA = await requireUserScope(prisma, 'seed-leader-a-openid'); - const doctorA = await requireUserScope(prisma, 'seed-doctor-a-openid'); - const doctorA2 = await requireUserScope(prisma, 'seed-doctor-a2-openid'); - const doctorA3 = await requireUserScope(prisma, 'seed-doctor-a3-openid'); - const doctorB = await requireUserScope(prisma, 'seed-doctor-b-openid'); - const engineerA = await requireUserScope(prisma, 'seed-engineer-a-openid'); - const engineerB = await requireUserScope(prisma, 'seed-engineer-b-openid'); + const systemAdmin = await requireUserScope(prisma, OPEN_IDS.systemAdmin); + const hospitalAdminA = await requireUserScope(prisma, OPEN_IDS.hospitalAdminA); + const directorA = await requireUserScope(prisma, OPEN_IDS.directorA); + const leaderA = await requireUserScope(prisma, OPEN_IDS.leaderA); + const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA); + const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2); + const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3); + const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB); + const engineerA = await requireUserScope(prisma, OPEN_IDS.engineerA); + const engineerB = await requireUserScope(prisma, OPEN_IDS.engineerB); const hospitalAId = hospitalAdminA.hospitalId; const hospitalBId = doctorB.hospitalId; @@ -144,6 +768,16 @@ export async function loadSeedFixtures( groupA1Id, groupA2Id, groupB1Id, + catalogs: { + adjustableValveId: await requireCatalogId( + prisma, + FIXTURE_NAMES.adjustableCatalog, + ), + highPressureValveId: await requireCatalogId( + prisma, + FIXTURE_NAMES.highPressureCatalog, + ), + }, users: { systemAdminId: systemAdmin.id, hospitalAdminAId: hospitalAdminA.id, @@ -160,8 +794,8 @@ export async function loadSeedFixtures( patientA1Id: await requirePatientId( prisma, hospitalAId, - '13800002001', - '110101199001010011', + SHARED_PATIENT_IDENTITY.phone, + SHARED_PATIENT_IDENTITY.idCard, ), patientA2Id: await requirePatientId( prisma, @@ -178,16 +812,16 @@ export async function loadSeedFixtures( patientB1Id: await requirePatientId( prisma, hospitalBId, - '13800002001', - '110101199001010011', + SHARED_PATIENT_IDENTITY.phone, + SHARED_PATIENT_IDENTITY.idCard, ), }, devices: { - deviceA1Id: await requireDeviceId(prisma, 'SEED-SN-A-001'), - deviceA2Id: await requireDeviceId(prisma, 'SEED-SN-A-002'), - deviceA3Id: await requireDeviceId(prisma, 'SEED-SN-A-003'), - deviceA4InactiveId: await requireDeviceId(prisma, 'SEED-SN-A-004'), - deviceB1Id: await requireDeviceId(prisma, 'SEED-SN-B-001'), + deviceA1Id: await requireDeviceId(prisma, 'Seed A1 当前在用设备'), + deviceA2Id: await requireDeviceId(prisma, 'Seed A2 当前在用设备'), + deviceA3Id: await requireDeviceId(prisma, 'Seed A3 当前在用设备'), + deviceA4InactiveId: await requireDeviceId(prisma, 'Seed A1 弃用历史设备'), + deviceB1Id: await requireDeviceId(prisma, 'Seed B1 当前在用设备'), }, }; } diff --git a/test/e2e/specs/devices.e2e-spec.ts b/test/e2e/specs/devices.e2e-spec.ts index 991e921..31ffe7b 100644 --- a/test/e2e/specs/devices.e2e-spec.ts +++ b/test/e2e/specs/devices.e2e-spec.ts @@ -28,7 +28,6 @@ describe('BDevicesController (e2e)', () => { .post('/b/devices') .set('Authorization', `Bearer ${token}`) .send({ - snCode: uniqueSeedValue('device-sn'), status: DeviceStatus.ACTIVE, patientId, }); @@ -36,8 +35,7 @@ describe('BDevicesController (e2e)', () => { expectSuccessEnvelope(response, 201); return response.body.data as { id: number; - snCode: string; - currentPressure: number; + currentPressure: string; status: DeviceStatus; patient: { id: number }; }; @@ -118,24 +116,28 @@ describe('BDevicesController (e2e)', () => { manufacturer: 'Global Vendor', name: '全局可调压阀', isPressureAdjustable: true, - pressureLevels: [70, 90, 110], + pressureLevels: ['10.0', '20', '30.0'], notes: '测试全局目录', }); expectSuccessEnvelope(createResponse, 201); - expect(createResponse.body.data.pressureLevels).toEqual([70, 90, 110]); + expect(createResponse.body.data.pressureLevels).toEqual(['10', '20', '30']); const updateResponse = await request(ctx.app.getHttpServer()) .patch(`/b/devices/catalogs/${createResponse.body.data.id}`) .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .send({ name: '全局可调压阀-更新版', - pressureLevels: [80, 100, 120], + pressureLevels: ['0.5', '1.0', '1.50'], }); expectSuccessEnvelope(updateResponse, 200); expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版'); - expect(updateResponse.body.data.pressureLevels).toEqual([80, 100, 120]); + expect(updateResponse.body.data.pressureLevels).toEqual([ + '0.5', + '1', + '1.5', + ]); const deleteResponse = await request(ctx.app.getHttpServer()) .delete(`/b/devices/catalogs/${createResponse.body.data.id}`) @@ -166,7 +168,7 @@ describe('BDevicesController (e2e)', () => { manufacturer: 'Role Matrix Vendor', name: '角色矩阵目录', isPressureAdjustable: true, - pressureLevels: [50, 80], + pressureLevels: ['10', '20'], }), sendWithoutToken: async () => request(ctx.app.getHttpServer()) @@ -188,9 +190,9 @@ describe('BDevicesController (e2e)', () => { ); expect(created.status).toBe(DeviceStatus.ACTIVE); - expect(created.currentPressure).toBe(0); + expect(created.currentPressure).toBe('0'); expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id); - expect(created.snCode).toMatch(/^DEVICE-SN-/); + expect(created).not.toHaveProperty('snCode'); }); it('失败:HOSPITAL_ADMIN 绑定跨院患者返回 403', async () => { @@ -198,7 +200,6 @@ describe('BDevicesController (e2e)', () => { .post('/b/devices') .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) .send({ - snCode: uniqueSeedValue('cross-hospital-device'), status: DeviceStatus.ACTIVE, patientId: ctx.fixtures.patients.patientB1Id, }); @@ -225,7 +226,7 @@ describe('BDevicesController (e2e)', () => { expect(response.body.data.patient.id).toBe( ctx.fixtures.patients.patientA2Id, ); - expect(response.body.data.currentPressure).toBe(0); + expect(response.body.data.currentPressure).toBe('0'); }); it('失败:设备实例接口不允许手工更新 currentPressure', async () => { @@ -238,7 +239,7 @@ describe('BDevicesController (e2e)', () => { .patch(`/b/devices/${created.id}`) .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .send({ - currentPressure: 99, + currentPressure: '1.5', }); expectErrorEnvelope(response, 400, 'currentPressure should not exist'); diff --git a/test/e2e/specs/organization.e2e-spec.ts b/test/e2e/specs/organization.e2e-spec.ts index 70c1fdb..5d93c3d 100644 --- a/test/e2e/specs/organization.e2e-spec.ts +++ b/test/e2e/specs/organization.e2e-spec.ts @@ -321,7 +321,7 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/departments role matrix', tokens: ctx.tokens, @@ -330,7 +330,7 @@ describe('Organization Controllers (e2e)', () => { [Role.HOSPITAL_ADMIN]: 200, [Role.DIRECTOR]: 200, [Role.LEADER]: 200, - [Role.DOCTOR]: 403, + [Role.DOCTOR]: 200, [Role.ENGINEER]: 403, }, sendAsRole: async (_role, token) => @@ -361,7 +361,7 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/departments/:id role matrix', tokens: ctx.tokens, @@ -370,7 +370,7 @@ describe('Organization Controllers (e2e)', () => { [Role.HOSPITAL_ADMIN]: 200, [Role.DIRECTOR]: 200, [Role.LEADER]: 200, - [Role.DOCTOR]: 403, + [Role.DOCTOR]: 200, [Role.ENGINEER]: 403, }, sendAsRole: async (_role, token) => @@ -413,15 +413,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'PATCH /b/organization/departments/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404, - [Role.DIRECTOR]: 404, - [Role.LEADER]: 404, + [Role.DIRECTOR]: 403, + [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -516,14 +516,14 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/organization/groups role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 400, [Role.HOSPITAL_ADMIN]: 400, - [Role.DIRECTOR]: 400, + [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, @@ -558,7 +558,7 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/groups role matrix', tokens: ctx.tokens, @@ -567,7 +567,7 @@ describe('Organization Controllers (e2e)', () => { [Role.HOSPITAL_ADMIN]: 200, [Role.DIRECTOR]: 200, [Role.LEADER]: 200, - [Role.DOCTOR]: 403, + [Role.DOCTOR]: 200, [Role.ENGINEER]: 403, }, sendAsRole: async (_role, token) => @@ -598,7 +598,7 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/groups/:id role matrix', tokens: ctx.tokens, @@ -607,7 +607,7 @@ describe('Organization Controllers (e2e)', () => { [Role.HOSPITAL_ADMIN]: 200, [Role.DIRECTOR]: 200, [Role.LEADER]: 200, - [Role.DOCTOR]: 403, + [Role.DOCTOR]: 200, [Role.ENGINEER]: 403, }, sendAsRole: async (_role, token) => @@ -650,15 +650,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'PATCH /b/organization/groups/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404, - [Role.DIRECTOR]: 404, - [Role.LEADER]: 404, + [Role.DIRECTOR]: 403, + [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -710,14 +710,14 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 409, '小组下仍有成员,无法删除'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'DELETE /b/organization/groups/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404, - [Role.DIRECTOR]: 404, + [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, diff --git a/test/e2e/specs/patients.e2e-spec.ts b/test/e2e/specs/patients.e2e-spec.ts index bc6fc2d..773637f 100644 --- a/test/e2e/specs/patients.e2e-spec.ts +++ b/test/e2e/specs/patients.e2e-spec.ts @@ -1,9 +1,5 @@ import request from 'supertest'; -import { - DeviceStatus, - Role, - TaskStatus, -} from '../../../src/generated/prisma/enums.js'; +import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js'; import { closeE2EContext, createE2EContext, @@ -201,12 +197,6 @@ describe('Patients Controllers (e2e)', () => { describe('患者手术录入', () => { it('成功:DOCTOR 可创建患者并附带首台手术和植入设备', async () => { - const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({ - where: { modelCode: 'SEED-ADJUSTABLE-VALVE' }, - select: { id: true }, - }); - expect(adjustableCatalog).toBeTruthy(); - const response = await request(ctx.app.getHttpServer()) .post('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) @@ -220,18 +210,17 @@ describe('Patients Controllers (e2e)', () => { initialSurgery: { surgeryDate: '2026-03-19T08:00:00.000Z', surgeryName: '脑室腹腔分流术', - surgeonName: 'Seed Doctor A', preOpPressure: 20, primaryDisease: '梗阻性脑积水', hydrocephalusTypes: ['交通性'], devices: [ { - implantCatalogId: adjustableCatalog!.id, + implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['额角'], valvePlacementSites: ['耳后'], distalShuntDirection: '腹腔', - initialPressure: 120, + initialPressure: '1', implantNotes: '首术植入', labelImageUrl: 'https://seed.example.com/tests/first-surgery.jpg', @@ -241,8 +230,18 @@ describe('Patients Controllers (e2e)', () => { }); expectSuccessEnvelope(response, 201); + expect(response.body.data.creatorId).toBe(ctx.fixtures.users.doctorAId); + expect(response.body.data.creator).toEqual( + expect.objectContaining({ + id: ctx.fixtures.users.doctorAId, + }), + ); + expect(response.body.data.createdAt).toBeTruthy(); expect(response.body.data.shuntSurgeryCount).toBe(1); expect(response.body.data.surgeries).toHaveLength(1); + expect(response.body.data.surgeries[0].surgeonId).toBe( + ctx.fixtures.users.doctorAId, + ); expect(response.body.data.surgeries[0].devices).toHaveLength(1); expect(response.body.data.surgeries[0].devices[0].implantModel).toBe( 'SEED-ADJUSTABLE-VALVE', @@ -250,12 +249,6 @@ describe('Patients Controllers (e2e)', () => { }); it('成功:新增二次手术时可弃用旧设备且保留旧设备调压历史', async () => { - const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({ - where: { modelCode: 'SEED-ADJUSTABLE-VALVE' }, - select: { id: true }, - }); - expect(adjustableCatalog).toBeTruthy(); - const createPatientResponse = await request(ctx.app.getHttpServer()) .post('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) @@ -269,18 +262,17 @@ describe('Patients Controllers (e2e)', () => { initialSurgery: { surgeryDate: '2026-03-01T08:00:00.000Z', surgeryName: '首次分流术', - surgeonName: 'Seed Doctor A', preOpPressure: 18, primaryDisease: '出血后脑积水', hydrocephalusTypes: ['高压性'], devices: [ { - implantCatalogId: adjustableCatalog!.id, + implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['额角'], valvePlacementSites: ['耳后'], distalShuntDirection: '腹腔', - initialPressure: 100, + initialPressure: '1', implantNotes: '首术设备', labelImageUrl: 'https://seed.example.com/tests/initial-device.jpg', @@ -296,23 +288,25 @@ describe('Patients Controllers (e2e)', () => { }; const oldDeviceId = patient.devices[0].id; - await ctx.prisma.task.create({ - data: { - status: TaskStatus.COMPLETED, - creatorId: ctx.fixtures.users.doctorAId, + const publishResponse = await request(ctx.app.getHttpServer()) + .post('/b/tasks/publish') + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) + .send({ engineerId: ctx.fixtures.users.engineerAId, - hospitalId: ctx.fixtures.hospitalAId, - items: { - create: [ - { - deviceId: oldDeviceId, - oldPressure: 100, - targetPressure: 120, - }, - ], - }, - }, - }); + items: [ + { + deviceId: oldDeviceId, + targetPressure: '1.5', + }, + ], + }); + expectSuccessEnvelope(publishResponse, 201); + + const completeResponse = await request(ctx.app.getHttpServer()) + .post('/b/tasks/complete') + .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) + .send({ taskId: publishResponse.body.data.id }); + expectSuccessEnvelope(completeResponse, 201); const surgeryResponse = await request(ctx.app.getHttpServer()) .post(`/b/patients/${patient.id}/surgeries`) @@ -320,29 +314,28 @@ describe('Patients Controllers (e2e)', () => { .send({ surgeryDate: '2026-03-18T08:00:00.000Z', surgeryName: '二次翻修术', - surgeonName: 'Seed Doctor A', preOpPressure: 16, primaryDisease: '分流功能障碍', hydrocephalusTypes: ['交通性', '高压性'], abandonedDeviceIds: [oldDeviceId], devices: [ { - implantCatalogId: adjustableCatalog!.id, + implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['枕角'], valvePlacementSites: ['耳后'], distalShuntDirection: '腹腔', - initialPressure: 120, + initialPressure: '1', implantNotes: '二次手术新设备-1', labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg', }, { - implantCatalogId: adjustableCatalog!.id, + implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['额角'], valvePlacementSites: ['胸前'], distalShuntDirection: '胸腔', - initialPressure: 140, + initialPressure: '1.5', implantNotes: '二次手术新设备-2', labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg', }, @@ -364,12 +357,6 @@ describe('Patients Controllers (e2e)', () => { }); it('失败:手术录入设备不允许手工传 currentPressure', async () => { - const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({ - where: { modelCode: 'SEED-ADJUSTABLE-VALVE' }, - select: { id: true }, - }); - expect(adjustableCatalog).toBeTruthy(); - const response = await request(ctx.app.getHttpServer()) .post('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) @@ -383,18 +370,17 @@ describe('Patients Controllers (e2e)', () => { initialSurgery: { surgeryDate: '2026-03-19T08:00:00.000Z', surgeryName: '脑室腹腔分流术', - surgeonName: 'Seed Doctor A', primaryDisease: '梗阻性脑积水', hydrocephalusTypes: ['交通性'], devices: [ { - implantCatalogId: adjustableCatalog!.id, + implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['额角'], valvePlacementSites: ['耳后'], distalShuntDirection: '腹腔', - initialPressure: 120, - currentPressure: 120, + initialPressure: '1', + currentPressure: '1', }, ], }, diff --git a/test/e2e/specs/tasks.e2e-spec.ts b/test/e2e/specs/tasks.e2e-spec.ts index 1c1b8a2..bb65b07 100644 --- a/test/e2e/specs/tasks.e2e-spec.ts +++ b/test/e2e/specs/tasks.e2e-spec.ts @@ -22,11 +22,17 @@ describe('BTasksController (e2e)', () => { await closeE2EContext(ctx); }); - async function publishPendingTask(deviceId: number, targetPressure: number) { + async function publishAssignedTask( + deviceId: number, + targetPressure: string, + actorToken = ctx.tokens[Role.DOCTOR], + engineerId = ctx.fixtures.users.engineerAId, + ) { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') - .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) + .set('Authorization', `Bearer ${actorToken}`) .send({ + engineerId, items: [ { deviceId, @@ -36,11 +42,123 @@ describe('BTasksController (e2e)', () => { }); expectSuccessEnvelope(response, 201); - return response.body.data as { id: number; status: TaskStatus }; + return response.body.data as { + id: number; + status: TaskStatus; + engineerId: number; + hospitalId: number; + }; } + describe('GET /b/tasks/engineers', () => { + it('成功:DOCTOR 可查看本院可选工程师', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/b/tasks/engineers') + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); + + expectSuccessEnvelope(response, 200); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: ctx.fixtures.users.engineerAId, + }), + ]), + ); + }); + + it('成功:SYSTEM_ADMIN 可按医院筛选工程师', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/b/tasks/engineers') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .query({ hospitalId: ctx.fixtures.hospitalBId }); + + expectSuccessEnvelope(response, 200); + expect(response.body.data).toEqual([ + expect.objectContaining({ + id: ctx.fixtures.users.engineerBId, + hospitalId: ctx.fixtures.hospitalBId, + }), + ]); + }); + + it('失败:hospitalId 非法返回 400', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/b/tasks/engineers') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .query({ hospitalId: 0 }); + + expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0'); + }); + }); + + describe('GET /b/tasks', () => { + it('成功:SYSTEM_ADMIN 可查看跨医院调压记录', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/b/tasks') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .query({ page: 1, pageSize: 20 }); + + expectSuccessEnvelope(response, 200); + expect(Array.isArray(response.body.data.list)).toBe(true); + expect(response.body.data.total).toBeGreaterThan(0); + expect( + response.body.data.list.every( + (item: { + creator?: { id?: number; name?: string }; + engineer?: { id?: number; name?: string }; + }) => + Number.isInteger(item.creator?.id) && + Boolean(item.creator?.name) && + Number.isInteger(item.engineer?.id) && + Boolean(item.engineer?.name), + ), + ).toBe(true); + + const hospitalIds = Array.from( + new Set( + response.body.data.list + .map((item: { hospital?: { id?: number } }) => item.hospital?.id) + .filter(Boolean), + ), + ); + expect(hospitalIds).toEqual( + expect.arrayContaining([ + ctx.fixtures.hospitalAId, + ctx.fixtures.hospitalBId, + ]), + ); + }); + + it('成功:DOCTOR 仅可查看本院调压记录', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/b/tasks') + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) + .query({ page: 1, pageSize: 20 }); + + expectSuccessEnvelope(response, 200); + expect(Array.isArray(response.body.data.list)).toBe(true); + expect(response.body.data.total).toBeGreaterThan(0); + expect( + response.body.data.list.every( + (item: { hospital?: { id?: number } }) => + item.hospital?.id === ctx.fixtures.hospitalAId, + ), + ).toBe(true); + }); + + it('失败:hospitalId 非法返回 400', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/b/tasks') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .query({ hospitalId: 0 }); + + expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0'); + }); + }); + describe('POST /b/tasks/publish', () => { - it('成功:DOCTOR 可发布任务', async () => { + it('成功:DOCTOR 发布任务时必须直接指定接收工程师', async () => { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) @@ -49,16 +167,30 @@ describe('BTasksController (e2e)', () => { items: [ { deviceId: ctx.fixtures.devices.deviceA2Id, - targetPressure: 120, + targetPressure: '1.5', }, ], }); expectSuccessEnvelope(response, 201); - expect(response.body.data.status).toBe(TaskStatus.PENDING); + expect(response.body.data.status).toBe(TaskStatus.ACCEPTED); + expect(response.body.data.engineerId).toBe( + ctx.fixtures.users.engineerAId, + ); }); - it('失败:可调压设备使用非法挡位返回 400', async () => { + it('成功:SYSTEM_ADMIN 可按设备自动归院发布任务', async () => { + const task = await publishAssignedTask( + ctx.fixtures.devices.deviceA1Id, + '1.5', + ctx.tokens[Role.SYSTEM_ADMIN], + ); + + expect(task.status).toBe(TaskStatus.ACCEPTED); + expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId); + }); + + it('失败:未指定接收工程师返回 400', async () => { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) @@ -66,7 +198,24 @@ describe('BTasksController (e2e)', () => { items: [ { deviceId: ctx.fixtures.devices.deviceA2Id, - targetPressure: 126, + targetPressure: '1.5', + }, + ], + }); + + expectErrorEnvelope(response, 400, 'engineerId 必须是整数'); + }); + + it('失败:可调压设备使用非法挡位返回 400', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/b/tasks/publish') + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) + .send({ + engineerId: ctx.fixtures.users.engineerAId, + items: [ + { + deviceId: ctx.fixtures.devices.deviceA2Id, + targetPressure: '2', }, ], }); @@ -79,10 +228,11 @@ describe('BTasksController (e2e)', () => { .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ + engineerId: ctx.fixtures.users.engineerAId, items: [ { deviceId: ctx.fixtures.devices.deviceB1Id, - targetPressure: 120, + targetPressure: '1.5', }, ], }); @@ -90,13 +240,13 @@ describe('BTasksController (e2e)', () => { expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在'); }); - it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { + it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/publish role matrix', tokens: ctx.tokens, expectedStatusByRole: { - [Role.SYSTEM_ADMIN]: 403, - [Role.HOSPITAL_ADMIN]: 403, + [Role.SYSTEM_ADMIN]: 400, + [Role.HOSPITAL_ADMIN]: 400, [Role.DIRECTOR]: 400, [Role.LEADER]: 400, [Role.DOCTOR]: 400, @@ -114,10 +264,10 @@ describe('BTasksController (e2e)', () => { }); describe('POST /b/tasks/accept', () => { - it('成功:ENGINEER 可接收待处理任务', async () => { - const task = await publishPendingTask( + it('失败:ENGINEER 接收接口已停用,返回 403', async () => { + const task = await publishAssignedTask( ctx.fixtures.devices.deviceA2Id, - 140, + '1.5', ); const response = await request(ctx.app.getHttpServer()) @@ -125,43 +275,14 @@ describe('BTasksController (e2e)', () => { .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: task.id }); - expectSuccessEnvelope(response, 201); - expect(response.body.data.status).toBe(TaskStatus.ACCEPTED); - expect(response.body.data.engineerId).toBe( - ctx.fixtures.users.engineerAId, + expectErrorEnvelope( + response, + 403, + '当前流程不支持工程师接收,请由创建人直接指定接收工程师', ); }); - it('失败:接收不存在任务返回 404', async () => { - const response = await request(ctx.app.getHttpServer()) - .post('/b/tasks/accept') - .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: 99999999 }); - - expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院'); - }); - - it('状态机失败:重复接收返回 409', async () => { - const task = await publishPendingTask( - ctx.fixtures.devices.deviceA3Id, - 120, - ); - - const firstAccept = await request(ctx.app.getHttpServer()) - .post('/b/tasks/accept') - .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: task.id }); - expectSuccessEnvelope(firstAccept, 201); - - const secondAccept = await request(ctx.app.getHttpServer()) - .post('/b/tasks/accept') - .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: task.id }); - - expectErrorEnvelope(secondAccept, 409, '仅待接收任务可执行接收'); - }); - - it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => { + it('角色矩阵:接收接口对所有角色都不可用,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/accept role matrix', tokens: ctx.tokens, @@ -171,7 +292,7 @@ describe('BTasksController (e2e)', () => { [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, - [Role.ENGINEER]: 404, + [Role.ENGINEER]: 403, }, sendAsRole: async (_role, token) => request(ctx.app.getHttpServer()) @@ -187,19 +308,13 @@ describe('BTasksController (e2e)', () => { }); describe('POST /b/tasks/complete', () => { - it('成功:ENGINEER 完成已接收任务并同步设备压力', async () => { - const targetPressure = 140; - const task = await publishPendingTask( + it('成功:ENGINEER 可直接完成已指派任务并同步设备压力', async () => { + const targetPressure = '1.5'; + const task = await publishAssignedTask( ctx.fixtures.devices.deviceA1Id, targetPressure, ); - const acceptResponse = await request(ctx.app.getHttpServer()) - .post('/b/tasks/accept') - .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: task.id }); - expectSuccessEnvelope(acceptResponse, 201); - const completeResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) @@ -224,18 +339,24 @@ describe('BTasksController (e2e)', () => { expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院'); }); - it('状态机失败:未接收任务直接完成返回 409', async () => { - const task = await publishPendingTask( + it('状态机失败:重复完成返回 409', async () => { + const task = await publishAssignedTask( ctx.fixtures.devices.deviceA2Id, - 100, + '1', ); - const response = await request(ctx.app.getHttpServer()) + const firstComplete = await request(ctx.app.getHttpServer()) + .post('/b/tasks/complete') + .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) + .send({ taskId: task.id }); + expectSuccessEnvelope(firstComplete, 201); + + const secondComplete = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: task.id }); - expectErrorEnvelope(response, 409, '仅已接收任务可执行完成'); + expectErrorEnvelope(secondComplete, 409, '仅已指派任务可执行完成'); }); it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => { @@ -264,10 +385,10 @@ describe('BTasksController (e2e)', () => { }); describe('POST /b/tasks/cancel', () => { - it('成功:DOCTOR 可取消自己创建的任务', async () => { - const task = await publishPendingTask( + it('成功:DOCTOR 可取消自己创建的已指派任务', async () => { + const task = await publishAssignedTask( ctx.fixtures.devices.deviceA3Id, - 120, + '1.5', ); const response = await request(ctx.app.getHttpServer()) @@ -289,17 +410,11 @@ describe('BTasksController (e2e)', () => { }); it('状态机失败:已完成任务不可取消返回 409', async () => { - const task = await publishPendingTask( + const task = await publishAssignedTask( ctx.fixtures.devices.deviceA2Id, - 160, + '1.5', ); - const acceptResponse = await request(ctx.app.getHttpServer()) - .post('/b/tasks/accept') - .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: task.id }); - expectSuccessEnvelope(acceptResponse, 201); - const completeResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) @@ -311,16 +426,16 @@ describe('BTasksController (e2e)', () => { .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ taskId: task.id }); - expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消'); + expectErrorEnvelope(cancelResponse, 409, '仅待指派/已指派任务可取消'); }); - it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { + it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/cancel role matrix', tokens: ctx.tokens, expectedStatusByRole: { - [Role.SYSTEM_ADMIN]: 403, - [Role.HOSPITAL_ADMIN]: 403, + [Role.SYSTEM_ADMIN]: 404, + [Role.HOSPITAL_ADMIN]: 404, [Role.DIRECTOR]: 404, [Role.LEADER]: 404, [Role.DOCTOR]: 404, diff --git a/test/e2e/specs/uploads.e2e-spec.ts b/test/e2e/specs/uploads.e2e-spec.ts new file mode 100644 index 0000000..1b7facc --- /dev/null +++ b/test/e2e/specs/uploads.e2e-spec.ts @@ -0,0 +1,225 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import ffmpegPath from 'ffmpeg-static'; +import sharp from 'sharp'; +import request from 'supertest'; +import { Role } from '../../../src/generated/prisma/enums.js'; +import { + closeE2EContext, + createE2EContext, + type E2EContext, +} from '../helpers/e2e-context.helper.js'; +import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js'; +import { + expectErrorEnvelope, + expectSuccessEnvelope, +} from '../helpers/e2e-http.helper.js'; + +describe('BUploadsController (e2e)', () => { + let ctx: E2EContext; + let tempDir = ''; + let sampleVideoPath = ''; + let samplePngBuffer: Buffer; + + beforeAll(async () => { + ctx = await createE2EContext(); + tempDir = await mkdtemp(join(tmpdir(), 'upload-assets-e2e-')); + sampleVideoPath = join(tempDir, 'ct-video.mov'); + await createSampleVideo(sampleVideoPath); + samplePngBuffer = await sharp({ + create: { + width: 32, + height: 32, + channels: 3, + background: { r: 24, g: 46, b: 82 }, + }, + }) + .png() + .toBuffer(); + }); + + afterAll(async () => { + await closeE2EContext(ctx); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('成功:HOSPITAL_ADMIN 可上传图片', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/b/uploads') + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) + .attach('file', samplePngBuffer, { + filename: 'ct-image.png', + contentType: 'image/png', + }); + + expectSuccessEnvelope(response, 201); + expect(response.body.data.type).toBe('IMAGE'); + expect(response.body.data.mimeType).toBe('image/webp'); + expect(response.body.data.fileName).toMatch(/^\d{14}-ct-image\.webp$/); + expect(response.body.data.storagePath).toMatch( + /^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/, + ); + expect(response.body.data.url).toMatch( + /^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/, + ); + expect(response.body.data.hospital.id).toBe(ctx.fixtures.hospitalAId); + }); + + it('成功:HOSPITAL_ADMIN 可上传视频并转为 mp4', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/b/uploads') + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) + .attach('file', sampleVideoPath, { + filename: 'ct-video.mov', + contentType: 'video/quicktime', + }); + + expectSuccessEnvelope(response, 201); + expect(response.body.data.type).toBe('VIDEO'); + expect(response.body.data.mimeType).toBe('video/mp4'); + expect(response.body.data.fileName).toMatch(/^\d{14}-ct-video\.mp4$/); + expect(response.body.data.storagePath).toMatch( + /^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/, + ); + expect(response.body.data.url).toMatch( + /^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/, + ); + }); + + it('失败:SYSTEM_ADMIN 上传文件时未指定医院返回 400', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/b/uploads') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .attach('file', samplePngBuffer, { + filename: 'ct-image.png', + contentType: 'image/png', + }); + + expectErrorEnvelope(response, 400, '系统管理员上传文件时必须显式指定 hospitalId'); + }); + + it('失败:DIRECTOR 查询影像库返回 403', async () => { + await request(ctx.app.getHttpServer()) + .post('/b/uploads') + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) + .attach('file', samplePngBuffer, { + filename: 'seed-image.png', + contentType: 'image/png', + }); + + const response = await request(ctx.app.getHttpServer()) + .get('/b/uploads') + .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`); + + expectErrorEnvelope(response, 403, '无权限执行当前操作'); + }); + + it('角色矩阵:上传允许系统/医院/主任/组长/医生,工程师 403,未登录 401', async () => { + await assertRoleMatrix({ + name: 'POST /b/uploads role matrix', + tokens: ctx.tokens, + expectedStatusByRole: { + [Role.SYSTEM_ADMIN]: 400, + [Role.HOSPITAL_ADMIN]: 201, + [Role.DIRECTOR]: 201, + [Role.LEADER]: 201, + [Role.DOCTOR]: 201, + [Role.ENGINEER]: 403, + }, + sendAsRole: async (role, token) => { + const req = request(ctx.app.getHttpServer()) + .post('/b/uploads') + .set('Authorization', `Bearer ${token}`) + .attach('file', samplePngBuffer, { + filename: `${role.toLowerCase()}.png`, + contentType: 'image/png', + }); + + if (role === Role.SYSTEM_ADMIN) { + return req; + } + return req; + }, + sendWithoutToken: async () => + request(ctx.app.getHttpServer()) + .post('/b/uploads') + .attach('file', samplePngBuffer, { + filename: 'anonymous.png', + contentType: 'image/png', + }), + }); + }); + + it('角色矩阵:上传列表仅系统/医院管理员可访问,其他角色 403,未登录 401', async () => { + await assertRoleMatrix({ + name: 'GET /b/uploads role matrix', + tokens: ctx.tokens, + expectedStatusByRole: { + [Role.SYSTEM_ADMIN]: 200, + [Role.HOSPITAL_ADMIN]: 200, + [Role.DIRECTOR]: 403, + [Role.LEADER]: 403, + [Role.DOCTOR]: 403, + [Role.ENGINEER]: 403, + }, + sendAsRole: async (role, token) => { + const req = request(ctx.app.getHttpServer()) + .get('/b/uploads') + .set('Authorization', `Bearer ${token}`); + if (role === Role.SYSTEM_ADMIN) { + req.query({ hospitalId: ctx.fixtures.hospitalAId }); + } + return req; + }, + sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/b/uploads'), + }); + }); +}); + +async function createSampleVideo(outputPath: string) { + const command = ffmpegPath as unknown as string | null; + if (!command) { + throw new Error('ffmpeg-static not available'); + } + + const args = [ + '-y', + '-f', + 'lavfi', + '-i', + 'color=c=black:s=320x240:d=1', + '-f', + 'lavfi', + '-i', + 'anullsrc=r=44100:cl=stereo', + '-shortest', + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-c:a', + 'aac', + '-movflags', + '+faststart', + outputPath, + ]; + + await new Promise((resolve, reject) => { + const child = spawn(command, args); + let stderr = ''; + + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(stderr.trim() || 'failed to create sample video')); + }); + }); +} diff --git a/test/e2e/specs/users.e2e-spec.ts b/test/e2e/specs/users.e2e-spec.ts index 67e413e..72f2bbf 100644 --- a/test/e2e/specs/users.e2e-spec.ts +++ b/test/e2e/specs/users.e2e-spec.ts @@ -43,6 +43,25 @@ describe('UsersController + BUsersController (e2e)', () => { return response.body.data as { id: number; name: string }; } + async function createLeaderUser(token: string) { + const response = await request(ctx.app.getHttpServer()) + .post('/users') + .set('Authorization', `Bearer ${token}`) + .send({ + name: uniqueSeedValue('用户-组长'), + phone: uniquePhone(), + password: 'Seed@1234', + role: Role.LEADER, + hospitalId: ctx.fixtures.hospitalAId, + departmentId: ctx.fixtures.departmentA1Id, + groupId: ctx.fixtures.groupA1Id, + openId: uniqueSeedValue('users-leader-openid'), + }); + + expectSuccessEnvelope(response, 201); + return response.body.data as { id: number; name: string }; + } + async function createEngineerUser(token: string) { const response = await request(ctx.app.getHttpServer()) .post('/users') @@ -65,8 +84,12 @@ describe('UsersController + BUsersController (e2e)', () => { await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]); }); - it('成功:DIRECTOR 可创建本科室医生', async () => { - await createDoctorUser(ctx.tokens[Role.DIRECTOR]); + it('成功:HOSPITAL_ADMIN 可创建本院医生', async () => { + await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]); + }); + + it('成功:HOSPITAL_ADMIN 可创建本院组长', async () => { + await createLeaderUser(ctx.tokens[Role.HOSPITAL_ADMIN]); }); it('失败:参数校验失败返回 400', async () => { @@ -86,31 +109,31 @@ describe('UsersController + BUsersController (e2e)', () => { expectErrorEnvelope(response, 400, 'phone 必须是合法手机号'); }); - it('失败:DIRECTOR 创建非医生角色返回 403', async () => { + it('失败:DIRECTOR 创建用户返回 403', async () => { const response = await request(ctx.app.getHttpServer()) .post('/users') .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`) .send({ - name: uniqueSeedValue('主任创建组长'), + name: uniqueSeedValue('主任创建用户'), phone: uniquePhone(), password: 'Seed@1234', - role: Role.LEADER, + role: Role.DOCTOR, hospitalId: ctx.fixtures.hospitalAId, departmentId: ctx.fixtures.departmentA1Id, groupId: ctx.fixtures.groupA1Id, }); - expectErrorEnvelope(response, 403, '当前角色无权限创建该用户'); + expectErrorEnvelope(response, 403, '无权限执行当前操作'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /users role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 400, [Role.HOSPITAL_ADMIN]: 400, - [Role.DIRECTOR]: 400, + [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, @@ -136,6 +159,34 @@ describe('UsersController + BUsersController (e2e)', () => { expect(Array.isArray(response.body.data)).toBe(true); }); + it('成功:DIRECTOR 可查看本科室下级列表', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/users') + .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`); + + expectSuccessEnvelope(response, 200); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBeGreaterThan(0); + response.body.data.forEach((item: { role: Role; departmentId: number }) => { + expect([Role.DOCTOR, Role.LEADER]).toContain(item.role); + expect(item.departmentId).toBe(ctx.fixtures.departmentA1Id); + }); + }); + + it('成功:LEADER 可查看本组医生列表', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/users') + .set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`); + + expectSuccessEnvelope(response, 200); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBeGreaterThan(0); + response.body.data.forEach((item: { role: Role; groupId: number }) => { + expect(item.role).toBe(Role.DOCTOR); + expect(item.groupId).toBe(ctx.fixtures.groupA1Id); + }); + }); + it('失败:未登录返回 401', async () => { const response = await request(ctx.app.getHttpServer()).get('/users'); expectErrorEnvelope(response, 401, '缺少 Bearer Token'); @@ -173,15 +224,6 @@ describe('UsersController + BUsersController (e2e)', () => { expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId); }); - it('成功:DIRECTOR 可查询本科室医生详情', async () => { - const response = await request(ctx.app.getHttpServer()) - .get(`/users/${ctx.fixtures.users.doctorAId}`) - .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`); - - expectSuccessEnvelope(response, 200); - expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId); - }); - it('失败:查询不存在用户返回 404', async () => { const response = await request(ctx.app.getHttpServer()) .get('/users/99999999') @@ -190,15 +232,43 @@ describe('UsersController + BUsersController (e2e)', () => { expectErrorEnvelope(response, 404, '用户不存在'); }); - it('失败:DIRECTOR 查询非本科室医生返回 403', async () => { + it('成功:DIRECTOR 可查询本科室医生详情', async () => { + const response = await request(ctx.app.getHttpServer()) + .get(`/users/${ctx.fixtures.users.doctorAId}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`); + + expectSuccessEnvelope(response, 200); + expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId); + expect(response.body.data.departmentId).toBe(ctx.fixtures.departmentA1Id); + }); + + it('成功:LEADER 可查询本组医生详情', async () => { + const response = await request(ctx.app.getHttpServer()) + .get(`/users/${ctx.fixtures.users.doctorAId}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`); + + expectSuccessEnvelope(response, 200); + expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId); + expect(response.body.data.groupId).toBe(ctx.fixtures.groupA1Id); + }); + + it('失败:DIRECTOR 查询跨科室医生返回 403', async () => { const response = await request(ctx.app.getHttpServer()) .get(`/users/${ctx.fixtures.users.doctorA3Id}`) .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`); - expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号'); + expectErrorEnvelope(response, 403, '无权限执行当前操作'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可访问,其他角色 403,未登录 401', async () => { + it('失败:LEADER 查询其他小组医生返回 403', async () => { + const response = await request(ctx.app.getHttpServer()) + .get(`/users/${ctx.fixtures.users.doctorA3Id}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`); + + expectErrorEnvelope(response, 403, '无权限执行当前操作'); + }); + + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其他角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /users/:id role matrix', tokens: ctx.tokens, @@ -206,7 +276,7 @@ describe('UsersController + BUsersController (e2e)', () => { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, [Role.DIRECTOR]: 200, - [Role.LEADER]: 403, + [Role.LEADER]: 200, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -236,19 +306,31 @@ describe('UsersController + BUsersController (e2e)', () => { expect(response.body.data.name).toBe(nextName); }); - it('成功:DIRECTOR 可更新本科室医生姓名', async () => { - const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]); + it('成功:HOSPITAL_ADMIN 可更新本院医生姓名', async () => { + const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]); const nextName = uniqueSeedValue('主任更新医生名'); const response = await request(ctx.app.getHttpServer()) .patch(`/users/${created.id}`) - .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) .send({ name: nextName }); expectSuccessEnvelope(response, 200); expect(response.body.data.name).toBe(nextName); }); + it('成功:HOSPITAL_ADMIN 可将本院医生调整为组长', async () => { + const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]); + + const response = await request(ctx.app.getHttpServer()) + .patch(`/users/${created.id}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) + .send({ role: Role.LEADER, groupId: ctx.fixtures.groupA1Id }); + + expectSuccessEnvelope(response, 200); + expect(response.body.data.role).toBe(Role.LEADER); + }); + it('失败:非医生调整科室/小组返回 400', async () => { const response = await request(ctx.app.getHttpServer()) .patch(`/users/${ctx.fixtures.users.engineerAId}`) @@ -265,32 +347,32 @@ describe('UsersController + BUsersController (e2e)', () => { ); }); - it('失败:DIRECTOR 不能把医生改成其他角色', async () => { + it('失败:DIRECTOR 更新用户返回 403', async () => { const response = await request(ctx.app.getHttpServer()) .patch(`/users/${ctx.fixtures.users.doctorAId}`) .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`) - .send({ role: Role.LEADER }); + .send({ name: uniqueSeedValue('主任越权更新') }); - expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号'); + expectErrorEnvelope(response, 403, '无权限执行当前操作'); }); - it('失败:DIRECTOR 不能把医生调整到其他科室', async () => { + it('失败:HOSPITAL_ADMIN 不能把用户改成医院管理员', async () => { const response = await request(ctx.app.getHttpServer()) .patch(`/users/${ctx.fixtures.users.doctorAId}`) - .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`) - .send({ departmentId: ctx.fixtures.departmentA2Id }); + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) + .send({ role: Role.HOSPITAL_ADMIN }); - expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号'); + expectErrorEnvelope(response, 403, '医院管理员仅可操作本院非管理员账号'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'PATCH /users/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404, - [Role.DIRECTOR]: 404, + [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, @@ -319,11 +401,21 @@ describe('UsersController + BUsersController (e2e)', () => { expect(response.body.data.id).toBe(created.id); }); - it('成功:DIRECTOR 可删除本科室医生', async () => { - const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]); + it('成功:HOSPITAL_ADMIN 可删除本院医生', async () => { + const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]); const response = await request(ctx.app.getHttpServer()) .delete(`/users/${created.id}`) - .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`); + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`); + + expectSuccessEnvelope(response, 200); + expect(response.body.data.id).toBe(created.id); + }); + + it('成功:HOSPITAL_ADMIN 可删除本院组长', async () => { + const created = await createLeaderUser(ctx.tokens[Role.HOSPITAL_ADMIN]); + const response = await request(ctx.app.getHttpServer()) + .delete(`/users/${created.id}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`); expectSuccessEnvelope(response, 200); expect(response.body.data.id).toBe(created.id); @@ -337,30 +429,30 @@ describe('UsersController + BUsersController (e2e)', () => { expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除'); }); - it('失败:DIRECTOR 删除非本科室医生返回 403', async () => { + it('失败:DIRECTOR 删除用户返回 403', async () => { const response = await request(ctx.app.getHttpServer()) .delete(`/users/${ctx.fixtures.users.doctorA3Id}`) .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`); - expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号'); - }); - - it('失败:HOSPITAL_ADMIN 无法删除返回 403', async () => { - const response = await request(ctx.app.getHttpServer()) - .delete(`/users/${ctx.fixtures.users.doctorAId}`) - .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`); - expectErrorEnvelope(response, 403, '无权限执行当前操作'); }); - it('角色矩阵:SYSTEM_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => { + it('失败:HOSPITAL_ADMIN 无法删除本院管理员', async () => { + const response = await request(ctx.app.getHttpServer()) + .delete(`/users/${ctx.fixtures.users.hospitalAdminAId}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`); + + expectErrorEnvelope(response, 403, '医院管理员仅可操作本院非管理员账号'); + }); + + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'DELETE /users/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, - [Role.HOSPITAL_ADMIN]: 403, - [Role.DIRECTOR]: 404, + [Role.HOSPITAL_ADMIN]: 404, + [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, diff --git a/test/jest-e2e.config.cjs b/test/jest-e2e.config.cjs index bbd1c9c..3fc7d22 100644 --- a/test/jest-e2e.config.cjs +++ b/test/jest-e2e.config.cjs @@ -17,4 +17,5 @@ module.exports = { '^(\\.{1,2}/.*)\\.js$': '$1', }, maxWorkers: 1, + testTimeout: 30000, }; diff --git a/tyt-admin/components.d.ts b/tyt-admin/components.d.ts index b78f775..4dc3202 100644 --- a/tyt-admin/components.d.ts +++ b/tyt-admin/components.d.ts @@ -11,11 +11,14 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AssetUploadButton: typeof import('./src/components/AssetUploadButton.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] ElAside: typeof import('element-plus/es')['ElAside'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCol: typeof import('element-plus/es')['ElCol'] + ElCollapse: typeof import('element-plus/es')['ElCollapse'] + ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] @@ -30,6 +33,7 @@ declare module 'vue' { ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElHeader: typeof import('element-plus/es')['ElHeader'] ElIcon: typeof import('element-plus/es')['ElIcon'] + ElImage: typeof import('element-plus/es')['ElImage'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElLink: typeof import('element-plus/es')['ElLink'] @@ -47,10 +51,9 @@ declare module 'vue' { ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] - ElTimeline: typeof import('element-plus/es')['ElTimeline'] - ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTree: typeof import('element-plus/es')['ElTree'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] + ElUpload: typeof import('element-plus/es')['ElUpload'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/tyt-admin/src/api/tasks.js b/tyt-admin/src/api/tasks.js index 2b0960a..fbaf55d 100644 --- a/tyt-admin/src/api/tasks.js +++ b/tyt-admin/src/api/tasks.js @@ -1,13 +1,17 @@ import request from './request'; +export const getTasks = (params) => { + return request.get('/b/tasks', { params }); +}; + +export const getTaskEngineers = (params) => { + return request.get('/b/tasks/engineers', { params }); +}; + export const publishTask = (data) => { return request.post('/b/tasks/publish', data); }; -export const acceptTask = (data) => { - return request.post('/b/tasks/accept', data); -}; - export const completeTask = (data) => { return request.post('/b/tasks/complete', data); }; diff --git a/tyt-admin/src/api/uploads.js b/tyt-admin/src/api/uploads.js new file mode 100644 index 0000000..b934dd6 --- /dev/null +++ b/tyt-admin/src/api/uploads.js @@ -0,0 +1,19 @@ +import request from './request'; + +export const getUploadAssets = (params) => { + return request.get('/b/uploads', { params }); +}; + +export const uploadAsset = (file, hospitalId) => { + const formData = new FormData(); + formData.append('file', file); + if (hospitalId) { + formData.append('hospitalId', String(hospitalId)); + } + + return request.post('/b/uploads', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); +}; diff --git a/tyt-admin/src/components/AssetUploadButton.vue b/tyt-admin/src/components/AssetUploadButton.vue new file mode 100644 index 0000000..8fe27c6 --- /dev/null +++ b/tyt-admin/src/components/AssetUploadButton.vue @@ -0,0 +1,62 @@ + + + diff --git a/tyt-admin/src/constants/role-permissions.js b/tyt-admin/src/constants/role-permissions.js index ea0f9e6..580f47c 100644 --- a/tyt-admin/src/constants/role-permissions.js +++ b/tyt-admin/src/constants/role-permissions.js @@ -2,18 +2,21 @@ * 前端页面级权限矩阵:与后端 @Roles 约束保持一致,避免页面可见但接口被 403。 */ const ADMIN_ROLES = Object.freeze(['SYSTEM_ADMIN', 'HOSPITAL_ADMIN']); -const ORG_MANAGER_ROLES = Object.freeze([ +const ORG_MANAGER_ROLES = ADMIN_ROLES; +const USER_MANAGER_ROLES = Object.freeze([ 'SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', ]); -const USER_MANAGER_ROLES = Object.freeze([ +const TASK_ROLES = Object.freeze([ 'SYSTEM_ADMIN', 'HOSPITAL_ADMIN', + 'DOCTOR', 'DIRECTOR', + 'LEADER', + 'ENGINEER', ]); -const TASK_ROLES = Object.freeze(['DOCTOR', 'DIRECTOR', 'LEADER', 'ENGINEER']); const PATIENT_ROLES = Object.freeze([ 'SYSTEM_ADMIN', 'HOSPITAL_ADMIN', @@ -22,16 +25,18 @@ const PATIENT_ROLES = Object.freeze([ // 后端患者接口允许医生访问,页面侧也应放开,避免前端先把医生拦掉。 'DOCTOR', ]); +const UPLOAD_ROLES = ADMIN_ROLES; export const ROLE_PERMISSIONS = Object.freeze({ ORG_TREE: ORG_MANAGER_ROLES, ORG_HOSPITALS: Object.freeze(['SYSTEM_ADMIN']), // 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。 ORG_DEPARTMENTS: ADMIN_ROLES, - ORG_GROUPS: ORG_MANAGER_ROLES, + ORG_GROUPS: ADMIN_ROLES, DICTIONARIES: Object.freeze(['SYSTEM_ADMIN']), USERS: USER_MANAGER_ROLES, DEVICES: Object.freeze(['SYSTEM_ADMIN']), + UPLOADS: UPLOAD_ROLES, TASKS: TASK_ROLES, PATIENTS: PATIENT_ROLES, }); diff --git a/tyt-admin/src/layouts/AdminLayout.vue b/tyt-admin/src/layouts/AdminLayout.vue index 204b3bd..2abcfac 100644 --- a/tyt-admin/src/layouts/AdminLayout.vue +++ b/tyt-admin/src/layouts/AdminLayout.vue @@ -64,6 +64,11 @@ 设备管理 + + + 影像库 + + 任务管理 @@ -127,6 +132,7 @@ import { Connection, Share, Monitor, + Picture, CollectionTag, } from '@element-plus/icons-vue'; @@ -138,13 +144,15 @@ const activeMenu = computed(() => { return route.path; }); -const isDirector = computed(() => userStore.role === 'DIRECTOR'); const canAccessUsers = computed(() => hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS), ); const canAccessDevices = computed(() => hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES), ); +const canAccessUploads = computed(() => + hasRolePermission(userStore.role, ROLE_PERMISSIONS.UPLOADS), +); const canAccessDictionaries = computed(() => hasRolePermission(userStore.role, ROLE_PERMISSIONS.DICTIONARIES), ); @@ -161,7 +169,11 @@ const canAccessPatients = computed(() => hasRolePermission(userStore.role, ROLE_PERMISSIONS.PATIENTS), ); const usersMenuLabel = computed(() => - isDirector.value ? '医生管理' : '用户管理', + userStore.role === 'DIRECTOR' + ? '成员查看' + : userStore.role === 'LEADER' + ? '组员列表' + : '用户管理', ); const handleCommand = (command) => { diff --git a/tyt-admin/src/router/index.js b/tyt-admin/src/router/index.js index 4ad24c6..0f24f41 100644 --- a/tyt-admin/src/router/index.js +++ b/tyt-admin/src/router/index.js @@ -97,6 +97,16 @@ const routes = [ allowedRoles: ROLE_PERMISSIONS.DEVICES, }, }, + { + path: 'uploads', + name: 'Uploads', + component: () => import('../views/uploads/MediaLibrary.vue'), + meta: { + title: '影像库', + requiresAuth: true, + allowedRoles: ROLE_PERMISSIONS.UPLOADS, + }, + }, { path: 'tasks', name: 'Tasks', diff --git a/tyt-admin/src/views/Dashboard.vue b/tyt-admin/src/views/Dashboard.vue index 117e6da..e0c6f69 100644 --- a/tyt-admin/src/views/Dashboard.vue +++ b/tyt-admin/src/views/Dashboard.vue @@ -47,7 +47,7 @@ @@ -78,7 +78,7 @@ const canViewOrg = computed(() => ['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role), ); const canViewUsers = computed(() => - ['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role), + ['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role), ); const canViewPatients = computed(() => ['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR'].includes( @@ -100,7 +100,7 @@ const statCards = computed(() => { if (canViewUsers.value) { cards.push({ key: 'users', - title: userStore.role === 'DIRECTOR' ? '本科室医生数' : '用户总数', + title: '用户总数', value: stats.value.users, }); } @@ -146,7 +146,6 @@ const fetchDashboardData = async () => { const usersRes = await getUsers({ page: 1, pageSize: 1, - role: userStore.role === 'DIRECTOR' ? 'DOCTOR' : undefined, }); stats.value.users = usersRes.total ?? 0; } diff --git a/tyt-admin/src/views/devices/Devices.vue b/tyt-admin/src/views/devices/Devices.vue index de4cab6..7a5529c 100644 --- a/tyt-admin/src/views/devices/Devices.vue +++ b/tyt-admin/src/views/devices/Devices.vue @@ -53,13 +53,6 @@ - - -