diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be18fa39ab..16317f7d0f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,7 @@ jobs: build: name: Build runs-on: ubuntu-latest + timeout-minutes: 45 permissions: pull-requests: write strategy: @@ -72,6 +73,7 @@ jobs: eslint: name: Run eslint runs-on: ubuntu-latest + timeout-minutes: 20 needs: [build] env: SECRETS_TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} @@ -149,6 +151,7 @@ jobs: test: name: Run tests runs-on: ubuntu-latest + timeout-minutes: 45 needs: [build] strategy: matrix: @@ -175,6 +178,19 @@ jobs: --health-retries 5 ports: - 5432/tcp + postgres-seed: + image: postgres:${{ matrix.pg-version }} + env: + POSTGRES_DB: medplum_test + POSTGRES_USER: medplum + POSTGRES_PASSWORD: medplum + options: >- + --health-cmd pg_isready + --health-retries 5 + --health-interval 10s + --health-timeout 5s + ports: + - 5433/tcp:5432/tcp redis: image: redis:${{ matrix.redis-version }} options: >- @@ -222,6 +238,7 @@ jobs: env: POSTGRES_HOST: localhost POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} + POSTGRES_SEED_PORT: ${{ job.services.postgres.ports[5433] }} REDIS_PASSWORD_DISABLED_IN_TESTS: 1 - name: Upload code coverage if: ${{ matrix.node-version == 20 && matrix.pg-version == 14 && matrix.redis-version == 7 }} @@ -233,6 +250,7 @@ jobs: build-docs: name: Build the docs runs-on: ubuntu-latest + timeout-minutes: 30 permissions: pull-requests: write env: diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 0ef710c9b0..12caab0aef 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -5,6 +5,7 @@ on: push jobs: chromatic: runs-on: ubuntu-latest + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 25e8f21310..cf2161421b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,6 +9,7 @@ jobs: build: name: Deploy runs-on: ubuntu-latest + timeout-minutes: 45 if: github.repository == 'medplum/medplum' env: NODE_VERSION: '20' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 0e5895f1ae..47a085300e 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -11,6 +11,7 @@ jobs: prepare-release: name: Prepare release runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 831e07018f..d810a865d7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,7 @@ jobs: build_and_check: name: publish runs-on: ubuntu-latest + timeout-minutes: 30 env: NODE_VERSION: '20' SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -93,6 +94,7 @@ jobs: build_agent_win64: runs-on: windows-latest + timeout-minutes: 45 env: NODE_VERSION: '20' TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} @@ -168,6 +170,7 @@ jobs: build_agent_linux: runs-on: ubuntu-latest + timeout-minutes: 45 env: NODE_VERSION: '20' TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 90415c714b..cfdae40638 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -7,6 +7,7 @@ jobs: sonar: name: Sonar runs-on: ubuntu-latest + timeout-minutes: 30 if: github.event.workflow_run.conclusion == 'success' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 2567455bb4..414c7fa290 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -10,6 +10,7 @@ jobs: build: name: Staging runs-on: ubuntu-latest + timeout-minutes: 30 env: NODE_VERSION: '20' TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} diff --git a/.gitignore b/.gitignore index 3e6907b917..7423c47f81 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ packages/react/build-storybook.log # Jest code coverage coverage/ +packages/server/coverage-seed/ # TypeScript incremental build tsconfig.tsbuildinfo diff --git a/docker-compose.seed.yml b/docker-compose.seed.yml new file mode 100644 index 0000000000..17ecc8af88 --- /dev/null +++ b/docker-compose.seed.yml @@ -0,0 +1,15 @@ +version: '3.7' +services: + postgres-seed: + image: postgres:12 + restart: always + environment: + - POSTGRES_USER=medplum + - POSTGRES_PASSWORD=medplum + + volumes: + - ./postgres/postgres.conf:/usr/local/etc/postgres/postgres.conf + - ./postgres/:/docker-entrypoint-initdb.d/ + command: postgres -c config_file=/usr/local/etc/postgres/postgres.conf + ports: + - '5433:5432' diff --git a/docker-compose.yml b/docker-compose.yml index 6b072b5578..7726992e47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,19 @@ services: command: postgres -c config_file=/usr/local/etc/postgres/postgres.conf ports: - '5432:5432' + postgres-seed: + image: postgres:12 + restart: always + environment: + - POSTGRES_USER=medplum + - POSTGRES_PASSWORD=medplum + + volumes: + - ./postgres/postgres.conf:/usr/local/etc/postgres/postgres.conf + - ./postgres/:/docker-entrypoint-initdb.d/ + command: postgres -c config_file=/usr/local/etc/postgres/postgres.conf + ports: + - '5433:5432' redis: image: redis:7 restart: always diff --git a/examples/foomedical/package.json b/examples/foomedical/package.json index ce42f06190..ca6a44c410 100644 --- a/examples/foomedical/package.json +++ b/examples/foomedical/package.json @@ -1,6 +1,6 @@ { "name": "foomedical", - "version": "3.1.2", + "version": "3.1.3", "type": "module", "scripts": { "build": "tsc && vite build", @@ -25,21 +25,21 @@ "@babel/preset-env": "7.24.4", "@babel/preset-react": "7.24.1", "@babel/preset-typescript": "7.24.1", - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", "@testing-library/jest-dom": "6.4.2", - "@testing-library/react": "14.3.0", + "@testing-library/react": "15.0.2", "@types/jest": "29.5.12", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "babel-jest": "29.7.0", "c8": "9.1.0", @@ -49,12 +49,12 @@ "jest-environment-jsdom": "29.7.0", "jest-transform-stub": "2.0.0", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-chartjs-2": "5.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-chart-demo/package.json b/examples/medplum-chart-demo/package.json index c7aeb5cb0e..6e1999d7d0 100644 --- a/examples/medplum-chart-demo/package.json +++ b/examples/medplum-chart-demo/package.json @@ -1,6 +1,6 @@ { "name": "medplum-chart-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -19,24 +19,24 @@ ] }, "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-chat-demo/.gitattributes b/examples/medplum-chat-demo/.gitattributes new file mode 100644 index 0000000000..9758f1aa1f --- /dev/null +++ b/examples/medplum-chat-demo/.gitattributes @@ -0,0 +1,25 @@ +# Auto detect text files and perform LF normalization +* text=auto + +*.java text eol=lf diff=java +*.html text eol=lf diff=html +*.css text eol=lf +*.js text eol=lf +*.ts text eol=lf +*.sh text eol=lf +*.sql text eol=lf +*.xml text eol=lf + +*.cur binary +*.gif binary +*.ico binary +*.jar binary +*.jpg binary +*.jpeg binary +*.png binary +*.xcf binary +*.zip binary + +package-lock.json -diff +package-lock.json linguist-generated=true + diff --git a/examples/medplum-chat-demo/.github/dependabot.yml b/examples/medplum-chat-demo/.github/dependabot.yml new file mode 100644 index 0000000000..4d7656afdc --- /dev/null +++ b/examples/medplum-chat-demo/.github/dependabot.yml @@ -0,0 +1,13 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: 'npm' # See documentation for possible values + directory: '/' # Location of package manifests + schedule: + interval: 'weekly' + allow: + - dependency-name: '@medplum/*' diff --git a/examples/medplum-chat-demo/.gitignore b/examples/medplum-chat-demo/.gitignore new file mode 100644 index 0000000000..b02a1ff770 --- /dev/null +++ b/examples/medplum-chat-demo/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +package-lock.json + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/medplum-chat-demo/README.md b/examples/medplum-chat-demo/README.md new file mode 100644 index 0000000000..e054a78075 --- /dev/null +++ b/examples/medplum-chat-demo/README.md @@ -0,0 +1,63 @@ +

Medplum Chat Demo

+

A starter application for using the Medplum platform.

+

+ + + +

+ +This example app demonstrates the following: + +- Creating a new React app with Vite and TypeScript to demonstrate [`Communication`](/packages/docs/api/fhir/resources/communication)-based workflows. +- A threaded [`Communiaction`](/docs/api/fhir/resources/communication) model. +- Creating thread-level and message-level [`Communication`](/packages/docs/api/fhir/resources/communication) resources. +- Sending and replying to messages. +- Realtime communication via WebSockets. +- Adding new participants to existing threads. +- Editing thread topics and categories. + +![Chat Demo Screenshot](medplum-chat-demo-screenshot.png) + +### Code Organization + +This repo is organized into two main directories: `src` and `data`. + +The `src` directory contains the app, including a `pages` and `components` directory. In addition, it contains a `bots` directory, which has [Medplum Bots](/packages/docs/docs/bots/bot-basics.md) for use. + +The `data` directory contains data that can be uploaded for use in the demo. The `example` directory contains data that is meant to be used for testing and learning, while the `core` directory contains resources, terminologies, and more that are necessary to use the demo. + +### Getting Started + +If you haven't already done so, follow the instructions in [this tutorial](https://www.medplum.com/docs/tutorials/register) to register a Medplum project to store your data. + +[Fork](https://github.com/medplum/medplum/fork) and clone the main Medplum repo. + +Move into the `medplum-chat-demo` directory. + +```bash +cd examples/medplum-chat-demo +``` + +Next, install the dependencies + +```bash +npm install +``` + +Then, run the app + +```bash +npm run dev +``` + +This app should run on `http://localhost:3000/` + +### About Medplum + +[Medplum](https://www.medplum.com/) is an open-source, API-first EHR. Medplum makes it easy to build healthcare apps quickly with less code. + +Medplum supports self-hosting, and provides a [hosted service](https://app.medplum.com/). Medplum Hello World uses the hosted service as a backend. + +- Read our [documentation](https://www.medplum.com/docs) +- Browse our [react component library](https://docs.medplum.com/storybook/index.html?) +- Join our [Discord](https://discord.gg/medplum) diff --git a/examples/medplum-chat-demo/data/example/example-data.json b/examples/medplum-chat-demo/data/example/example-data.json new file mode 100644 index 0000000000..e3f7f04a25 --- /dev/null +++ b/examples/medplum-chat-demo/data/example/example-data.json @@ -0,0 +1,2864 @@ +{ + "resourceType": "Bundle", + "type": "batch", + "entry": [ + { + "fullUrl": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", + "request": { "method": "POST", "url": "Patient" }, + "resource": { + "resourceType": "Patient", + "active": true, + "name": [{ "family": "Simpson", "given": ["Homer"] }], + "gender": "male", + "birthDate": "1956-05-12", + "address": [ + { + "use": "home", + "line": ["742 Evergreen Terrace"], + "city": "Springfield" + } + ] + } + }, + { + "fullUrl": "urn:uuid:6ac7de0c-4f35-4024-8942-639d761caa44", + "request": { "method": "POST", "url": "Condition" }, + "resource": { + "resourceType": "Condition", + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + } + ] + }, + "verificationStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed", + "display": "Confirmed" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "problem-list-item", + "display": "Problem List Item" + } + ] + } + ], + "code": { + "coding": [ + { + "code": "Obesity", + "display": "Obesity" + } + ] + }, + "subject": { + "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", + "display": "Homer Simpson" + } + } + }, + { + "fullUrl": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", + "request": { "method": "POST", "url": "Practitioner" }, + "resource": { + "resourceType": "Practitioner", + "active": true, + "name": [{ "family": "Smith", "given": ["Alice"], "prefix": ["Dr."] }], + "gender": "female", + "qualification": [ + { + "code": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0360", + "code": "MD", + "display": "Doctor of Medicine" + } + ] + } + } + ] + } + }, + { + "fullUrl": "urn:uuid:5e18d560-9223-4f43-a133-76a30200315d", + "request": { "method": "POST", "url": "Communication" }, + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "recipient": [ + { "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" }, + { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" } + ], + "sender": { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" }, + "topic": { "text": "Blood Test for Peanut Allergy" }, + "category": [{ "coding": [{ "display": "Lab Tests" }] }], + "subject": { "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" } + } + }, + { + "fullUrl": "urn:uuid:8f5c2717-7f5c-4c5c-a4a7-cf1047e4443e", + "request": { "method": "POST", "url": "Communication" }, + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "partOf": [{ "reference": "urn:uuid:5e18d560-9223-4f43-a133-76a30200315d" }], + "sender": { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" }, + "recipient": [{ "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" }], + "topic": { "coding": [{ "display": "Blood Test for Peanut Allergy" }] }, + "category": [{ "coding": [{ "display": "Lab Tests" }] }], + "sent": "2024-03-21T20:28:42.148Z", + "payload": [ + { "contentString": "Hi Homer, your blood work just came back. Would you like to come in to talk about it?" } + ] + } + }, + { + "fullUrl": "urn:uuid:09ab29bd-934f-4f99-b3c5-8a35f85e312b", + "request": { "method": "POST", "url": "Communication" }, + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "partOf": [{ "reference": "urn:uuid:5e18d560-9223-4f43-a133-76a30200315d" }], + "recipient": [{ "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" }], + "sender": { "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" }, + "topic": { "coding": [{ "display": "Blood Test for Peanut Allergy" }] }, + "category": [{ "coding": [{ "display": "Lab Tests" }] }], + "sent": "2024-03-21T20:38:42.148Z", + "inResponseTo": [{ "reference": "urn:uuid:8f5c2717-7f5c-4c5c-a4a7-cf1047e4443e" }], + "payload": [{ "contentString": "Just tell me doc, I can't bear to wait any longer." }] + } + }, + { + "fullUrl": "urn:uuid:70546170-7f0a-4f95-88f4-1f908bd0a708", + "request": { "method": "POST", "url": "Communication" }, + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "partOf": [{ "reference": "urn:uuid:5e18d560-9223-4f43-a133-76a30200315d" }], + "sender": { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" }, + "recipient": [{ "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" }], + "topic": { "coding": [{ "display": "Blood Test for Peanut Allergy" }] }, + "category": [{ "coding": [{ "display": "Lab Tests" }] }], + "sent": "2024-03-21T20:40:42.148Z", + "inResponseTo": [{ "reference": "urn:uuid:09ab29bd-934f-4f99-b3c5-8a35f85e312b" }], + "payload": [ + { + "contentString": "If you insist... Unfortunately, the results came back positive. You're allergic to peanuts." + } + ] + } + }, + { + "fullUrl": "urn:uuid:6bcf025a-c8b0-483e-8399-e3e9f43bd944", + "request": { "method": "POST", "url": "Practitioner" }, + "resource": { + "resourceType": "Practitioner", + "active": true, + "name": [{ "family": "House", "given": ["Gregory"], "prefix": ["Dr."] }], + "gender": "male", + "qualification": [ + { + "code": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0360", + "code": "MD", + "display": "Doctor of Medicine" + } + ] + } + } + ] + } + }, + { + "fullUrl": "urn:uuid:5be59c7f-7b82-49d3-9183-46d1a9c385c6", + "request": { "method": "POST", "url": "Practitioner" }, + "resource": { + "resourceType": "Practitioner", + "active": true, + "name": [{ "family": "Peyton", "given": ["Jackie"] }], + "gender": "female", + "qualification": [ + { + "code": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0360", + "code": "RN", + "display": "Registered Nurse" + } + ] + } + } + ] + } + }, + { + "fullUrl": "urn:uuid:d2fe5b7d-98f5-4126-bd25-0dbb2ade1ff2", + "request": { "method": "POST", "url": "Communication" }, + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "sender": { "reference": "urn:uuid:6bcf025a-c8b0-483e-8399-e3e9f43bd944", "display": "Dr. Gregory House" }, + "recipient": [ + { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" }, + { "reference": "urn:uuid:5be59c7f-7b82-49d3-9183-46d1a9c385c6", "display": "Nurse Jackie" }, + { "reference": "urn:uuid:6bcf025a-c8b0-483e-8399-e3e9f43bd944", "display": "Dr. Gregory House" } + ], + "topic": { "coding": [{ "display": "Capturing Homer's vitals" }] }, + "category": [{ "coding": [{ "display": "Vital Signs" }] }], + "subject": { "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" } + } + }, + { + "fullUrl": "urn:uuid:b82f3090-82bb-4102-9081-f9113fe81ff9", + "request": { "method": "POST", "url": "Communication" }, + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "sender": { "reference": "urn:uuid:6bcf025a-c8b0-483e-8399-e3e9f43bd944", "display": "Dr. Gregory House" }, + "sent": "2024-03-22T20:28:42.148Z", + "recipient": [ + { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" }, + { "reference": "urn:uuid:5be59c7f-7b82-49d3-9183-46d1a9c385c6", "display": "Nurse Jackie" }, + { "reference": "urn:uuid:6bcf025a-c8b0-483e-8399-e3e9f43bd944", "display": "Dr. Gregory House" } + ], + "partOf": [{ "reference": "urn:uuid:d2fe5b7d-98f5-4126-bd25-0dbb2ade1ff2" }], + "topic": { "coding": [{ "display": "Capturing Homer's vitals" }] }, + "category": [{ "coding": [{ "display": "Vital Signs" }] }], + "subject": { "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" }, + "payload": [ + { "contentString": "Are either of available to take Homer's vital signs before his physical tomorrow?" } + ] + } + }, + { + "fullUrl": "urn:uuid:f7c6f976-edf9-4081-b179-f1dbb5347f13", + "request": { "method": "POST", "url": "Communication" }, + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "sender": { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111" }, + "sent": "2024-03-22T20:55:18.148Z", + "recipient": [ + { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" }, + { "reference": "urn:uuid:5be59c7f-7b82-49d3-9183-46d1a9c385c6", "display": "Nurse Jackie" }, + { "reference": "urn:uuid:6bcf025a-c8b0-483e-8399-e3e9f43bd944", "display": "Dr. Gregory House" } + ], + "partOf": [{ "reference": "urn:uuid:d2fe5b7d-98f5-4126-bd25-0dbb2ade1ff2" }], + "inResponseTo": [{ "reference": "urn:uuid:b82f3090-82bb-4102-9081-f9113fe81ff9" }], + "topic": { "coding": [{ "display": "Capturing Homer's vitals" }] }, + "category": [{ "coding": [{ "display": "Vital Signs" }] }], + "subject": { "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" }, + "payload": [{ "contentString": "I have the day off tomorrow, so I cannot." }] + } + }, + { + "fullUrl": "urn:uuid:35bf5aa3-6a85-400e-bce4-a114fb60f8b9", + "request": { "method": "POST", "url": "Communication" }, + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "sender": { "reference": "urn:uuid:5be59c7f-7b82-49d3-9183-46d1a9c385c6" }, + "sent": "2024-03-22T21:13:33.148Z", + "recipient": [ + { "reference": "urn:uuid:3bf50fa0-9c13-48ca-9cd7-1379dbf5f111", "display": "Dr. Alice Smith" }, + { "reference": "urn:uuid:5be59c7f-7b82-49d3-9183-46d1a9c385c6", "display": "Nurse Jackie" }, + { "reference": "urn:uuid:6bcf025a-c8b0-483e-8399-e3e9f43bd944", "display": "Dr. Gregory House" } + ], + "partOf": [{ "reference": "urn:uuid:d2fe5b7d-98f5-4126-bd25-0dbb2ade1ff2" }], + "inResponseTo": [{ "reference": "urn:uuid:f7c6f976-edf9-4081-b179-f1dbb5347f13" }], + "topic": { "coding": [{ "display": "Capturing Homer's vitals" }] }, + "category": [{ "coding": [{ "display": "Vital Signs" }] }], + "subject": { "reference": "urn:uuid:ec42014b-63af-407e-ba31-c5b080c527a4", "display": "Homer Simpson" }, + "payload": [{ "contentString": "I can do it, but it will have to be quick and he must be on time." }] + } + }, + { + "fullUrl": "urn:uuid:23b65226-f1fd-424b-90c7-af2914dd6eba", + "request": { "method": "POST", "url": "ValueSet" }, + "resource": { + "resourceType": "ValueSet", + "status": "active", + "url": "https://example.org/thread-categories", + "name": "thread-category-codes", + "title": "Thread Category Codes", + "description": "These codes identify categories to be help organize threads.", + "compose": { + "include": [ + { + "system": "http://snomed.info/sct", + "concept": [ + { "code": "19388002", "display": "Physical" }, + { "code": "33879002", "display": "Vaccination" }, + { "code": "363680008", "display": "Diagnostic imaging procedure using X-rays" }, + { "code": "396550006", "display": "Blood test (procedure)" }, + { "code": "16076005", "display": "Prescription (procedure)" }, + { "code": "225362009", "display": "Dental care" }, + { "code": "386243005", "display": "Vision care" }, + { "code": "390808007", "display": "Mental health care" }, + { "code": "722138006", "display": "Physical therapy" }, + { "code": "394582007", "display": "Dermatology" }, + { "code": "408439002", "display": "Allergy" }, + { "code": "394579002", "display": "Cardiology" }, + { "code": "394801008", "display": "Trauma and orthopedics" }, + { "code": "408470005", "display": "Obstetrics" }, + { "code": "394586005", "display": "Gynecology" }, + { "code": "394592004", "display": "Clinical oncology" }, + { "code": "726527001", "display": "Weight" }, + { "code": "52052004", "display": "Rehabilitation therapy" }, + { "code": "408450004", "display": "Sleep studies" }, + { "code": "722164000", "display": "Dietetics and nutrition" }, + { "code": "710081004", "display": "Smoking cessation therapy" }, + { "code": "91193004", "display": "Hearing therapy" }, + { "code": "722166003", "display": "Podiatry" }, + { + "code": "106289002", + "display": "Dentist (occupation)" + }, + { + "code": "106292003", + "display": "Professional nurse (occupation)" + }, + { + "code": "106293008", + "display": "Nursing personnel (occupation)" + }, + { + "code": "106294002", + "display": "Midwifery personnel (occupation)" + }, + { + "code": "106296000", + "display": "Physiotherapist/occupational therapist (occupation)" + }, + { + "code": "106310008", + "display": "Worker in religion (occupation)" + }, + { + "code": "106311007", + "display": "Minister of religion/related member of religious order (occupation)" + }, + { + "code": "106330007", + "display": "Philologist, translator/interpreter (occupation)" + }, + { + "code": "11015003", + "display": "Minister of religion (occupation)" + }, + { + "code": "116154003", + "display": "Patient (person)" + }, + { + "code": "11661002", + "display": "Neuropathologist (occupation)" + }, + { + "code": "1172950003", + "display": "Massage therapist (occupation)" + }, + { + "code": "1186716007", + "display": "Intellectual disability psychiatrist (occupation)" + }, + { + "code": "1186914001", + "display": "Intellectual disability nurse (occupation)" + }, + { + "code": "11911009", + "display": "Nephrologist (occupation)" + }, + { + "code": "119246008", + "display": "Imam (occupation)" + }, + { + "code": "11935004", + "display": "Obstetrician (occupation)" + }, + { + "code": "1251537007", + "display": "Sport medicine specialist (occupation)" + }, + { + "code": "1251542004", + "display": "Medical coder (occupation)" + }, + { + "code": "1251548000", + "display": "Neuroradiologist (occupation)" + }, + { + "code": "1254982001", + "display": "Medical surgical nurse (occupation)" + }, + { + "code": "1254983006", + "display": "Chronic care nurse (occupation)" + }, + { + "code": "1254984000", + "display": "Rehabilitation nurse (occupation)" + }, + { + "code": "1255370008", + "display": "Specialist in naturopathy (occupation)" + }, + { + "code": "1255371007", + "display": "Specialist in homeopathy (occupation)" + }, + { + "code": "1255372000", + "display": "Phytotherapist (occupation)" + }, + { + "code": "1255373005", + "display": "Specialist in traditional Chinese medicine (occupation)" + }, + { + "code": "1255374004", + "display": "Clinical nutritionist (occupation)" + }, + { + "code": "1255514008", + "display": "Regulatory affairs pharmacist (occupation)" + }, + { + "code": "1255515009", + "display": "Pharmacogenomics pharmacist (occupation)" + }, + { + "code": "1255517001", + "display": "Intern in healthcare (occupation)" + }, + { + "code": "1255518006", + "display": "Organizational and social psychologist (occupation)" + }, + { + "code": "1255519003", + "display": "Cardiopulmonary technician (occupation)" + }, + { + "code": "1255719001", + "display": "Neurophysiology technician (occupation)" + }, + { + "code": "1256114007", + "display": "Nuclear medicine technologist (occupation)" + }, + { + "code": "1259214004", + "display": "Immunohemotherapy specialist (occupation)" + }, + { + "code": "1259964002", + "display": "Oral medicine specialist (occupation)" + }, + { + "code": "1268923002", + "display": "Obstetric nurse (occupation)" + }, + { + "code": "1271000175101", + "display": "Primary obstetrician (occupation)" + }, + { + "code": "1276561000168102", + "display": "Prosthetist (occupation)" + }, + { + "code": "1276571000168108", + "display": "Orthotist and prosthetist (occupation)" + }, + { + "code": "133932002", + "display": "Caregiver (person)" + }, + { + "code": "13580004", + "display": "School dental assistant (occupation)" + }, + { + "code": "1421009", + "display": "Specialized surgeon (occupation)" + }, + { + "code": "14613005", + "display": "Ordained rabbi (occupation)" + }, + { + "code": "14698002", + "display": "Medical microbiologist (occupation)" + }, + { + "code": "158939004", + "display": "Child care officer (occupation)" + }, + { + "code": "158942005", + "display": "Residential child care worker (occupation)" + }, + { + "code": "158943000", + "display": "Residential youth care worker (occupation)" + }, + { + "code": "158965000", + "display": "Medical practitioner (occupation)" + }, + { + "code": "158966004", + "display": "Medical administrator - national (occupation)" + }, + { + "code": "158967008", + "display": "Consultant physician (occupation)" + }, + { + "code": "158968003", + "display": "Consultant surgeon (occupation)" + }, + { + "code": "158969006", + "display": "Consultant gynecology/obstetrics (occupation)" + }, + { + "code": "158971006", + "display": "Hospital registrar (occupation)" + }, + { + "code": "158972004", + "display": "House officer (occupation)" + }, + { + "code": "158973009", + "display": "Occupational physician (occupation)" + }, + { + "code": "158974003", + "display": "Clinical medical officer (occupation)" + }, + { + "code": "158975002", + "display": "Medical practitioner - teaching (occupation)" + }, + { + "code": "158977005", + "display": "Dental administrator (occupation)" + }, + { + "code": "158978000", + "display": "Dental consultant (occupation)" + }, + { + "code": "158979008", + "display": "Dental general practitioner (occupation)" + }, + { + "code": "158980006", + "display": "Dental practitioner - teaching (occupation)" + }, + { + "code": "158983008", + "display": "Nurse administrator - national (occupation)" + }, + { + "code": "158984002", + "display": "Nursing officer - region (occupation)" + }, + { + "code": "158985001", + "display": "Nursing officer - district (occupation)" + }, + { + "code": "158986000", + "display": "Nursing administrator - professional body (occupation)" + }, + { + "code": "158987009", + "display": "Nursing officer - division (occupation)" + }, + { + "code": "158988004", + "display": "Nurse education director (occupation)" + }, + { + "code": "158989007", + "display": "Occupational health nursing officer (occupation)" + }, + { + "code": "158990003", + "display": "Nursing officer (occupation)" + }, + { + "code": "158992006", + "display": "Midwifery sister (occupation)" + }, + { + "code": "158993001", + "display": "Nursing sister (theater) (occupation)" + }, + { + "code": "158994007", + "display": "Staff nurse (occupation)" + }, + { + "code": "158995008", + "display": "Staff midwife (occupation)" + }, + { + "code": "158996009", + "display": "State enrolled nurse (occupation)" + }, + { + "code": "158997000", + "display": "District nurse (occupation)" + }, + { + "code": "158998005", + "display": "Private nurse (occupation)" + }, + { + "code": "158999002", + "display": "Community midwife (occupation)" + }, + { + "code": "159001001", + "display": "Clinic nurse (occupation)" + }, + { + "code": "159002008", + "display": "Practice nurse (occupation)" + }, + { + "code": "159003003", + "display": "School nurse (occupation)" + }, + { + "code": "159004009", + "display": "Nurse teacher (occupation)" + }, + { + "code": "159005005", + "display": "Student nurse (occupation)" + }, + { + "code": "159006006", + "display": "Dental nurse (occupation)" + }, + { + "code": "159007002", + "display": "Community pediatric nurse (occupation)" + }, + { + "code": "159010009", + "display": "Hospital pharmacist (occupation)" + }, + { + "code": "159011008", + "display": "Retail pharmacist (occupation)" + }, + { + "code": "159012001", + "display": "Industrial pharmacist (occupation)" + }, + { + "code": "159014000", + "display": "Trainee pharmacist (occupation)" + }, + { + "code": "159016003", + "display": "Medical radiographer (occupation)" + }, + { + "code": "159017007", + "display": "Diagnostic radiographer (occupation)" + }, + { + "code": "159018002", + "display": "Therapeutic radiographer (occupation)" + }, + { + "code": "159019005", + "display": "Trainee radiographer (occupation)" + }, + { + "code": "159021000", + "display": "Ophthalmic optician (occupation)" + }, + { + "code": "159022007", + "display": "Trainee optician (occupation)" + }, + { + "code": "159025009", + "display": "Remedial gymnast (occupation)" + }, + { + "code": "159026005", + "display": "Speech/language therapist (occupation)" + }, + { + "code": "159027001", + "display": "Orthoptist (occupation)" + }, + { + "code": "159028006", + "display": "Trainee remedial therapist (occupation)" + }, + { + "code": "159033005", + "display": "Dietitian (occupation)" + }, + { + "code": "159034004", + "display": "Podiatrist (occupation)" + }, + { + "code": "159035003", + "display": "Dental auxiliary (occupation)" + }, + { + "code": "159036002", + "display": "Electrocardiogram technician (occupation)" + }, + { + "code": "159037006", + "display": "Electroencephalogram technician (occupation)" + }, + { + "code": "159038001", + "display": "Artificial limb fitter (occupation)" + }, + { + "code": "159039009", + "display": "Audiology technician (occupation)" + }, + { + "code": "159040006", + "display": "Pharmacy technician (occupation)" + }, + { + "code": "159041005", + "display": "Trainee medical technician (occupation)" + }, + { + "code": "159141008", + "display": "Geneticist (occupation)" + }, + { + "code": "159148002", + "display": "Research chemist (occupation)" + }, + { + "code": "159174008", + "display": "Civil engineer - research (occupation)" + }, + { + "code": "159972006", + "display": "Surgical corset fitter (occupation)" + }, + { + "code": "160008000", + "display": "Dental technician (occupation)" + }, + { + "code": "17561000", + "display": "Cardiologist (occupation)" + }, + { + "code": "184152007", + "display": "Care assistant (occupation)" + }, + { + "code": "184154008", + "display": "Care manager (occupation)" + }, + { + "code": "18803008", + "display": "Dermatologist (occupation)" + }, + { + "code": "18850004", + "display": "Laboratory hematologist (occupation)" + }, + { + "code": "19244007", + "display": "Gerodontist (occupation)" + }, + { + "code": "20145008", + "display": "Removable prosthodontist (occupation)" + }, + { + "code": "21365001", + "display": "Specialized dentist (occupation)" + }, + { + "code": "21450003", + "display": "Neuropsychiatrist (occupation)" + }, + { + "code": "224529009", + "display": "Clinical assistant (occupation)" + }, + { + "code": "224530004", + "display": "Senior registrar (occupation)" + }, + { + "code": "224531000", + "display": "Registrar (occupation)" + }, + { + "code": "224532007", + "display": "Senior house officer (occupation)" + }, + { + "code": "224533002", + "display": "Medical officer (occupation)" + }, + { + "code": "224534008", + "display": "Health visitor, nurse/midwife (occupation)" + }, + { + "code": "224535009", + "display": "Registered nurse (occupation)" + }, + { + "code": "224536005", + "display": "Midwifery tutor (occupation)" + }, + { + "code": "224537001", + "display": "Accident and Emergency nurse (occupation)" + }, + { + "code": "224538006", + "display": "Triage nurse (occupation)" + }, + { + "code": "224540001", + "display": "Community nurse (occupation)" + }, + { + "code": "224541002", + "display": "Nursing continence advisor (occupation)" + }, + { + "code": "224542009", + "display": "Coronary care nurse (occupation)" + }, + { + "code": "224543004", + "display": "Diabetic nurse (occupation)" + }, + { + "code": "224544005", + "display": "Family planning nurse (occupation)" + }, + { + "code": "224545006", + "display": "Care of the elderly nurse (occupation)" + }, + { + "code": "224546007", + "display": "Infection control nurse (occupation)" + }, + { + "code": "224547003", + "display": "Intensive therapy nurse (occupation)" + }, + { + "code": "224548008", + "display": "Learning disabilities nurse (occupation)" + }, + { + "code": "224549000", + "display": "Neonatal nurse (occupation)" + }, + { + "code": "224550000", + "display": "Neurology nurse (occupation)" + }, + { + "code": "224551001", + "display": "Industrial nurse (occupation)" + }, + { + "code": "224552008", + "display": "Oncology nurse (occupation)" + }, + { + "code": "224554009", + "display": "Marie Curie nurse (occupation)" + }, + { + "code": "224555005", + "display": "Pain control nurse (occupation)" + }, + { + "code": "224556006", + "display": "Palliative care nurse (occupation)" + }, + { + "code": "224557002", + "display": "Chemotherapy nurse (occupation)" + }, + { + "code": "224558007", + "display": "Radiotherapy nurse (occupation)" + }, + { + "code": "224559004", + "display": "Recovery nurse (occupation)" + }, + { + "code": "224560009", + "display": "Stoma care nurse (occupation)" + }, + { + "code": "224562001", + "display": "Pediatric nurse (occupation)" + }, + { + "code": "224563006", + "display": "Mental health nurse (occupation)" + }, + { + "code": "224564000", + "display": "Community mental health nurse (occupation)" + }, + { + "code": "224565004", + "display": "Renal nurse (occupation)" + }, + { + "code": "224566003", + "display": "Hemodialysis nurse (occupation)" + }, + { + "code": "224567007", + "display": "Tissue viability nurse (occupation)" + }, + { + "code": "224569005", + "display": "Nurse grade (occupation)" + }, + { + "code": "224570006", + "display": "Clinical nurse specialist (occupation)" + }, + { + "code": "224571005", + "display": "Nurse practitioner (occupation)" + }, + { + "code": "224572003", + "display": "Nursing sister (occupation)" + }, + { + "code": "224573008", + "display": "Charge nurse (occupation)" + }, + { + "code": "224574002", + "display": "Ward manager (occupation)" + }, + { + "code": "224575001", + "display": "Nursing team leader (occupation)" + }, + { + "code": "224576000", + "display": "Nursing assistant (occupation)" + }, + { + "code": "224577009", + "display": "Healthcare assistant (occupation)" + }, + { + "code": "224578004", + "display": "Nursery nurse (occupation)" + }, + { + "code": "224579007", + "display": "Healthcare service manager (occupation)" + }, + { + "code": "224580005", + "display": "Occupational health service manager (occupation)" + }, + { + "code": "224581009", + "display": "Community nurse manager (occupation)" + }, + { + "code": "224583007", + "display": "Behavior therapist (occupation)" + }, + { + "code": "224584001", + "display": "Behavior therapy assistant (occupation)" + }, + { + "code": "224585000", + "display": "Drama therapist (occupation)" + }, + { + "code": "224586004", + "display": "Domiciliary occupational therapist (occupation)" + }, + { + "code": "224587008", + "display": "Occupational therapy helper (occupation)" + }, + { + "code": "224588003", + "display": "Psychotherapist (occupation)" + }, + { + "code": "224589006", + "display": "Community-based physiotherapist (occupation)" + }, + { + "code": "224590002", + "display": "Play therapist (occupation)" + }, + { + "code": "224591003", + "display": "Play specialist (occupation)" + }, + { + "code": "224592005", + "display": "Play leader (occupation)" + }, + { + "code": "224593000", + "display": "Community-based speech/language therapist (occupation)" + }, + { + "code": "224594006", + "display": "Speech/language assistant (occupation)" + }, + { + "code": "224595007", + "display": "Professional counselor (occupation)" + }, + { + "code": "224596008", + "display": "Marriage guidance counselor (occupation)" + }, + { + "code": "224597004", + "display": "Trained nurse counselor (occupation)" + }, + { + "code": "224598009", + "display": "Trained social worker counselor (occupation)" + }, + { + "code": "224599001", + "display": "Trained personnel counselor (occupation)" + }, + { + "code": "224600003", + "display": "Psychoanalyst (occupation)" + }, + { + "code": "224601004", + "display": "Assistant psychologist (occupation)" + }, + { + "code": "224602006", + "display": "Community-based podiatrist (occupation)" + }, + { + "code": "224603001", + "display": "Foot care worker (occupation)" + }, + { + "code": "224604007", + "display": "Audiometrician (occupation)" + }, + { + "code": "224606009", + "display": "Technical healthcare occupation (occupation)" + }, + { + "code": "224607000", + "display": "Occupational therapy technical instructor (occupation)" + }, + { + "code": "224608005", + "display": "Administrative healthcare staff (occupation)" + }, + { + "code": "224609002", + "display": "Complementary health worker (occupation)" + }, + { + "code": "224610007", + "display": "Supporting services personnel (occupation)" + }, + { + "code": "224614003", + "display": "Research associate (occupation)" + }, + { + "code": "224615002", + "display": "Research nurse (occupation)" + }, + { + "code": "224620002", + "display": "Human aid to communication (occupation)" + }, + { + "code": "224621003", + "display": "Palantypist (occupation)" + }, + { + "code": "224622005", + "display": "Note taker (occupation)" + }, + { + "code": "224623000", + "display": "Cuer (occupation)" + }, + { + "code": "224624006", + "display": "Lipspeaker (occupation)" + }, + { + "code": "224625007", + "display": "Interpreter for British sign language (occupation)" + }, + { + "code": "224626008", + "display": "Interpreter for Signs supporting English (occupation)" + }, + { + "code": "224936003", + "display": "General practitioner locum (occupation)" + }, + { + "code": "22515006", + "display": "Medical assistant (occupation)" + }, + { + "code": "225725005", + "display": "Chaplain (occupation)" + }, + { + "code": "225726006", + "display": "Lactation consultant (occupation)" + }, + { + "code": "225727002", + "display": "Midwife counselor (occupation)" + }, + { + "code": "22731001", + "display": "Orthopedic surgeon (occupation)" + }, + { + "code": "229774002", + "display": "Caregiver (occupation)" + }, + { + "code": "22983004", + "display": "Thoracic surgeon (occupation)" + }, + { + "code": "23278007", + "display": "Community health physician (occupation)" + }, + { + "code": "24430003", + "display": "Physical medicine specialist (occupation)" + }, + { + "code": "24590004", + "display": "Urologist (occupation)" + }, + { + "code": "25941000087102", + "display": "Adult gerontology primary care nurse practitioner (occupation)" + }, + { + "code": "25961008", + "display": "Electroencephalography specialist (occupation)" + }, + { + "code": "26031000087100", + "display": "Pediatric nurse practitioner (occupation)" + }, + { + "code": "26042002", + "display": "Dental hygienist (occupation)" + }, + { + "code": "26071000087103", + "display": "Primary health care nurse practitioner (occupation)" + }, + { + "code": "26091000087104", + "display": "Public health nurse practitioner (occupation)" + }, + { + "code": "26369006", + "display": "Public health nurse (occupation)" + }, + { + "code": "265937000", + "display": "Nursing occupation (occupation)" + }, + { + "code": "265939002", + "display": "Medical/dental technicians (occupation)" + }, + { + "code": "28229004", + "display": "Optometrist (occupation)" + }, + { + "code": "283875005", + "display": "Parkinson's disease nurse (occupation)" + }, + { + "code": "28411006", + "display": "Neonatologist (occupation)" + }, + { + "code": "28544002", + "display": "Medical biochemist (occupation)" + }, + { + "code": "302211009", + "display": "Specialist registrar (occupation)" + }, + { + "code": "303124005", + "display": "Member of mental health review tribunal (occupation)" + }, + { + "code": "303129000", + "display": "Hospital manager (occupation)" + }, + { + "code": "303133007", + "display": "Responsible medical officer (occupation)" + }, + { + "code": "303134001", + "display": "Independent doctor (occupation)" + }, + { + "code": "304291006", + "display": "Bereavement counselor (occupation)" + }, + { + "code": "304292004", + "display": "Surgeon (occupation)" + }, + { + "code": "307988006", + "display": "Medical technician (occupation)" + }, + { + "code": "308002005", + "display": "Remedial therapist (occupation)" + }, + { + "code": "309294001", + "display": "Emergency department physician (occupation)" + }, + { + "code": "309295000", + "display": "Clinical oncologist (occupation)" + }, + { + "code": "309296004", + "display": "Family planning doctor (occupation)" + }, + { + "code": "309322005", + "display": "Associate general practitioner (occupation)" + }, + { + "code": "309323000", + "display": "Partner of general practitioner (occupation)" + }, + { + "code": "309324006", + "display": "General practitioner assistant (occupation)" + }, + { + "code": "309326008", + "display": "Deputizing general practitioner (occupation)" + }, + { + "code": "309327004", + "display": "General practitioner registrar (occupation)" + }, + { + "code": "309328009", + "display": "Ambulatory pediatrician (occupation)" + }, + { + "code": "309329001", + "display": "Community pediatrician (occupation)" + }, + { + "code": "309330006", + "display": "Pediatric cardiologist (occupation)" + }, + { + "code": "309331005", + "display": "Pediatric endocrinologist (occupation)" + }, + { + "code": "309332003", + "display": "Pediatric gastroenterologist (occupation)" + }, + { + "code": "309333008", + "display": "Pediatric nephrologist (occupation)" + }, + { + "code": "309334002", + "display": "Pediatric neurologist (occupation)" + }, + { + "code": "309335001", + "display": "Pediatric rheumatologist (occupation)" + }, + { + "code": "309336000", + "display": "Pediatric oncologist (occupation)" + }, + { + "code": "309337009", + "display": "Pain management specialist (occupation)" + }, + { + "code": "309338004", + "display": "Intensive care specialist (occupation)" + }, + { + "code": "309339007", + "display": "Adult intensive care specialist (occupation)" + }, + { + "code": "309340009", + "display": "Pediatric intensive care specialist (occupation)" + }, + { + "code": "309341008", + "display": "Blood transfusion doctor (occupation)" + }, + { + "code": "309342001", + "display": "Histopathologist (occupation)" + }, + { + "code": "309343006", + "display": "Physician (occupation)" + }, + { + "code": "309345004", + "display": "Chest physician (occupation)" + }, + { + "code": "309346003", + "display": "Thoracic physician (occupation)" + }, + { + "code": "309347007", + "display": "Clinical hematologist (occupation)" + }, + { + "code": "309348002", + "display": "Clinical neurophysiologist (occupation)" + }, + { + "code": "309349005", + "display": "Clinical physiologist (occupation)" + }, + { + "code": "309350005", + "display": "Diabetologist (occupation)" + }, + { + "code": "309351009", + "display": "Andrologist (occupation)" + }, + { + "code": "309352002", + "display": "Neuroendocrinologist (occupation)" + }, + { + "code": "309353007", + "display": "Reproductive endocrinologist (occupation)" + }, + { + "code": "309354001", + "display": "Thyroidologist (occupation)" + }, + { + "code": "309355000", + "display": "Clinical geneticist (occupation)" + }, + { + "code": "309356004", + "display": "Clinical cytogeneticist (occupation)" + }, + { + "code": "309357008", + "display": "Clinical molecular geneticist (occupation)" + }, + { + "code": "309358003", + "display": "Genitourinary medicine physician (occupation)" + }, + { + "code": "309359006", + "display": "Palliative care physician (occupation)" + }, + { + "code": "309360001", + "display": "Rehabilitation physician (occupation)" + }, + { + "code": "309361002", + "display": "Child and adolescent psychiatrist (occupation)" + }, + { + "code": "309362009", + "display": "Forensic psychiatrist (occupation)" + }, + { + "code": "309363004", + "display": "Liaison psychiatrist (occupation)" + }, + { + "code": "309364005", + "display": "Psychogeriatrician (occupation)" + }, + { + "code": "309366007", + "display": "Rehabilitation psychiatrist (occupation)" + }, + { + "code": "309367003", + "display": "Obstetrician and gynecologist (occupation)" + }, + { + "code": "309368008", + "display": "Breast surgeon (occupation)" + }, + { + "code": "309369000", + "display": "Cardiothoracic surgeon (occupation)" + }, + { + "code": "309371000", + "display": "Cardiac surgeon (occupation)" + }, + { + "code": "309372007", + "display": "Ear, nose and throat surgeon (occupation)" + }, + { + "code": "309373002", + "display": "Endocrine surgeon (occupation)" + }, + { + "code": "309374008", + "display": "Thyroid surgeon (occupation)" + }, + { + "code": "309375009", + "display": "Pituitary surgeon (occupation)" + }, + { + "code": "309376005", + "display": "Gastrointestinal surgeon (occupation)" + }, + { + "code": "309377001", + "display": "General gastrointestinal surgeon (occupation)" + }, + { + "code": "309378006", + "display": "Upper gastrointestinal surgeon (occupation)" + }, + { + "code": "309379003", + "display": "Colorectal surgeon (occupation)" + }, + { + "code": "309380000", + "display": "Hand surgeon (occupation)" + }, + { + "code": "309381001", + "display": "Hepatobiliary surgeon (occupation)" + }, + { + "code": "309382008", + "display": "Ophthalmic surgeon (occupation)" + }, + { + "code": "309383003", + "display": "Pediatric surgeon (occupation)" + }, + { + "code": "309384009", + "display": "Pancreatic surgeon (occupation)" + }, + { + "code": "309385005", + "display": "Transplant surgeon (occupation)" + }, + { + "code": "309386006", + "display": "Trauma surgeon (occupation)" + }, + { + "code": "309388007", + "display": "Vascular surgeon (occupation)" + }, + { + "code": "309389004", + "display": "Medical practitioner grade (occupation)" + }, + { + "code": "309390008", + "display": "Hospital consultant (occupation)" + }, + { + "code": "309391007", + "display": "Visiting specialist registrar (occupation)" + }, + { + "code": "309392000", + "display": "Research registrar (occupation)" + }, + { + "code": "309393005", + "display": "General practitioner grade (occupation)" + }, + { + "code": "309394004", + "display": "General practitioner principal (occupation)" + }, + { + "code": "309395003", + "display": "Hospital specialist (occupation)" + }, + { + "code": "309396002", + "display": "Associate specialist (occupation)" + }, + { + "code": "309397006", + "display": "Research fellow (occupation)" + }, + { + "code": "309398001", + "display": "Profession allied to medicine (occupation)" + }, + { + "code": "309399009", + "display": "Hospital-based dietitian (occupation)" + }, + { + "code": "309400002", + "display": "Domiciliary physiotherapist (occupation)" + }, + { + "code": "309401003", + "display": "General practitioner-based physiotherapist (occupation)" + }, + { + "code": "309402005", + "display": "Hospital-based physiotherapist (occupation)" + }, + { + "code": "309403000", + "display": "Private physiotherapist (occupation)" + }, + { + "code": "309404006", + "display": "Physiotherapy helper (occupation)" + }, + { + "code": "309409001", + "display": "Hospital-based speech and language therapist (occupation)" + }, + { + "code": "309410006", + "display": "Arts therapist (occupation)" + }, + { + "code": "309411005", + "display": "Dance therapist (occupation)" + }, + { + "code": "309412003", + "display": "Music therapist (occupation)" + }, + { + "code": "309413008", + "display": "Renal dietitian (occupation)" + }, + { + "code": "309414002", + "display": "Liver dietitian (occupation)" + }, + { + "code": "309415001", + "display": "Oncology dietitian (occupation)" + }, + { + "code": "309416000", + "display": "Pediatric dietitian (occupation)" + }, + { + "code": "309417009", + "display": "Diabetes dietitian (occupation)" + }, + { + "code": "309418004", + "display": "Audiologist (occupation)" + }, + { + "code": "309419007", + "display": "Hearing therapist (occupation)" + }, + { + "code": "309420001", + "display": "Audiological scientist (occupation)" + }, + { + "code": "309421002", + "display": "Hearing aid dispenser (occupation)" + }, + { + "code": "309422009", + "display": "Community-based occupational therapist (occupation)" + }, + { + "code": "309423004", + "display": "Hospital-based occupational therapist (occupation)" + }, + { + "code": "309427003", + "display": "Social services occupational therapist (occupation)" + }, + { + "code": "309428008", + "display": "Orthotist (occupation)" + }, + { + "code": "309429000", + "display": "Surgical fitter (occupation)" + }, + { + "code": "309434001", + "display": "Hospital-based podiatrist (occupation)" + }, + { + "code": "309435000", + "display": "Podiatry assistant (occupation)" + }, + { + "code": "309436004", + "display": "Lymphedema nurse (occupation)" + }, + { + "code": "309437008", + "display": "Community learning disabilities nurse (occupation)" + }, + { + "code": "309439006", + "display": "Clinical nurse teacher (occupation)" + }, + { + "code": "309440008", + "display": "Community practice nurse teacher (occupation)" + }, + { + "code": "309441007", + "display": "Nurse tutor (occupation)" + }, + { + "code": "309442000", + "display": "Nurse teacher practitioner (occupation)" + }, + { + "code": "309443005", + "display": "Nurse lecturer practitioner (occupation)" + }, + { + "code": "309444004", + "display": "Outreach nurse (occupation)" + }, + { + "code": "309445003", + "display": "Anesthetic nurse (occupation)" + }, + { + "code": "309446002", + "display": "Nurse manager (occupation)" + }, + { + "code": "309450009", + "display": "Nurse administrator (occupation)" + }, + { + "code": "309452001", + "display": "Midwifery grade (occupation)" + }, + { + "code": "309453006", + "display": "Registered midwife (occupation)" + }, + { + "code": "309454000", + "display": "Student midwife (occupation)" + }, + { + "code": "309455004", + "display": "Parentcraft sister (occupation)" + }, + { + "code": "309457007", + "display": "Vicar (occupation)" + }, + { + "code": "309459005", + "display": "Healthcare professional grade (occupation)" + }, + { + "code": "309460000", + "display": "Restorative dentist (occupation)" + }, + { + "code": "310170009", + "display": "Pediatric audiologist (occupation)" + }, + { + "code": "310171008", + "display": "Immunopathologist (occupation)" + }, + { + "code": "310172001", + "display": "Audiological physician (occupation)" + }, + { + "code": "310173006", + "display": "Clinical pharmacologist (occupation)" + }, + { + "code": "310174000", + "display": "Private doctor (occupation)" + }, + { + "code": "310175004", + "display": "Agency nurse (occupation)" + }, + { + "code": "310176003", + "display": "Behavioral therapist nurse (occupation)" + }, + { + "code": "310177007", + "display": "Cardiac rehabilitation nurse (occupation)" + }, + { + "code": "310178002", + "display": "Genitourinary nurse (occupation)" + }, + { + "code": "310179005", + "display": "Rheumatology nurse specialist (occupation)" + }, + { + "code": "310180008", + "display": "Continence nurse (occupation)" + }, + { + "code": "310181007", + "display": "Contact tracing nurse (occupation)" + }, + { + "code": "310182000", + "display": "General nurse (occupation)" + }, + { + "code": "310184004", + "display": "Liaison nurse (occupation)" + }, + { + "code": "310185003", + "display": "Diabetic liaison nurse (occupation)" + }, + { + "code": "310186002", + "display": "Nurse psychotherapist (occupation)" + }, + { + "code": "310187006", + "display": "Company nurse (occupation)" + }, + { + "code": "310188001", + "display": "Hospital midwife (occupation)" + }, + { + "code": "310189009", + "display": "Genetic counselor (occupation)" + }, + { + "code": "310190000", + "display": "Mental health counselor (occupation)" + }, + { + "code": "310191001", + "display": "Clinical psychologist (occupation)" + }, + { + "code": "310192008", + "display": "Educational psychologist (occupation)" + }, + { + "code": "310193003", + "display": "Coroner (occupation)" + }, + { + "code": "310194009", + "display": "Appliance officer (occupation)" + }, + { + "code": "310512001", + "display": "Medical oncologist (occupation)" + }, + { + "code": "311441001", + "display": "School medical officer (occupation)" + }, + { + "code": "312485001", + "display": "Integrated midwife (occupation)" + }, + { + "code": "3430008", + "display": "Radiation therapist (occupation)" + }, + { + "code": "36682004", + "display": "Physiotherapist (occupation)" + }, + { + "code": "37154003", + "display": "Periodontist (occupation)" + }, + { + "code": "372102007", + "display": "Registered nurse first assist (occupation)" + }, + { + "code": "373864002", + "display": "Outpatient (person)" + }, + { + "code": "37504001", + "display": "Orthodontist (occupation)" + }, + { + "code": "3842006", + "display": "Chiropractor (occupation)" + }, + { + "code": "387619007", + "display": "Optician (occupation)" + }, + { + "code": "394572006", + "display": "Medical secretary (occupation)" + }, + { + "code": "394618009", + "display": "Hospital nurse (occupation)" + }, + { + "code": "39677007", + "display": "Internal medicine specialist (occupation)" + }, + { + "code": "397897005", + "display": "Paramedic (occupation)" + }, + { + "code": "397903001", + "display": "Staff grade obstetrician (occupation)" + }, + { + "code": "397908005", + "display": "Staff grade practitioner (occupation)" + }, + { + "code": "3981000175106", + "display": "Nurse complex case manager (occupation)" + }, + { + "code": "398130009", + "display": "Medical student (occupation)" + }, + { + "code": "398238009", + "display": "Acting obstetric registrar (occupation)" + }, + { + "code": "40127002", + "display": "Dietitian (general) (occupation)" + }, + { + "code": "40204001", + "display": "Hematologist (occupation)" + }, + { + "code": "404940000", + "display": "Physiotherapist technical instructor (occupation)" + }, + { + "code": "405277009", + "display": "Resident physician (occupation)" + }, + { + "code": "405278004", + "display": "Certified registered nurse anesthetist (occupation)" + }, + { + "code": "405279007", + "display": "Attending physician (occupation)" + }, + { + "code": "405623001", + "display": "Assigned practitioner (occupation)" + }, + { + "code": "405684005", + "display": "Professional initiating surgical case (occupation)" + }, + { + "code": "405685006", + "display": "Professional providing staff relief during surgical procedure (occupation)" + }, + { + "code": "40570005", + "display": "Interpreter (occupation)" + }, + { + "code": "407542009", + "display": "Informal caregiver (person)" + }, + { + "code": "407543004", + "display": "Primary caregiver (person)" + }, + { + "code": "408290003", + "display": "Diabetes key contact (occupation)" + }, + { + "code": "408798009", + "display": "Consultant pediatrician (occupation)" + }, + { + "code": "408799001", + "display": "Consultant neonatologist (occupation)" + }, + { + "code": "409974004", + "display": "Health educator (occupation)" + }, + { + "code": "409975003", + "display": "Certified health education specialist (occupation)" + }, + { + "code": "413854007", + "display": "Circulating nurse (occupation)" + }, + { + "code": "415075003", + "display": "Perioperative nurse (occupation)" + }, + { + "code": "415506007", + "display": "Scrub nurse (occupation)" + }, + { + "code": "416034003", + "display": "Primary screener (person)" + }, + { + "code": "416035002", + "display": "Secondary screener (person)" + }, + { + "code": "416160000", + "display": "Fellow of American Academy of Osteopathy (occupation)" + }, + { + "code": "4162009", + "display": "Dental assistant (occupation)" + }, + { + "code": "41672002", + "display": "Respiratory disease specialist (occupation)" + }, + { + "code": "416800000", + "display": "Inpatient (person)" + }, + { + "code": "41904004", + "display": "Medical X-ray technician (occupation)" + }, + { + "code": "420158005", + "display": "Performer of method (person)" + }, + { + "code": "420409002", + "display": "Oculoplastic surgeon (occupation)" + }, + { + "code": "420678001", + "display": "Retinal surgeon (occupation)" + }, + { + "code": "421841007", + "display": "Admitting physician (occupation)" + }, + { + "code": "422140007", + "display": "Medical ophthalmologist (occupation)" + }, + { + "code": "422234006", + "display": "Ophthalmologist (occupation)" + }, + { + "code": "428024001", + "display": "Clinical trial participant (person)" + }, + { + "code": "429577009", + "display": "Patient advocate (person)" + }, + { + "code": "43018001", + "display": "Babysitter (occupation)" + }, + { + "code": "432100008", + "display": "Health coach (occupation)" + }, + { + "code": "43702002", + "display": "Occupational health nurse (occupation)" + }, + { + "code": "440051000124108", + "display": "Medical examiner (occupation)" + }, + { + "code": "442251000124100", + "display": "Licensed practical nurse (occupation)" + }, + { + "code": "442867008", + "display": "Respiratory therapist (occupation)" + }, + { + "code": "443090005", + "display": "Podiatric surgeon (occupation)" + }, + { + "code": "444912007", + "display": "Hypnotherapist (occupation)" + }, + { + "code": "445313000", + "display": "Asthma nurse specialist (occupation)" + }, + { + "code": "445451001", + "display": "Nurse case manager (occupation)" + }, + { + "code": "445521000124102", + "display": "Advanced practice midwife (occupation)" + }, + { + "code": "445531000124104", + "display": "Lay midwife (occupation)" + }, + { + "code": "446050000", + "display": "Primary care physician (occupation)" + }, + { + "code": "44652006", + "display": "Pharmaceutical assistant (occupation)" + }, + { + "code": "446701002", + "display": "Addiction medicine specialist (occupation)" + }, + { + "code": "449161006", + "display": "Physician assistant (occupation)" + }, + { + "code": "450044741000087100", + "display": "Acupuncturist (occupation)" + }, + { + "code": "453061000124100", + "display": "Pharmacist specialist (person)" + }, + { + "code": "453071000124107", + "display": "Primary care pharmacist (person)" + }, + { + "code": "453081000124105", + "display": "Infusion pharmacist (person)" + }, + { + "code": "453091000124108", + "display": "Receiving provider (person)" + }, + { + "code": "453101000124102", + "display": "Consultant pharmacist (person)" + }, + { + "code": "453111000124104", + "display": "Dispensing pharmacist (person)" + }, + { + "code": "453121000124107", + "display": "Emergency department healthcare professional (occupation)" + }, + { + "code": "453231000124104", + "display": "Primary care provider (occupation)" + }, + { + "code": "45440000", + "display": "Rheumatologist (occupation)" + }, + { + "code": "45544007", + "display": "Neurosurgeon (occupation)" + }, + { + "code": "457141000124107", + "display": "Locum tenens attending physician (occupation)" + }, + { + "code": "457151000124109", + "display": "Locum tenens admitting physician (occupation)" + }, + { + "code": "45956004", + "display": "Sanitarian (occupation)" + }, + { + "code": "46255001", + "display": "Pharmacist (occupation)" + }, + { + "code": "471302004", + "display": "Government midwife (occupation)" + }, + { + "code": "48639005", + "display": "Ordained minister (occupation)" + }, + { + "code": "48740002", + "display": "Philologist (occupation)" + }, + { + "code": "49203003", + "display": "Dispensing optician (occupation)" + }, + { + "code": "49993003", + "display": "Oral surgeon (occupation)" + }, + { + "code": "50149000", + "display": "Endodontist (occupation)" + }, + { + "code": "5191000124109", + "display": "Private midwife (occupation)" + }, + { + "code": "5275007", + "display": "Auxiliary nurse (occupation)" + }, + { + "code": "53564008", + "display": "Ordained clergy (occupation)" + }, + { + "code": "54503009", + "display": "Faith healer (occupation)" + }, + { + "code": "56397003", + "display": "Neurologist (occupation)" + }, + { + "code": "56466003", + "display": "Public health physician (occupation)" + }, + { + "code": "56542007", + "display": "Medical record administrator (occupation)" + }, + { + "code": "56545009", + "display": "Cardiovascular surgeon (occupation)" + }, + { + "code": "57654006", + "display": "Fixed prosthodontist (occupation)" + }, + { + "code": "59058001", + "display": "General physician (occupation)" + }, + { + "code": "59169001", + "display": "Orthopedic technician (occupation)" + }, + { + "code": "59317003", + "display": "Dental prosthesis maker and repairer (occupation)" + }, + { + "code": "59944000", + "display": "Psychologist (occupation)" + }, + { + "code": "60008001", + "display": "Public health nutritionist (occupation)" + }, + { + "code": "611581000124105", + "display": "Cognitive neuropsychologist (occupation)" + }, + { + "code": "611601000124100", + "display": "Neonatal nurse practitioner (occupation)" + }, + { + "code": "611611000124102", + "display": "Counseling psychologist (occupation)" + }, + { + "code": "611621000124105", + "display": "Clinical neuropsychologist (occupation)" + }, + { + "code": "611701000124107", + "display": "Sleep psychologist (occupation)" + }, + { + "code": "61207006", + "display": "Medical pathologist (occupation)" + }, + { + "code": "61246008", + "display": "Laboratory medicine specialist (occupation)" + }, + { + "code": "61345009", + "display": "Otorhinolaryngologist (occupation)" + }, + { + "code": "61894003", + "display": "Endocrinologist (occupation)" + }, + { + "code": "62247001", + "display": "Family medicine specialist (occupation)" + }, + { + "code": "63098009", + "display": "Clinical immunologist (occupation)" + }, + { + "code": "64220005", + "display": "Religious worker (member of religious order) (occupation)" + }, + { + "code": "651501000124106", + "display": "Pediatric emergency medicine physician (occupation)" + }, + { + "code": "65803006", + "display": "Missionary (occupation)" + }, + { + "code": "66476003", + "display": "Oral pathologist (occupation)" + }, + { + "code": "66862007", + "display": "Radiologist (occupation)" + }, + { + "code": "671101000124102", + "display": "Family nurse practitioner (occupation)" + }, + { + "code": "67811000052107", + "display": "Pediatric hematology and oncology physician (occupation)" + }, + { + "code": "6816002", + "display": "Specialized nurse (occupation)" + }, + { + "code": "68191000052106", + "display": "Neuropsychologist (occupation)" + }, + { + "code": "6868009", + "display": "Hospital administrator (occupation)" + }, + { + "code": "68867008", + "display": "Public health dentist (occupation)" + }, + { + "code": "68950000", + "display": "Prosthodontist (occupation)" + }, + { + "code": "69280009", + "display": "Specialized physician (occupation)" + }, + { + "code": "71838004", + "display": "Gastroenterologist (occupation)" + }, + { + "code": "720503005", + "display": "Sleep medicine specialist (occupation)" + }, + { + "code": "721936008", + "display": "Occupation medicine specialist (occupation)" + }, + { + "code": "721937004", + "display": "Preventive medicine specialist (occupation)" + }, + { + "code": "721938009", + "display": "Tropical medicine specialist (occupation)" + }, + { + "code": "721939001", + "display": "Vascular medicine specialist (occupation)" + }, + { + "code": "721940004", + "display": "Legal medicine specialist (occupation)" + }, + { + "code": "721941000", + "display": "Health psychologist (occupation)" + }, + { + "code": "721942007", + "display": "Cardiovascular perfusionist (occupation)" + }, + { + "code": "721943002", + "display": "Clinical immunology and allergy specialist (occupation)" + }, + { + "code": "73265009", + "display": "Nursing aid (occupation)" + }, + { + "code": "734293001", + "display": "Clinical pharmacist (occupation)" + }, + { + "code": "734294007", + "display": "Pharmacist prescriber (occupation)" + }, + { + "code": "75271001", + "display": "Professional midwife (occupation)" + }, + { + "code": "76166008", + "display": "Practical aid (pharmacy) (occupation)" + }, + { + "code": "76231001", + "display": "Osteopath (occupation)" + }, + { + "code": "763292005", + "display": "Radiation oncologist (occupation)" + }, + { + "code": "768730001", + "display": "Home health aide (occupation)" + }, + { + "code": "768731002", + "display": "Home helper (occupation)" + }, + { + "code": "768732009", + "display": "School health educator (occupation)" + }, + { + "code": "768733004", + "display": "Spiritual advisor (occupation)" + }, + { + "code": "768734005", + "display": "Research study coordinator (occupation)" + }, + { + "code": "768815003", + "display": "Investigative specialist (occupation)" + }, + { + "code": "768816002", + "display": "Associate investigator (occupation)" + }, + { + "code": "768817006", + "display": "Co-principal investigator (occupation)" + }, + { + "code": "768818001", + "display": "Principal investigator (occupation)" + }, + { + "code": "768819009", + "display": "Medically responsible investigator (occupation)" + }, + { + "code": "768820003", + "display": "Care coordinator (occupation)" + }, + { + "code": "768821004", + "display": "Care team coordinator (occupation)" + }, + { + "code": "768822006", + "display": "Rehabilitation coordinator (occupation)" + }, + { + "code": "768825008", + "display": "Doula (occupation)" + }, + { + "code": "768826009", + "display": "Crisis counselor (occupation)" + }, + { + "code": "768827000", + "display": "Nutritionist (occupation)" + }, + { + "code": "768828005", + "display": "Epidemiologist (occupation)" + }, + { + "code": "768829002", + "display": "Community dietician (occupation)" + }, + { + "code": "768832004", + "display": "Case manager (occupation)" + }, + { + "code": "768833009", + "display": "Discharging physician (occupation)" + }, + { + "code": "768834003", + "display": "Disease manager (occupation)" + }, + { + "code": "768836001", + "display": "Patient navigator (occupation)" + }, + { + "code": "768837005", + "display": "Hospitalist (occupation)" + }, + { + "code": "768839008", + "display": "Consultant (occupation)" + }, + { + "code": "76899008", + "display": "Infectious disease specialist (occupation)" + }, + { + "code": "769038007", + "display": "Researcher (occupation)" + }, + { + "code": "78703002", + "display": "General surgeon (occupation)" + }, + { + "code": "78729002", + "display": "Diagnostic radiologist (occupation)" + }, + { + "code": "789543004", + "display": "Sonographer (occupation)" + }, + { + "code": "79898004", + "display": "Auxiliary midwife (occupation)" + }, + { + "code": "79918004", + "display": "Ordained priest (occupation)" + }, + { + "code": "80409005", + "display": "Translator (occupation)" + }, + { + "code": "80546007", + "display": "Occupational therapist (occupation)" + }, + { + "code": "80584001", + "display": "Psychiatrist (occupation)" + }, + { + "code": "80933006", + "display": "Nuclear medicine specialist (occupation)" + }, + { + "code": "81464008", + "display": "Clinical pathologist (occupation)" + }, + { + "code": "82296001", + "display": "Pediatrician (occupation)" + }, + { + "code": "83273008", + "display": "Anatomic pathologist (occupation)" + }, + { + "code": "83685006", + "display": "Gynecologist (occupation)" + }, + { + "code": "840583002", + "display": "Allied health assistant (occupation)" + }, + { + "code": "840584008", + "display": "Allied health student (occupation)" + }, + { + "code": "85733003", + "display": "General pathologist (occupation)" + }, + { + "code": "8724009", + "display": "Plastic surgeon (occupation)" + }, + { + "code": "878785002", + "display": "Clinical respiratory physiologist (occupation)" + }, + { + "code": "878786001", + "display": "Operating room technician (occupation)" + }, + { + "code": "878787005", + "display": "Anesthesia technician (occupation)" + }, + { + "code": "88189002", + "display": "Anesthesiologist (occupation)" + }, + { + "code": "897187007", + "display": "Sexual assault nurse examiner (occupation)" + }, + { + "code": "90201008", + "display": "Pedodontist (occupation)" + }, + { + "code": "90655003", + "display": "Geriatrics specialist (occupation)" + }, + { + "code": "9371000175105", + "display": "Adolescent medicine specialist (occupation)" + } + ] + }, + { + "system": "ParticipationFunction", + "concept": [ + { + "code": "ADMPHYS", + "display": "admitting physician" + }, + { + "code": "ANEST", + "display": "anesthesist" + }, + { + "code": "ANRS", + "display": "anesthesia nurse" + }, + { + "code": "ASSEMBLER", + "display": "assembly software" + }, + { + "code": "ATTPHYS", + "display": "attending physician" + }, + { + "code": "AUCG", + "display": "caregiver information receiver" + }, + { + "code": "AUCOV", + "display": "consent overrider" + }, + { + "code": "AUEMROV", + "display": "emergency overrider" + }, + { + "code": "AULR", + "display": "legitimate relationship information receiver" + }, + { + "code": "AUTM", + "display": "care team information receiver" + }, + { + "code": "AUWA", + "display": "work area information receiver" + }, + { + "code": "CLMADJ", + "display": "claims adjudication" + }, + { + "code": "COMPOSER", + "display": "composer software" + }, + { + "code": "DISPHYS", + "display": "discharging physician" + }, + { + "code": "ENROLL", + "display": "enrollment broker" + }, + { + "code": "FASST", + "display": "first assistant surgeon" + }, + { + "code": "FFSMGT", + "display": "ffs management" + }, + { + "code": "FULINRD", + "display": "fully insured" + }, + { + "code": "GRDCON", + "display": "legal guardian consent author" + }, + { + "code": "MCMGT", + "display": "managed care management" + }, + { + "code": "MDWF", + "display": "midwife" + }, + { + "code": "NASST", + "display": "nurse assistant" + }, + { + "code": "PAYORCNTR", + "display": "payor contracting" + }, + { + "code": "PCP", + "display": "primary care physician" + }, + { + "code": "POACON", + "display": "healthcare power of attorney consent author" + }, + { + "code": "PRCON", + "display": "personal representative consent author" + }, + { + "code": "PRISURG", + "display": "primary surgeon" + }, + { + "code": "PROMSK", + "display": "authorized provider masking author" + }, + { + "code": "PROVMGT", + "display": "provider management" + }, + { + "code": "REINS", + "display": "reinsures" + }, + { + "code": "RETROCES", + "display": "retrocessionaires" + }, + { + "code": "REVIEWER", + "display": "reviewer" + }, + { + "code": "RNDPHYS", + "display": "rounding physician" + }, + { + "code": "SASST", + "display": "second assistant surgeon" + }, + { + "code": "SELFINRD", + "display": "self insured" + }, + { + "code": "SNRS", + "display": "scrub nurse" + }, + { + "code": "SUBCON", + "display": "subject of consent author" + }, + { + "code": "SUBCTRT", + "display": "subcontracting risk" + }, + { + "code": "TASST", + "display": "third assistant" + }, + { + "code": "UMGT", + "display": "utilization management" + }, + { + "code": "UNDERWRTNG", + "display": "underwriting" + }, + { + "code": "_AuthorizedParticipationFunction", + "display": "AuthorizedParticipationFunction" + }, + { + "code": "_AuthorizedReceiverParticipationFunction", + "display": "AuthorizedReceiverParticipationFunction" + }, + { + "code": "_ConsenterParticipationFunction", + "display": "ConsenterParticipationFunction" + }, + { + "code": "_CoverageParticipationFunction", + "display": "CoverageParticipationFunction" + }, + { + "code": "_OverriderParticipationFunction", + "display": "OverriderParticipationFunction" + }, + { + "code": "_PayorParticipationFunction", + "display": "PayorParticipationFunction" + }, + { + "code": "_SponsorParticipationFunction", + "display": "SponsorParticipationFunction" + }, + { + "code": "_UnderwriterParticipationFunction", + "display": "UnderwriterParticipationFunction" + } + ] + } + ] + } + } + } + ] +} diff --git a/examples/medplum-chat-demo/index.html b/examples/medplum-chat-demo/index.html new file mode 100644 index 0000000000..db26a0f118 --- /dev/null +++ b/examples/medplum-chat-demo/index.html @@ -0,0 +1,14 @@ + + + + + + + Medplum Chat Demo + + +
+ + + + diff --git a/examples/medplum-chat-demo/medplum-chat-demo-screenshot.png b/examples/medplum-chat-demo/medplum-chat-demo-screenshot.png new file mode 100644 index 0000000000..e38d0c49bc Binary files /dev/null and b/examples/medplum-chat-demo/medplum-chat-demo-screenshot.png differ diff --git a/examples/medplum-chat-demo/package.json b/examples/medplum-chat-demo/package.json new file mode 100644 index 0000000000..9547a12138 --- /dev/null +++ b/examples/medplum-chat-demo/package.json @@ -0,0 +1,42 @@ +{ + "name": "medplum-chat-demo", + "version": "3.1.3", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite", + "preview": "vite preview" + }, + "prettier": { + "printWidth": 120, + "singleQuote": true, + "trailingComma": "es5" + }, + "eslintConfig": { + "extends": [ + "@medplum/eslint-config" + ] + }, + "devDependencies": { + "@mantine/core": "7.6.2", + "@mantine/hooks": "7.6.2", + "@mantine/notifications": "7.6.2", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.0.0", + "@types/node": "20.11.28", + "@types/react": "18.2.66", + "@types/react-dom": "18.2.22", + "@vitejs/plugin-react": "4.2.1", + "postcss": "8.4.36", + "postcss-preset-mantine": "1.13.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "6.22.3", + "typescript": "5.4.2", + "vite": "5.1.6" + } +} diff --git a/examples/medplum-nextauth-demo/postcss.config.mjs b/examples/medplum-chat-demo/postcss.config.mjs similarity index 100% rename from examples/medplum-nextauth-demo/postcss.config.mjs rename to examples/medplum-chat-demo/postcss.config.mjs diff --git a/examples/medplum-chat-demo/public/favicon.ico b/examples/medplum-chat-demo/public/favicon.ico new file mode 100644 index 0000000000..b8a7ba9c7b Binary files /dev/null and b/examples/medplum-chat-demo/public/favicon.ico differ diff --git a/examples/medplum-chat-demo/src/App.tsx b/examples/medplum-chat-demo/src/App.tsx new file mode 100644 index 0000000000..cae4d9d13f --- /dev/null +++ b/examples/medplum-chat-demo/src/App.tsx @@ -0,0 +1,119 @@ +import { formatSearchQuery, getReferenceString, Operator, ProfileResource } from '@medplum/core'; +import { AppShell, Loading, Logo, NotificationIcon, useMedplum, useMedplumProfile } from '@medplum/react'; +import { IconClipboardCheck, IconFileImport, IconMail, IconMessage, IconMessage2Bolt } from '@tabler/icons-react'; +import { Suspense } from 'react'; +import { NavigateFunction, Route, Routes, useNavigate } from 'react-router-dom'; +import { CommunicationPage } from './pages/CommunicationPage'; +import { LandingPage } from './pages/LandingPage'; +import { PatientPage } from './pages/PatientPage'; +import { ResourcePage } from './pages/ResourcePage'; +import { SearchPage } from './pages/SearchPage'; +import { SignInPage } from './pages/SignInPage'; +import { UploadDataPage } from './pages/UploadDataPage'; + +export function App(): JSX.Element | null { + const medplum = useMedplum(); + const profile = useMedplumProfile(); + const navigate = useNavigate(); + + // Format a search query to get all active threads assigned to the current user + const myThreadsQuery = formatSearchQuery({ + resourceType: 'Communication', + filters: [ + { code: 'part-of:missing', operator: Operator.EQUALS, value: 'true' }, + { code: 'recipient', operator: Operator.EQUALS, value: (profile && getReferenceString(profile)) as string }, + { code: 'status:not', operator: Operator.EQUALS, value: 'completed' }, + ], + }); + + if (medplum.isLoading()) { + return null; + } + + return ( + } + menus={[ + { + // A section of the sidebar that displays links to see all threads and threads the user is a part of + title: 'My Links', + links: [ + { + icon: , + label: 'All Threads', + href: '/Communication?part-of:missing=true&status:not=completed', + }, + { icon: , label: 'My Threads', href: `/Communication${myThreadsQuery}` }, + ], + }, + { + // A section of the sidebar that links to a page to upload example data for the app + title: 'Upload Data', + links: [{ icon: , label: 'Upload Example Data', href: 'upload/example' }], + }, + ]} + // This adds notification icons for unread messages and active tasks for the current user + notifications={ + profile && ( + <> + + + + ) + } + > + }> + + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +interface NotificationProps { + profile: ProfileResource; + navigate: NavigateFunction; +} + +function MessageNotification({ profile, navigate }: NotificationProps): JSX.Element { + return ( + } + onClick={() => + navigate( + `/Communication?recipient=${getReferenceString(profile as ProfileResource)}&status:not=completed&part-of:missing=false&_fields=sender,recipient,subject,status,_lastUpdated` + ) + } + /> + ); +} + +function TaskNotification({ profile, navigate }: NotificationProps): JSX.Element { + return ( + } + onClick={() => + navigate( + `/Task?owner=${getReferenceString(profile as ProfileResource)}&status:not=completed&_fields=subject,code,description,status,_lastUpdated` + ) + } + /> + ); +} diff --git a/examples/medplum-chat-demo/src/components/CommunicationDetails.tsx b/examples/medplum-chat-demo/src/components/CommunicationDetails.tsx new file mode 100644 index 0000000000..81efd073a0 --- /dev/null +++ b/examples/medplum-chat-demo/src/components/CommunicationDetails.tsx @@ -0,0 +1,45 @@ +import { Paper, Tabs, Title } from '@mantine/core'; +import { Communication } from '@medplum/fhirtypes'; +import { CodeableConceptDisplay, ResourceHistoryTable, ResourceTable } from '@medplum/react'; +import { useNavigate } from 'react-router-dom'; + +interface CommunicationDetailsProps { + readonly communication: Communication; +} + +export function CommunicationDetails({ communication }: CommunicationDetailsProps): JSX.Element { + const navigate = useNavigate(); + const id = communication.id as string; + const tabs = ['Details', 'History']; + + // Get the current tab + const tab = window.location.pathname.split('/').pop(); + const currentTab = tab && tabs.map((t) => t.toLowerCase()).includes(tab) ? tab : tabs[0].toLowerCase(); + + function handleTabChange(newTab: string | null): void { + navigate(`/Communication/${id}/${newTab ?? ''}`); + } + + return ( + + + <CodeableConceptDisplay value={communication.topic} /> + + + + {tabs.map((tab) => ( + + {tab} + + ))} + + + + + + + + + + ); +} diff --git a/examples/medplum-chat-demo/src/components/PatientDetails.tsx b/examples/medplum-chat-demo/src/components/PatientDetails.tsx new file mode 100644 index 0000000000..9f5d2dc3c3 --- /dev/null +++ b/examples/medplum-chat-demo/src/components/PatientDetails.tsx @@ -0,0 +1,104 @@ +import { Loader, Tabs } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { getReferenceString, normalizeErrorString, Operator, SearchRequest } from '@medplum/core'; +import { Patient, Practitioner, Resource } from '@medplum/fhirtypes'; +import { + Document, + ResourceForm, + ResourceHistoryTable, + ResourceTable, + SearchControl, + useMedplum, + useMedplumProfile, + useResource, +} from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { PatientHeader } from '../pages/PatientHeader'; +import { cleanResource } from '../utils'; + +interface PatientDetailsProps { + onChange: (patient: Patient) => void; +} + +export function PatientDetails({ onChange }: PatientDetailsProps): JSX.Element { + const medplum = useMedplum(); + const navigate = useNavigate(); + const profile = useMedplumProfile() as Practitioner; + const { id } = useParams() as { id: string }; + const patient = useResource({ reference: `Patient/${id}` }); + + const tabs = ['Details', 'Threads', 'Edit', 'History']; + const tab = window.location.pathname.split('/').pop(); + const currentTab = tab && tabs.map((t) => t.toLowerCase()).includes(tab) ? tab : tabs[0].toLowerCase(); + + // Create a search request to get all threads that the current patient is a participant in or a subject of. + const threadSearch: SearchRequest = { + resourceType: 'Communication', + filters: [ + { code: 'part-of:missing', operator: Operator.EQUALS, value: 'true' }, + { code: 'subject', operator: Operator.EQUALS, value: `Patient/${id}` }, + { code: 'recipient', operator: Operator.EQUALS, value: getReferenceString(profile) }, + ], + fields: ['topic', 'category', '_lastUpdated'], + }; + + async function handlePatientEdit(newPatient: Resource): Promise { + try { + const updatedPatient = (await medplum.updateResource(cleanResource(newPatient))) as Patient; + showNotification({ + icon: , + title: 'Success', + message: 'Patient updated', + }); + onChange(updatedPatient); + window.scrollTo(0, 0); + } catch (err) { + showNotification({ + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + } + } + + function handleTabChange(newTab: string | null): void { + navigate(`/Patient/${id}/${newTab ?? ''}`); + } + + if (!patient) { + return ; + } + + return ( + + + + + {tabs.map((tab) => ( + + {tab} + + ))} + + + + + + navigate(`/${getReferenceString(e.resource)}`)} + /> + + + + + + + + + + ); +} diff --git a/examples/medplum-chat-demo/src/components/actions/AddParticipant.tsx b/examples/medplum-chat-demo/src/components/actions/AddParticipant.tsx new file mode 100644 index 0000000000..b6370e74e4 --- /dev/null +++ b/examples/medplum-chat-demo/src/components/actions/AddParticipant.tsx @@ -0,0 +1,129 @@ +import { Button, Modal } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { showNotification } from '@mantine/notifications'; +import { normalizeErrorString, PatchOperation } from '@medplum/core'; +import { Communication, Questionnaire, QuestionnaireResponse } from '@medplum/fhirtypes'; +import { QuestionnaireForm, useMedplum } from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; +import { checkForInvalidRecipient, getRecipients } from '../../utils'; + +interface AddParticipantProps { + readonly communication: Communication; + readonly onChange: (communication: Communication) => void; +} + +export function AddParticipant(props: AddParticipantProps): JSX.Element { + const medplum = useMedplum(); + const [opened, handlers] = useDisclosure(false); + + function onQuestionnaireSubmit(formData: QuestionnaireResponse): void { + const newParticipantsData = getRecipients(formData); + if (!newParticipantsData) { + throw new Error('Please select a valid person to add to this thread.'); + } + const newParticipants = newParticipantsData?.map( + (participant) => participant.valueReference + ) as Communication['recipient']; + + if (!newParticipants) { + throw new Error('Please select a valid person to add to this thread.'); + } + + const invalidRecipients = checkForInvalidRecipient(newParticipants); + + if (invalidRecipients) { + showNotification({ + color: 'red', + icon: , + title: 'Error', + message: 'Invalid recipient type', + }); + throw new Error('Invalid recipient type'); + } + + addNewParticipant(newParticipants).catch(console.error); + handlers.close(); + } + + async function addNewParticipant(newParticipant: Communication['recipient']): Promise { + if (!newParticipant) { + return; + } + + // Get the communication id and the participants that are already a part of the thread + const communicationId = props.communication.id as string; + const currentParticipants = props.communication.recipient ?? []; + + // If there are no participants, we will add a participant array, otherwise we will replace the current one with an udpated version. + const op = currentParticipants.length === 0 ? 'add' : 'replace'; + + // Add the new participants to the array + const updatedParticipants = currentParticipants.concat(newParticipant); + + const ops: PatchOperation[] = [ + // Test to prevent race conditions + { op: 'test', path: '/meta/versionId', value: props.communication.meta?.versionId }, + { op, path: '/recipient', value: updatedParticipants }, + ]; + + try { + // Patch the thread with the updated participants + const result = await medplum.patchResource('Communication', communicationId, ops); + showNotification({ + icon: , + title: 'Success', + message: 'User added to thread.', + }); + props.onChange(result); + } catch (err) { + showNotification({ + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + } + } + + return ( +
+ + + + +
+ ); +} + +const addParticipantQuestionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + id: 'add-participant', + item: [ + { + linkId: 'participants', + type: 'reference', + text: 'Add someone to this thread:', + repeats: true, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-referenceResource', + valueCodeableConcept: { + coding: [ + { + code: 'Patient', + }, + { + code: 'Practitioner', + }, + { + code: 'RelatedPerson', + }, + ], + }, + }, + ], + }, + ], +}; diff --git a/examples/medplum-chat-demo/src/components/actions/AddSubject.tsx b/examples/medplum-chat-demo/src/components/actions/AddSubject.tsx new file mode 100644 index 0000000000..56db27d127 --- /dev/null +++ b/examples/medplum-chat-demo/src/components/actions/AddSubject.tsx @@ -0,0 +1,93 @@ +import { Button, Modal } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { showNotification } from '@mantine/notifications'; +import { getQuestionnaireAnswers, normalizeErrorString, PatchOperation } from '@medplum/core'; +import { Communication, Patient, Questionnaire, QuestionnaireResponse, Reference } from '@medplum/fhirtypes'; +import { QuestionnaireForm, useMedplum } from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; + +interface AddSubjectProps { + readonly communication: Communication; + readonly onChange: (communication: Communication) => void; +} + +export function AddSubject(props: AddSubjectProps): JSX.Element { + const medplum = useMedplum(); + const [opened, handlers] = useDisclosure(false); + + function onQuestionnaireSubmit(formData: QuestionnaireResponse): void { + // Throw an error if there is already a subject on the thread + if (props.communication.subject) { + throw new Error('Thread already has a subject.'); + } + const subjectData = getQuestionnaireAnswers(formData)['add-subject'].valueReference as Reference; + if (!subjectData) { + throw new Error('Invalid subject'); + } + addSubjectToThread(subjectData).catch(console.error); + handlers.close(); + } + + async function addSubjectToThread(subjectData: Reference): Promise { + const communicationId = props.communication.id as string; + const ops: PatchOperation[] = [ + // Test to prevent race conditions + { op: 'test', path: '/meta/versionId', value: props.communication.meta?.versionId }, + // Patch the new subject to the thread's subject path + { op: 'add', path: '/subject', value: subjectData }, + ]; + + try { + const result = await medplum.patchResource('Communication', communicationId, ops); + props.onChange(result); + showNotification({ + icon: , + title: 'Success', + message: 'Subject added', + }); + } catch (err) { + showNotification({ + color: 'red', + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + } + } + + return ( +
+ + + + +
+ ); +} + +const addSubjectQuestionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + id: 'add-subject', + item: [ + { + linkId: 'add-subject', + type: 'reference', + text: 'Add a subject to the thread', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-referenceResource', + valueCodeableConcept: { + coding: [ + { + code: 'Patient', + }, + ], + }, + }, + ], + }, + ], +}; diff --git a/examples/medplum-chat-demo/src/components/actions/CloseOpenThread.tsx b/examples/medplum-chat-demo/src/components/actions/CloseOpenThread.tsx new file mode 100644 index 0000000000..09a3f47e94 --- /dev/null +++ b/examples/medplum-chat-demo/src/components/actions/CloseOpenThread.tsx @@ -0,0 +1,70 @@ +import { Button, Group, Modal } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { showNotification } from '@mantine/notifications'; +import { normalizeErrorString, PatchOperation } from '@medplum/core'; +import { Communication } from '@medplum/fhirtypes'; +import { useMedplum } from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; + +interface CloseOpenThreadProps { + readonly communication: Communication; + readonly onChange: (communication: Communication) => void; +} + +export function CloseOpenThread(props: CloseOpenThreadProps): JSX.Element { + const medplum = useMedplum(); + const [opened, handlers] = useDisclosure(false); + const status = props.communication.status; + + // Check the status to see if the thread should be closed or reopened + const display = status === 'completed' ? 'Reopen' : 'Close'; + + async function handleStatusUpdate(): Promise { + const communicationId = props.communication.id as string; + // Update the status to the opposite of the current status + const updatedStatus = status === 'completed' ? 'in-progress' : 'completed'; + + const ops: PatchOperation[] = [ + { op: 'test', path: '/meta/versionId', value: props.communication.meta?.versionId }, + { op: 'replace', path: '/status', value: updatedStatus }, + ]; + + try { + // Update the thread to the new status + const result = await medplum.patchResource('Communication', communicationId, ops); + showNotification({ + icon: , + title: 'Success', + message: `Thread ${display === 'Close' ? 'closed.' : 'reopened.'}`, + }); + props.onChange(result); + handlers.close(); + } catch (err) { + showNotification({ + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + } + } + + return ( +
+ + + + + + + +
+ ); +} diff --git a/examples/medplum-chat-demo/src/components/actions/CommunicationActions.tsx b/examples/medplum-chat-demo/src/components/actions/CommunicationActions.tsx new file mode 100644 index 0000000000..b2679be6b0 --- /dev/null +++ b/examples/medplum-chat-demo/src/components/actions/CommunicationActions.tsx @@ -0,0 +1,45 @@ +import { Stack, Title } from '@mantine/core'; +import { parseReference } from '@medplum/core'; +import { Communication } from '@medplum/fhirtypes'; +import { AddParticipant } from './AddParticipant'; +import { AddSubject } from './AddSubject'; +import { CloseOpenThread } from './CloseOpenThread'; +import { CreateEncounter } from './CreateEncounter'; +import { EditThreadTopic } from './EditThreadTopic'; + +interface CommunicationActionsProps { + readonly communication: Communication; + readonly onChange: (communication: Communication) => void; +} + +export function CommunicationActions(props: CommunicationActionsProps): JSX.Element { + return ( + + Thread Actions + + + {!props.communication.subject ? ( + + ) : null} + + {props.communication.status === 'completed' && checkThreadForPatient(props.communication) ? ( + + ) : null} + + ); +} + +function checkThreadForPatient(thread: Communication): boolean { + const recipients = thread.recipient; + if (!recipients || recipients.length === 0) { + return false; + } + + for (const recipient of recipients) { + if (parseReference(recipient)[0] === 'Patient') { + return true; + } + } + + return false; +} diff --git a/examples/medplum-chat-demo/src/components/actions/CreateEncounter.tsx b/examples/medplum-chat-demo/src/components/actions/CreateEncounter.tsx new file mode 100644 index 0000000000..99b01cbd6d --- /dev/null +++ b/examples/medplum-chat-demo/src/components/actions/CreateEncounter.tsx @@ -0,0 +1,148 @@ +import { Button, Modal } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { showNotification } from '@mantine/notifications'; +import { createReference, normalizeErrorString, parseReference, PatchOperation } from '@medplum/core'; +import { + Communication, + Encounter, + Group, + Patient, + Period, + Practitioner, + Reference, + Resource, +} from '@medplum/fhirtypes'; +import { ResourceForm, useMedplum, useMedplumProfile } from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getAttenders } from '../../utils'; + +interface CreateEncounterProps { + readonly communication: Communication; + readonly onChange: (communication: Communication) => void; +} + +export function CreateEncounter(props: CreateEncounterProps): JSX.Element { + const medplum = useMedplum(); + const navigate = useNavigate(); + const profile = useMedplumProfile() as Practitioner; + const [opened, handlers] = useDisclosure(false); + const [period, setPeriod] = useState(); + + async function onEncounterSubmit(resource: Resource): Promise { + const encounterData = resource as Encounter; + encounterData.period = period; + + try { + // Create the encounter and update the communication to be linked to it. For more details see https://www.medplum.com/docs/communications/async-encounters + const encounter = await medplum.createResource(encounterData); + linkEncounterToCommunication(encounter, props.communication).catch(console.error); + showNotification({ + icon: , + title: 'Success', + message: 'Encounter created.', + }); + handlers.close(); + navigate(`/Encounter/${encounter.id}`); + } catch (err) { + showNotification({ + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + } + } + + // A function that links a Communication to an Encounter using the Communication.encounter field. For more details see https://www.medplum.com/docs/communications/async-encounters + async function linkEncounterToCommunication(encounter: Encounter, communication: Communication): Promise { + const communicationId = communication.id as string; + const encounterReference = createReference(encounter); + + const ops: PatchOperation[] = [ + // Test to prevent race conditions + { op: 'test', path: '/meta/versionId', value: communication.meta?.versionId }, + // Patch the encounter field of the communication + { op: 'add', path: '/encounter', value: encounterReference }, + ]; + + try { + // Update the communication + const result = await medplum.patchResource('Communication', communicationId, ops); + props.onChange(result); + } catch (err) { + console.error(err); + } + } + + useEffect(() => { + // When creating an encounter, the period should be from the time the first message in the thread was sent until the last message was sent + const getEncounterPeriod = async (thread: Communication): Promise => { + const messages = await medplum.searchResources('Communication', { + 'part-of': `Communication/${thread.id}`, + _sort: 'sent', + }); + + const period: Period = { + start: messages[0].sent, + end: messages[messages.length - 1].sent, + }; + + return period; + }; + + getEncounterPeriod(props.communication).then(setPeriod).catch(console.error); + }, [props.communication]); + + const attenders = getAttenders(props.communication.recipient, profile, false); + const subject = getEncounterSubject(props.communication); + + // A default encounter to pre-fill the form with + const defaultEncounter: Encounter = { + resourceType: 'Encounter', + status: 'in-progress', + class: { + system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode', + code: 'VR', + display: 'virtual', + }, + subject: subject, + participant: attenders, + period: period, + }; + + return ( +
+ + + + +
+ ); +} + +function getEncounterSubject(thread: Communication): Reference | undefined { + // If the thread has a subject, this will be the Encounter subject + if (thread.subject) { + return thread.subject; + } + + if (!thread.recipient || thread.recipient.length === 0) { + return undefined; + } + + // Filter for only the recipients that are patients + const patients = thread.recipient.filter( + (recipient) => parseReference(recipient)[0] === 'Patient' + ) as Reference[]; + + // If there are none, or more than one do not return a subject + if (patients.length !== 1) { + return undefined; + } + + // Return a the patient if there is only one + return patients[0]; +} diff --git a/examples/medplum-chat-demo/src/components/actions/CreateThread.tsx b/examples/medplum-chat-demo/src/components/actions/CreateThread.tsx new file mode 100644 index 0000000000..cfffd76508 --- /dev/null +++ b/examples/medplum-chat-demo/src/components/actions/CreateThread.tsx @@ -0,0 +1,187 @@ +import { Modal } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { createReference, getQuestionnaireAnswers, normalizeErrorString, parseReference } from '@medplum/core'; +import { + Communication, + Patient, + Practitioner, + Questionnaire, + QuestionnaireResponse, + QuestionnaireResponseItemAnswer, + Reference, +} from '@medplum/fhirtypes'; +import { QuestionnaireForm, useMedplum, useMedplumProfile } from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; +import { useNavigate } from 'react-router-dom'; +import { getRecipients, checkForInvalidRecipient } from '../../utils'; + +interface CreateThreadProps { + opened: boolean; + handlers: { + readonly open: () => void; + readonly close: () => void; + readonly toggle: () => void; + }; +} + +export function CreateThread({ opened, handlers }: CreateThreadProps): JSX.Element { + const medplum = useMedplum(); + const profile = useMedplumProfile() as Practitioner; + const navigate = useNavigate(); + + function onQuestionnaireSubmit(formData: QuestionnaireResponse): void { + const participants = getRecipients(formData) as QuestionnaireResponseItemAnswer[]; + const answers = getQuestionnaireAnswers(formData); + const topic = answers.topic.valueString as string; + const subject = answers.subject?.valueReference as Reference; + + if (subject && parseReference(subject)[0] !== 'Patient') { + showNotification({ + color: 'red', + icon: , + title: 'Error', + message: 'The subject of a thread must be a patient', + }); + throw new Error('The subject of a thread must be a patient'); + } + + handleCreateThread(topic, participants, subject).catch(console.error); + handlers.close(); + } + + async function handleCreateThread( + topic: string, + participants: QuestionnaireResponseItemAnswer[], + subject?: Reference + ): Promise { + // The suggested way to handle threads is by including all participants in the `recipients` field. This gets all people that are a entered as a recipient + const profileReference = createReference(profile); + + const recipients = participants + ?.filter((participant) => participant.valueReference?.reference !== profileReference.reference) + .map((participant) => participant.valueReference) as Communication['recipient']; + + if (!topic || !recipients) { + throw new Error('Please ensure a valid input.'); + } + + const invalidRecipients = checkForInvalidRecipient(recipients); + + if (invalidRecipients) { + showNotification({ + color: 'red', + icon: , + title: 'Error', + message: 'Invalid recipient type', + }); + throw new Error('Invalid recipient type'); + } + + // Add the user that created the trhead as a participant + recipients?.push(profileReference); + + const thread: Communication = { + resourceType: 'Communication', + status: 'in-progress', + topic: { + coding: [ + { + display: topic, + }, + ], + }, + recipient: recipients, + sender: profileReference, + }; + + if (subject) { + thread.subject = subject; + } + + try { + // Create the thread + const result = await medplum.createResource(thread); + showNotification({ + icon: , + title: 'Success', + message: 'Thread created', + }); + navigate(`/Communication/${result.id}`); + } catch (err) { + showNotification({ + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + } + } + + return ( + + + + ); +} + +const createThreadQuestionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + title: 'Start a New Thread', + id: 'new-thread', + item: [ + { + linkId: 'topic', + type: 'string', + text: 'Thread Topic:', + required: true, + }, + { + linkId: 'participants', + type: 'reference', + text: 'Add thread participants:', + repeats: true, + required: true, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-referenceResource', + valueCodeableConcept: { + coding: [ + { + code: 'Patient', + }, + { + code: 'Practitioner', + }, + { + code: 'RelatedPerson', + }, + ], + }, + }, + ], + }, + { + linkId: 'category', + type: 'choice', + text: 'Thread Category', + answerValueSet: 'https://example.org/thread-categories', + }, + { + linkId: 'subject', + type: 'reference', + text: 'Select a patient that is the subject of this thread (Optional)', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-referenceResource', + valueCodeableConcept: { + coding: [ + { + code: 'Patient', + }, + ], + }, + }, + ], + }, + ], +}; diff --git a/examples/medplum-chat-demo/src/components/actions/EditThreadTopic.tsx b/examples/medplum-chat-demo/src/components/actions/EditThreadTopic.tsx new file mode 100644 index 0000000000..6195dc5ca3 --- /dev/null +++ b/examples/medplum-chat-demo/src/components/actions/EditThreadTopic.tsx @@ -0,0 +1,88 @@ +import { Button, Modal } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { showNotification } from '@mantine/notifications'; +import { getQuestionnaireAnswers, normalizeErrorString, PatchOperation } from '@medplum/core'; +import { CodeableConcept, Communication, Questionnaire, QuestionnaireResponse } from '@medplum/fhirtypes'; +import { QuestionnaireForm, useMedplum } from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; + +interface EditTopicThreadProps { + readonly communication: Communication; + readonly onChange: (communication: Communication) => void; +} + +export function EditThreadTopic({ communication, onChange }: EditTopicThreadProps): JSX.Element { + const medplum = useMedplum(); + const [opened, handlers] = useDisclosure(false); + + function onQuestionnaireSubmit(formData: QuestionnaireResponse): void { + const newTopic = getQuestionnaireAnswers(formData)['edit-topic'].valueString; + + if (!newTopic) { + throw new Error('Please enter a new topic'); + } + + handleTopicUpdate(newTopic).catch(console.error); + handlers.close(); + } + + async function handleTopicUpdate(newTopic: string): Promise { + const communicationId = communication.id as string; + // Create a codeable concept for the topic + const topicCodeable: CodeableConcept = { + coding: [ + { + display: newTopic, + }, + ], + }; + + // Add a topic or replace the previous topic + const ops: PatchOperation[] = [ + { op: 'test', path: '/meta/versionId', value: communication.meta?.versionId }, + { op: communication.topic ? 'replace' : 'add', path: '/topic', value: topicCodeable }, + ]; + + try { + // Update the thread + const result = await medplum.patchResource(communication.resourceType, communicationId, ops); + showNotification({ + icon: , + title: 'Success', + message: 'Topic updated.', + }); + onChange(result); + } catch (err) { + showNotification({ + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + } + } + + return ( +
+ + + + +
+ ); +} + +const editTopicQuestionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + id: 'edit-thread-topic', + item: [ + { + linkId: 'edit-topic', + type: 'string', + text: 'Edit Thread Topic', + required: true, + }, + ], +}; diff --git a/examples/medplum-chat-demo/src/main.tsx b/examples/medplum-chat-demo/src/main.tsx new file mode 100644 index 0000000000..f83370501c --- /dev/null +++ b/examples/medplum-chat-demo/src/main.tsx @@ -0,0 +1,49 @@ +import { MantineProvider, createTheme } from '@mantine/core'; +import '@mantine/core/styles.css'; +import '@mantine/notifications/styles.css'; +import { Notifications } from '@mantine/notifications'; +import { MedplumClient } from '@medplum/core'; +import { MedplumProvider } from '@medplum/react'; +import '@medplum/react/styles.css'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { App } from './App'; + +const medplum = new MedplumClient({ + onUnauthenticated: () => (window.location.href = '/'), + // baseUrl: 'http://localhost:8103/', //Uncomment this to run against the server on your localhost; also change `googleClientId` in `./pages/SignInPage.tsx` +}); + +const theme = createTheme({ + headings: { + sizes: { + h1: { + fontSize: '1.125rem', + fontWeight: '500', + lineHeight: '2.0', + }, + }, + }, + fontSizes: { + xs: '0.6875rem', + sm: '0.875rem', + md: '0.875rem', + lg: '1.0rem', + xl: '1.125rem', + }, +}); + +const container = document.getElementById('root') as HTMLDivElement; +const root = createRoot(container); +const router = createBrowserRouter([{ path: '*', element: }]); +root.render( + + + + + + + + +); diff --git a/examples/medplum-chat-demo/src/pages/CommunicationPage.tsx b/examples/medplum-chat-demo/src/pages/CommunicationPage.tsx new file mode 100644 index 0000000000..ee765d071c --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/CommunicationPage.tsx @@ -0,0 +1,49 @@ +import { Communication } from '@medplum/fhirtypes'; +import { Loading, useMedplum } from '@medplum/react'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { MessagePage } from './MessagePage'; +import { ThreadPage } from './ThreadPage'; + +export function CommunicationPage(): JSX.Element { + const medplum = useMedplum(); + const { id } = useParams(); + const [communication, setCommunication] = useState(); + const [isThread, setIsThread] = useState(); + + function onCommunicationChange(newCommunication: Communication): void { + setCommunication(newCommunication); + } + + useEffect(() => { + const fetchData = async (): Promise => { + try { + if (id) { + const communication = await medplum.readResource('Communication', id); + setCommunication(communication); + + // If the Communication is a part of another communication, it is a message, otherwise it is a thread. For more details see https://www.medplum.com/docs/communications/organizing-communications + setIsThread(communication.partOf ? false : true); // eslint-disable-line no-unneeded-ternary + } + } catch (err) { + console.error(err); + } + }; + + fetchData().catch(console.error); + }); + + if (!communication) { + return ; + } + + return ( +
+ {isThread ? ( + + ) : ( + + )} +
+ ); +} diff --git a/examples/medplum-chat-demo/src/pages/LandingPage.tsx b/examples/medplum-chat-demo/src/pages/LandingPage.tsx new file mode 100644 index 0000000000..1605b639ee --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/LandingPage.tsx @@ -0,0 +1,21 @@ +import { Anchor, Button, Stack, Text, Title } from '@mantine/core'; +import { Document } from '@medplum/react'; +import { Link } from 'react-router-dom'; + +export function LandingPage(): JSX.Element { + return ( + + + Welcome! + + This Chat Demo shows how to build a simple React application that fetches messaging data from Medplum. If you + haven't already done so, register for Medplum + Project. After that you can sign into your project by clicking the link below. + + + + + ); +} diff --git a/examples/medplum-chat-demo/src/pages/MessagePage.tsx b/examples/medplum-chat-demo/src/pages/MessagePage.tsx new file mode 100644 index 0000000000..13d82bc5f3 --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/MessagePage.tsx @@ -0,0 +1,55 @@ +import { Grid, GridCol } from '@mantine/core'; +import { parseReference, resolveId } from '@medplum/core'; +import { Communication, Patient } from '@medplum/fhirtypes'; +import { PatientSummary, useMedplum } from '@medplum/react'; +import { useEffect, useState } from 'react'; +import { CommunicationDetails } from '../components/CommunicationDetails'; + +interface MessagePageProps { + readonly message: Communication; + readonly onChange: (communication: Communication) => void; +} + +export function MessagePage(props: MessagePageProps): JSX.Element { + const medplum = useMedplum(); + const [patient, setPatient] = useState(); + + // Get a reference to the patient if the sender of the message is a patient + const patientReference = getPatientReference(props.message); + + useEffect(() => { + const patientId = resolveId(patientReference); + + if (patientId) { + // Get the patient resource to display their summary + medplum.readResource('Patient', patientId).then(setPatient).catch(console.error); + } + }, [patientReference, medplum]); + + return ( +
+ {patient ? ( + + + + + + + + + ) : ( + + )} +
+ ); +} + +// If the sender of the message is a patient, return a reference to that patient +function getPatientReference(message: Communication): Communication['sender'] | undefined { + const sender = parseReference(message.sender); + if (sender[0] === 'Patient') { + return message.sender; + } + + return undefined; +} diff --git a/examples/medplum-chat-demo/src/pages/PatientHeader.module.css b/examples/medplum-chat-demo/src/pages/PatientHeader.module.css new file mode 100644 index 0000000000..71fbb95a41 --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/PatientHeader.module.css @@ -0,0 +1,26 @@ +.root { + display: flex; + flex-direction: row; + align-items: center; + padding: 8px 10px; + background: var(--mantine-color-white); + + & dl { + display: inline-block; + margin: 5px 20px 5px 5px; + } + + & dt { + color: var(--mantine-color-gray-6); + text-transform: uppercase; + font-size: var(--mantine-font-size-xs); + white-space: nowrap; + } + + & dd { + font-size: var(--mantine-font-size-md); + font-weight: 600; + margin-left: 0; + white-space: nowrap; + } +} diff --git a/examples/medplum-chat-demo/src/pages/PatientHeader.tsx b/examples/medplum-chat-demo/src/pages/PatientHeader.tsx new file mode 100644 index 0000000000..c576995a97 --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/PatientHeader.tsx @@ -0,0 +1,60 @@ +import { calculateAgeString } from '@medplum/core'; +import { Patient, Reference } from '@medplum/fhirtypes'; +import { HumanNameDisplay, MedplumLink, ResourceAvatar, useResource } from '@medplum/react'; +import classes from './PatientHeader.module.css'; + +export interface PatientHeaderProps { + readonly patient: Patient | Reference; +} + +export function PatientHeader(props: PatientHeaderProps): JSX.Element | null { + const patient = useResource(props.patient); + if (!patient) { + return null; + } + return ( +
+ +
+
Name
+
+ + {patient.name ? : '[blank]'} + +
+
+ {patient.birthDate && ( + <> +
+
DoB
+
{patient.birthDate}
+
+
+
Age
+
{calculateAgeString(patient.birthDate)}
+
+ + )} + {patient.gender && ( +
+
Gender
+
{patient.gender}
+
+ )} + {patient.address && ( + <> +
+
State
+
{patient.address?.[0]?.state}
+
+ + )} + {patient.identifier?.map((identifier, index) => ( +
+
{identifier?.system}
+
{identifier?.value}
+
+ ))} +
+ ); +} diff --git a/examples/medplum-chat-demo/src/pages/PatientPage.tsx b/examples/medplum-chat-demo/src/pages/PatientPage.tsx new file mode 100644 index 0000000000..0df0f637ea --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/PatientPage.tsx @@ -0,0 +1,43 @@ +import { Grid, GridCol, Loader } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { normalizeErrorString } from '@medplum/core'; +import { Patient } from '@medplum/fhirtypes'; +import { PatientSummary, useMedplum } from '@medplum/react'; +import { IconCircleOff } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { PatientDetails } from '../components/PatientDetails'; + +export function PatientPage(): JSX.Element { + const medplum = useMedplum(); + const { id } = useParams() as { id: string }; + const [patient, setPatient] = useState(); + + useEffect(() => { + medplum + .readResource('Patient', id) + .then(setPatient) + .catch((err) => { + showNotification({ + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + }); + }, [id, medplum]); + + if (!patient) { + return ; + } + + return ( + + + + + + + + + ); +} diff --git a/examples/medplum-chat-demo/src/pages/ResourcePage.tsx b/examples/medplum-chat-demo/src/pages/ResourcePage.tsx new file mode 100644 index 0000000000..ee8952bfa2 --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/ResourcePage.tsx @@ -0,0 +1,103 @@ +import { Grid, Tabs, Title } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { getDisplayString, normalizeErrorString } from '@medplum/core'; +import { Patient, Reference, Resource, ResourceType } from '@medplum/fhirtypes'; +import { + Document, + PatientSummary, + ResourceForm, + ResourceHistoryTable, + ResourceTable, + useMedplum, +} from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { cleanResource, shouldShowPatientSummary } from '../utils'; + +/** + * This is an example of a generic "Resource Display" page. + * It uses the Medplum `` component to display a resource. + * @returns A React component that displays a resource. + */ +export function ResourcePage(): JSX.Element | null { + const medplum = useMedplum(); + const navigate = useNavigate(); + const { resourceType, id } = useParams(); + const [resource, setResource] = useState(undefined); + const tabs = ['Details', 'Edit', 'History']; + + const tab = window.location.pathname.split('/').pop(); + const currentTab = tab && tabs.map((t) => t.toLowerCase()).includes(tab) ? tab : tabs[0]; + + useEffect(() => { + if (resourceType && id) { + medplum + .readResource(resourceType as ResourceType, id) + .then(setResource) + .catch(console.error); + } + }, [medplum, resourceType, id]); + + async function handleResourceEdit(newResource: Resource): Promise { + try { + const updatedResource = await medplum.updateResource(cleanResource(newResource)); + setResource(updatedResource); + showNotification({ + icon: , + title: 'Success', + message: `${resourceType} edited`, + }); + } catch (err) { + showNotification({ + color: 'red', + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + } + } + + function handleTabChange(newTab: string | null): void { + navigate(`/${resourceType}/${id}/${newTab ?? ''}`); + } + + if (!resource) { + return null; + } + + return ( +
+ + {resource.resourceType === 'Encounter' && shouldShowPatientSummary(resource) ? ( + + } m="sm" /> + + ) : null} + + + {getDisplayString(resource)} + + + {tabs.map((tab) => ( + + {tab} + + ))} + + + + + + + + + + + + + + +
+ ); +} diff --git a/examples/medplum-chat-demo/src/pages/SearchPage.tsx b/examples/medplum-chat-demo/src/pages/SearchPage.tsx new file mode 100644 index 0000000000..7caceef227 --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/SearchPage.tsx @@ -0,0 +1,164 @@ +import { Tabs } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { formatSearchQuery, getReferenceString, Operator, parseSearchRequest, SearchRequest } from '@medplum/core'; +import { Document, Loading, SearchControl, useMedplum } from '@medplum/react'; +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { CreateThread } from '../components/actions/CreateThread'; +import { getPopulatedSearch } from '../utils'; + +export function SearchPage(): JSX.Element { + const medplum = useMedplum(); + const navigate = useNavigate(); + const location = useLocation(); + const [search, setSearch] = useState(); + const [opened, handlers] = useDisclosure(false); + + // Only show the active and complete tabs when viewing Communication resources + const [showTabs, setShowTabs] = useState(() => { + const search = parseSearchRequest(location.pathname + location.search); + return shouldShowTabs(search); + }); + + const tabs = ['Active', 'Completed']; + const searchQuery = window.location.search; + const currentSearch = searchQuery ? parseSearchRequest(searchQuery) : null; + const currentTab = currentSearch ? handleInitialTab(currentSearch) : null; + + useEffect(() => { + const searchQuery = parseSearchRequest(location.pathname + location.search); + setShowTabs(shouldShowTabs(searchQuery)); + }, [location]); + + useEffect(() => { + const parsedSearch = parseSearchRequest(location.pathname + location.search); + // Navigate to view Communication resources by default + if (!parsedSearch.resourceType) { + navigate('/Communication'); + return; + } + + // Populate the search with details for a given resource type + const populatedSearch = getPopulatedSearch(parsedSearch); + + if ( + location.pathname === `/${populatedSearch.resourceType}` && + location.search === formatSearchQuery(populatedSearch) + ) { + // If you are alrady at the correct url, execute the search + setSearch(populatedSearch); + } else { + // Otherwise, navigate to the correct url before executing + navigate(`/${populatedSearch.resourceType}${formatSearchQuery(populatedSearch)}`); + } + }, [medplum, navigate, location]); + + function handleTabChange(newTab: string | null): void { + if (!search) { + throw new Error('Error: No valid search'); + } + + const updatedSearch = updateSearch(newTab ?? 'active', search); + const updatedSearchQuery = formatSearchQuery(updatedSearch); + navigate(`/Communication${updatedSearchQuery}`); + } + + if (!search?.resourceType || !search.fields || search.fields.length === 0) { + return ; + } + + return ( + + + {showTabs ? ( + + + {tabs.map((tab) => ( + + {tab} + + ))} + + + navigate(`/${getReferenceString(e.resource)}`)} + hideFilters={true} + hideToolbar={false} + onNew={handlers.open} + onChange={(e) => { + navigate(`/${search.resourceType}${formatSearchQuery(e.definition)}`); + }} + /> + + + navigate(`/${getReferenceString(e.resource)}`)} + hideFilters={true} + hideToolbar={false} + onNew={handlers.open} + onChange={(e) => { + navigate(`/${search.resourceType}${formatSearchQuery(e.definition)}`); + }} + /> + + + ) : ( + navigate(`/${getReferenceString(e.resource)}`)} + hideFilters={true} + hideToolbar={true} + /> + )} + + ); +} + +function handleInitialTab(currentSearch: SearchRequest): string { + if (!currentSearch.filters) { + return 'active'; + } + + for (const filter of currentSearch.filters) { + if (filter.value === 'completed') { + const tab = filter.operator; + if (tab === Operator.NOT) { + return 'active'; + } else { + return 'completed'; + } + } + } + return 'active'; +} + +function updateSearch(newTab: string, search: SearchRequest): SearchRequest { + const filters = search.filters || []; + const newCode = newTab === 'active' ? 'status:not' : 'status'; + + if (filters.length === 0) { + filters.push({ code: newCode, operator: Operator.EQUALS, value: 'completed' }); + } else { + for (const filter of filters) { + if (filter.value === 'completed') { + filter.code = newCode; + filter.operator = Operator.EQUALS; + } + } + } + + return { + ...search, + filters, + }; +} + +function shouldShowTabs(search: SearchRequest): boolean { + if (search.resourceType !== 'Communication') { + return false; + } + + return true; +} diff --git a/examples/medplum-chat-demo/src/pages/SignInPage.tsx b/examples/medplum-chat-demo/src/pages/SignInPage.tsx new file mode 100644 index 0000000000..d45c4dd67a --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/SignInPage.tsx @@ -0,0 +1,18 @@ +import { Title } from '@mantine/core'; +import { Logo, SignInForm } from '@medplum/react'; +import { useNavigate } from 'react-router-dom'; + +export function SignInPage(): JSX.Element { + const navigate = useNavigate(); + return ( + navigate('/')} + > + + Sign in to Medplum + + ); +} diff --git a/examples/medplum-chat-demo/src/pages/ThreadPage.tsx b/examples/medplum-chat-demo/src/pages/ThreadPage.tsx new file mode 100644 index 0000000000..b2e6fb4c32 --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/ThreadPage.tsx @@ -0,0 +1,92 @@ +import { Anchor, Grid, GridCol, List, Paper, Stack, Title } from '@mantine/core'; +import { resolveId } from '@medplum/core'; +import { Communication, Patient } from '@medplum/fhirtypes'; +import { PatientSummary, ThreadChat, useMedplum } from '@medplum/react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { CommunicationActions } from '../components/actions/CommunicationActions'; + +interface ThreadPageProps { + readonly thread: Communication; + readonly onChange: (communication: Communication) => void; +} + +export function ThreadPage(props: ThreadPageProps): JSX.Element { + const medplum = useMedplum(); + const navigate = useNavigate(); + const [patient, setPatient] = useState(); + + const patientReference = props.thread.subject; + + useEffect(() => { + const patientId = resolveId(patientReference); + + // Get the patient linked to this thread to display their information. + if (patientId) { + medplum.readResource('Patient', patientId).then(setPatient).catch(console.error); + } + }, [patientReference, medplum]); + + return ( +
+ {patient ? ( + + + + + + + + + + + + + + + + Participants + + {props.thread.recipient?.map((participant, index) => ( + + navigate(`/${participant.reference}`)}> + {participant.display ?? participant.reference} + + + ))} + + + + + + ) : ( + + + + + + + + + + + + + Participants + + {props.thread.recipient?.map((participant, index) => ( + + navigate(`/${participant.reference}`)}> + {participant.display ?? participant.reference} + + + ))} + + + + + + )} +
+ ); +} diff --git a/examples/medplum-chat-demo/src/pages/UploadDataPage.tsx b/examples/medplum-chat-demo/src/pages/UploadDataPage.tsx new file mode 100644 index 0000000000..e775bb9e52 --- /dev/null +++ b/examples/medplum-chat-demo/src/pages/UploadDataPage.tsx @@ -0,0 +1,55 @@ +import { Button } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { capitalize, MedplumClient, normalizeErrorString } from '@medplum/core'; +import { Bundle } from '@medplum/fhirtypes'; +import { Document, useMedplum } from '@medplum/react'; +import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; +import { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import exampleDataSet from '../../data/example/example-data.json'; + +export function UploadDataPage(): JSX.Element { + const medplum = useMedplum(); + const navigate = useNavigate(); + const [buttonDisabled, setButtonDisabled] = useState(false); + + // Get the data type and capitalize the first letter + const { dataType } = useParams(); + const dataTypeDisplay = dataType ? capitalize(dataType) : ''; + + function handleDataUpload(): void { + setButtonDisabled(true); + uploadData(medplum, dataType) + .then(() => navigate('/')) + .catch((err) => { + showNotification({ + color: 'red', + icon: , + title: 'Error', + message: normalizeErrorString(err), + }); + }) + .finally(() => setButtonDisabled(false)); + } + + return ( + + + + ); +} + +async function uploadData(medplum: MedplumClient, dataType?: string): Promise { + if (dataType === 'example') { + await medplum.executeBatch(exampleDataSet as Bundle); + showNotification({ + icon: , + title: 'Success', + message: `${capitalize(dataType)} data uploaded`, + }); + } else { + throw new Error('Invalid data type'); + } +} diff --git a/examples/medplum-chat-demo/src/utils.tsx b/examples/medplum-chat-demo/src/utils.tsx new file mode 100644 index 0000000000..cf06dcf32d --- /dev/null +++ b/examples/medplum-chat-demo/src/utils.tsx @@ -0,0 +1,186 @@ +import { Filter, getReferenceString, Operator, parseReference, SearchRequest } from '@medplum/core'; +import { + Communication, + Encounter, + EncounterParticipant, + Practitioner, + QuestionnaireResponse, + QuestionnaireResponseItemAnswer, + Resource, +} from '@medplum/fhirtypes'; + +export function cleanResource(resource: Resource): Resource { + let meta = resource.meta; + if (meta) { + meta = { + ...meta, + lastUpdated: undefined, + versionId: undefined, + author: undefined, + }; + } + return { + ...resource, + meta, + }; +} + +export function getPopulatedSearch(search: SearchRequest): SearchRequest { + const filters = search.filters ?? getDefaultFilters(search.resourceType); + const fields = search.fields ?? getDefaultFields(search.resourceType); + const sortRules = search.sortRules ?? [{ code: '-_lastUpdated' }]; + + return { + resourceType: search.resourceType, + filters, + fields, + sortRules, + }; +} + +export function getDefaultFilters(resourceType: string): Filter[] { + const filters = []; + + switch (resourceType) { + case 'Communication': + filters.push( + { code: 'part-of:missing', operator: Operator.EQUALS, value: 'true' }, + { code: 'status:not', operator: Operator.EQUALS, value: 'completed' } + ); + break; + } + + return filters; +} + +export function getDefaultFields(resourceType: string): string[] { + const fields = []; + + switch (resourceType) { + case 'Communication': + fields.push('topic', 'sender', 'recipient', 'sent'); + break; + case 'Patient': + fields.push('name', '_lastUpdated'); + break; + default: + fields.push('id'); + } + + return fields; +} + +// A helper function to specifically get all of the people entered as a participant in the form +export function getRecipients(formData: QuestionnaireResponse): QuestionnaireResponseItemAnswer[] | undefined { + const items = formData.item; + const recipients: QuestionnaireResponseItemAnswer[] = []; + + if (!items) { + return recipients; + } + + for (const item of items) { + if (item.linkId === 'participants') { + if (!item.answer) { + return recipients; + } + recipients.push(...item.answer); + } + } + + return recipients; +} + +export function checkForInvalidRecipient(recipients: Communication['recipient']): boolean { + if (!recipients) { + return true; + } + + for (const recipient of recipients) { + const resourceType = parseReference(recipient)[0]; + if ( + resourceType !== 'Patient' && + resourceType !== 'Practitioner' && + resourceType !== 'RelatedPerson' && + resourceType !== 'CareTeam' && + resourceType !== 'Device' && + resourceType !== 'Organization' && + resourceType !== 'Group' && + resourceType !== 'HealthcareService' && + resourceType !== 'PractitionerRole' + ) { + return true; + } + } + + return false; +} + +export function getAttenders( + recipients: Communication['recipient'], + profile: Practitioner, + userIsParticipant: boolean = false +): EncounterParticipant[] { + // Filter recipients to only practitioners + const practitionerRecipients = recipients?.filter((recipient) => parseReference(recipient)[0] === 'Practitioner'); + + // Add all the practitioners that are included on the thread as attendants on the encounter + const attenders: EncounterParticipant[] = practitionerRecipients + ? practitionerRecipients?.map((recipient) => { + // check if the user is on the recipient list + if (getReferenceString(profile) === getReferenceString(recipient)) { + userIsParticipant = true; + } + + return { + type: [ + { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationType', + code: 'ATND', + display: 'attender', + }, + ], + }, + ], + individual: { + reference: getReferenceString(recipient), + }, + }; + }) + : []; + + if (!userIsParticipant) { + attenders.push({ + type: [ + { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationType', + code: 'ATND', + display: 'attender', + }, + ], + }, + ], + individual: { + reference: getReferenceString(profile), + }, + }); + } + + return attenders; +} + +export function shouldShowPatientSummary(encounter: Encounter): boolean { + if (!encounter.subject) { + return false; + } + + if (parseReference(encounter.subject)[0] === 'Patient') { + return true; + } + + return false; +} diff --git a/examples/medplum-chat-demo/src/vite-env.d.ts b/examples/medplum-chat-demo/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/medplum-chat-demo/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/medplum-chat-demo/tsconfig.json b/examples/medplum-chat-demo/tsconfig.json new file mode 100644 index 0000000000..79240f45dd --- /dev/null +++ b/examples/medplum-chat-demo/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/examples/medplum-chat-demo/vercel.json b/examples/medplum-chat-demo/vercel.json new file mode 100644 index 0000000000..33c4ed5992 --- /dev/null +++ b/examples/medplum-chat-demo/vercel.json @@ -0,0 +1,5 @@ +{ + "installCommand": "npm install", + "buildCommand": "npm run build", + "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] +} diff --git a/examples/medplum-chat-demo/vite.config.ts b/examples/medplum-chat-demo/vite.config.ts new file mode 100644 index 0000000000..e4c46a598c --- /dev/null +++ b/examples/medplum-chat-demo/vite.config.ts @@ -0,0 +1,14 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import dns from 'dns'; + +dns.setDefaultResultOrder('verbatim'); + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: 'localhost', + port: 3000, + }, +}); diff --git a/examples/medplum-client-external-idp-demo/package.json b/examples/medplum-client-external-idp-demo/package.json index 974bbc45d0..b1c254d4fc 100644 --- a/examples/medplum-client-external-idp-demo/package.json +++ b/examples/medplum-client-external-idp-demo/package.json @@ -1,6 +1,6 @@ { "name": "medplum-client-external-idp-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -13,9 +13,9 @@ "singleQuote": true }, "devDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "rimraf": "5.0.5", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-demo-bots/package.json b/examples/medplum-demo-bots/package.json index b1be6b566b..3d070f2d4e 100644 --- a/examples/medplum-demo-bots/package.json +++ b/examples/medplum-demo-bots/package.json @@ -1,6 +1,6 @@ { "name": "medplum-demo-bots", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Demo Bots", "license": "Apache-2.0", "author": "Medplum ", @@ -29,16 +29,16 @@ "root": true }, "devDependencies": { - "@medplum/cli": "3.1.2", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@types/node": "20.12.5", + "@medplum/cli": "3.1.3", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@types/node": "20.12.7", "@types/node-fetch": "2.6.11", "@types/ssh2-sftp-client": "9.0.3", - "@vitest/coverage-v8": "1.4.0", - "@vitest/ui": "1.4.0", + "@vitest/coverage-v8": "1.5.0", + "@vitest/ui": "1.5.0", "esbuild": "0.20.2", "form-data": "4.0.0", "glob": "^10.3.12", @@ -46,8 +46,8 @@ "pdfmake": "0.2.10", "rimraf": "5.0.5", "ssh2-sftp-client": "10.0.3", - "stripe": "14.24.0", - "typescript": "5.4.4", - "vitest": "1.4.0" + "stripe": "15.1.0", + "typescript": "5.4.5", + "vitest": "1.5.0" } } diff --git a/examples/medplum-eligibility-demo/package.json b/examples/medplum-eligibility-demo/package.json index 83a74b4b45..33452b0b06 100644 --- a/examples/medplum-eligibility-demo/package.json +++ b/examples/medplum-eligibility-demo/package.json @@ -1,6 +1,6 @@ { "name": "medplum-eligibility-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -21,26 +21,26 @@ ] }, "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-fhircast-demo/package.json b/examples/medplum-fhircast-demo/package.json index 5c13f9589e..d121f9df2d 100644 --- a/examples/medplum-fhircast-demo/package.json +++ b/examples/medplum-fhircast-demo/package.json @@ -1,6 +1,6 @@ { "name": "medplum-fhircast-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -15,23 +15,23 @@ ] }, "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-hello-world/package.json b/examples/medplum-hello-world/package.json index 1574f7e969..ff4a9ab33c 100644 --- a/examples/medplum-hello-world/package.json +++ b/examples/medplum-hello-world/package.json @@ -1,6 +1,6 @@ { "name": "medplum-hello-world", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -19,24 +19,24 @@ ] }, "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-live-chat-demo/package.json b/examples/medplum-live-chat-demo/package.json index f632abb155..f3c3674e73 100644 --- a/examples/medplum-live-chat-demo/package.json +++ b/examples/medplum-live-chat-demo/package.json @@ -1,6 +1,6 @@ { "name": "medplum-live-chat-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -19,24 +19,24 @@ ] }, "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-live-chat-demo/src/pages/HomePage.tsx b/examples/medplum-live-chat-demo/src/pages/HomePage.tsx index b0698d631c..8363fc4e36 100644 --- a/examples/medplum-live-chat-demo/src/pages/HomePage.tsx +++ b/examples/medplum-live-chat-demo/src/pages/HomePage.tsx @@ -55,7 +55,7 @@ export function HomePage(): JSX.Element { recipient: [meReference, createReference(homerSimpson)], status: 'in-progress', }, - `part-of:missing=true&recipient=${getReferenceString(profile)},${getReferenceString(homerSimpson)}&topic:text='Demo Thread'` + `part-of:missing=true&recipient=${getReferenceString(profile)}&recipient=${getReferenceString(homerSimpson)}&topic:text='Demo Thread'` ) .then((thread) => { setThread(thread); diff --git a/examples/medplum-nextauth-demo/app/layout.tsx b/examples/medplum-nextauth-demo/app/layout.tsx index 3216050723..468d84ccf0 100644 --- a/examples/medplum-nextauth-demo/app/layout.tsx +++ b/examples/medplum-nextauth-demo/app/layout.tsx @@ -1,20 +1,15 @@ -import type { Metadata } from 'next'; import { MantineProvider } from '@mantine/core'; import '@mantine/core/styles.css'; import { Inter } from 'next/font/google'; +import { ReactNode } from 'react'; const inter = Inter({ subsets: ['latin'] }); -export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -}; - export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; -}>): React.ReactNode { + children: ReactNode; +}>): ReactNode { return ( diff --git a/examples/medplum-nextauth-demo/package.json b/examples/medplum-nextauth-demo/package.json index 9adf4fdecc..d5ffff83c3 100644 --- a/examples/medplum-nextauth-demo/package.json +++ b/examples/medplum-nextauth-demo/package.json @@ -1,6 +1,6 @@ { "name": "medplum-nextauth-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -20,23 +20,23 @@ ] }, "dependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "next": "14.1.4", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "next": "14.2.1", "next-auth": "4.24.7", "react": "18.2.0", "react-dom": "18.2.0" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@medplum/fhirtypes": "3.1.3", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "eslint": "8.57.0", - "eslint-config-next": "14.1.4", - "typescript": "5.4.4" + "eslint-config-next": "14.2.1", + "typescript": "5.4.5" } } diff --git a/examples/medplum-nextauth-demo/postcss.config.cjs b/examples/medplum-nextauth-demo/postcss.config.cjs new file mode 100644 index 0000000000..cef96c213e --- /dev/null +++ b/examples/medplum-nextauth-demo/postcss.config.cjs @@ -0,0 +1,17 @@ +module.exports = { + plugins: [ + 'postcss-preset-mantine', + [ + 'postcss-simple-vars', + { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + ], + ], +}; diff --git a/examples/medplum-nextjs-demo/app/layout.tsx b/examples/medplum-nextjs-demo/app/layout.tsx index 5ac7b3c633..764ad88518 100644 --- a/examples/medplum-nextjs-demo/app/layout.tsx +++ b/examples/medplum-nextjs-demo/app/layout.tsx @@ -1,6 +1,8 @@ +import { ColorSchemeScript, MantineProvider } from '@mantine/core'; import type { Metadata } from 'next'; import { ReactNode } from 'react'; import Root from './root'; +import { theme } from './theme'; // eslint-disable-next-line react-refresh/only-export-components export const metadata: Metadata = { @@ -15,8 +17,15 @@ export default function RootLayout(props: { children: ReactNode }): JSX.Element return ( + + + + + - {children} + + {children} + ); diff --git a/examples/medplum-nextjs-demo/app/root.tsx b/examples/medplum-nextjs-demo/app/root.tsx index c7f9a43c42..313559318f 100644 --- a/examples/medplum-nextjs-demo/app/root.tsx +++ b/examples/medplum-nextjs-demo/app/root.tsx @@ -1,6 +1,5 @@ 'use client'; -import { MantineProvider } from '@mantine/core'; import '@mantine/core/styles.css'; import { MedplumClient } from '@medplum/core'; import { MedplumProvider } from '@medplum/react'; @@ -22,9 +21,5 @@ const medplum = new MedplumClient({ }); export default function Root(props: { children: ReactNode }): JSX.Element { - return ( - - {props.children} - - ); + return {props.children}; } diff --git a/examples/medplum-nextjs-demo/app/theme.ts b/examples/medplum-nextjs-demo/app/theme.ts new file mode 100644 index 0000000000..7fdf832313 --- /dev/null +++ b/examples/medplum-nextjs-demo/app/theme.ts @@ -0,0 +1,7 @@ +'use client'; + +import { createTheme } from '@mantine/core'; + +export const theme = createTheme({ + /* Put your mantine theme override here */ +}); diff --git a/examples/medplum-nextjs-demo/next.config.js b/examples/medplum-nextjs-demo/next.config.js deleted file mode 100644 index 7f9a79bde9..0000000000 --- a/examples/medplum-nextjs-demo/next.config.js +++ /dev/null @@ -1,6 +0,0 @@ -const nextConfig = { - reactStrictMode: true, - swcMinify: true, -}; - -export default nextConfig; diff --git a/examples/medplum-nextjs-demo/next.config.mjs b/examples/medplum-nextjs-demo/next.config.mjs new file mode 100644 index 0000000000..8f38edd956 --- /dev/null +++ b/examples/medplum-nextjs-demo/next.config.mjs @@ -0,0 +1,16 @@ +import bundleAnalyzer from '@next/bundle-analyzer'; + +const withBundleAnalyzer = bundleAnalyzer({ + // eslint-disable-next-line no-undef + enabled: process.env.ANALYZE === 'true', +}); + +export default withBundleAnalyzer({ + reactStrictMode: false, + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + optimizePackageImports: ['@mantine/core', '@mantine/hooks'], + }, +}); diff --git a/examples/medplum-nextjs-demo/package.json b/examples/medplum-nextjs-demo/package.json index 450d1aefbd..d93fea723a 100644 --- a/examples/medplum-nextjs-demo/package.json +++ b/examples/medplum-nextjs-demo/package.json @@ -1,6 +1,6 @@ { "name": "medplum-nextjs-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -10,25 +10,26 @@ "start": "next start" }, "dependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/react": "3.1.2", - "next": "14.1.4", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/react": "3.1.3", + "@next/bundle-analyzer": "14.2.1", + "next": "14.2.1", "react": "18.2.0", "react-dom": "18.2.0", "rfc6902": "5.1.1" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@medplum/fhirtypes": "3.1.3", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "eslint": "8.57.0", - "eslint-config-next": "14.1.4", + "eslint-config-next": "14.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", - "typescript": "5.4.4" + "postcss-preset-mantine": "1.14.4", + "typescript": "5.4.5" } } diff --git a/examples/medplum-nextjs-demo/postcss.config.cjs b/examples/medplum-nextjs-demo/postcss.config.cjs new file mode 100644 index 0000000000..cef96c213e --- /dev/null +++ b/examples/medplum-nextjs-demo/postcss.config.cjs @@ -0,0 +1,17 @@ +module.exports = { + plugins: [ + 'postcss-preset-mantine', + [ + 'postcss-simple-vars', + { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + ], + ], +}; diff --git a/examples/medplum-nextjs-demo/postcss.config.mjs b/examples/medplum-nextjs-demo/postcss.config.mjs deleted file mode 100644 index feba649756..0000000000 --- a/examples/medplum-nextjs-demo/postcss.config.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import mantinePreset from 'postcss-preset-mantine'; -import simpleVars from 'postcss-simple-vars'; - -const config = { - plugins: [ - mantinePreset(), - simpleVars({ - variables: { - 'mantine-breakpoint-xs': '36em', - 'mantine-breakpoint-sm': '48em', - 'mantine-breakpoint-md': '62em', - 'mantine-breakpoint-lg': '75em', - 'mantine-breakpoint-xl': '88em', - }, - }), - ], -}; - -export default config; diff --git a/examples/medplum-provider/package.json b/examples/medplum-provider/package.json index ff7467305c..323006d2ed 100644 --- a/examples/medplum-provider/package.json +++ b/examples/medplum-provider/package.json @@ -1,6 +1,6 @@ { "name": "medplum-provider", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -19,24 +19,24 @@ ] }, "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-provider/src/App.tsx b/examples/medplum-provider/src/App.tsx index f41f4e83c1..93db197fc1 100644 --- a/examples/medplum-provider/src/App.tsx +++ b/examples/medplum-provider/src/App.tsx @@ -22,7 +22,6 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { CreateResourcePage } from './pages/CreateResourcePage'; import { HomePage } from './pages/HomePage'; import { OnboardingPage } from './pages/OnboardingPage'; -import { ResourcePage } from './pages/ResourcePage'; import { SearchPage } from './pages/SearchPage'; import { SignInPage } from './pages/SignInPage'; import { EditTab } from './pages/patient/EditTab'; @@ -33,6 +32,10 @@ import { PatientPage } from './pages/patient/PatientPage'; import { PatientSearchPage } from './pages/patient/PatientSearchPage'; import { TasksTab } from './pages/patient/TasksTab'; import { TimelineTab } from './pages/patient/TimelineTab'; +import { ResourceDetailPage } from './pages/resource/ResourceDetailPage'; +import { ResourceEditPage } from './pages/resource/ResourceEditPage'; +import { ResourceHistoryPage } from './pages/resource/ResourceHistoryPage'; +import { ResourcePage } from './pages/resource/ResourcePage'; export function App(): JSX.Element | null { const medplum = useMedplum(); @@ -117,14 +120,21 @@ export function App(): JSX.Element | null { } /> } /> } /> - } /> + }> + } /> + } /> + } /> + } /> } /> } /> } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> ) : ( diff --git a/examples/medplum-provider/src/hooks/usePatient.ts b/examples/medplum-provider/src/hooks/usePatient.ts index 313b2eb03a..7a0494928d 100644 --- a/examples/medplum-provider/src/hooks/usePatient.ts +++ b/examples/medplum-provider/src/hooks/usePatient.ts @@ -1,9 +1,10 @@ -import { Patient } from '@medplum/fhirtypes'; +import { OperationOutcome, Patient } from '@medplum/fhirtypes'; import { useResource } from '@medplum/react'; import { useParams } from 'react-router-dom'; type Options = { ignoreMissingPatientId?: boolean; + setOutcome?: (outcome: OperationOutcome) => void; }; export function usePatient(options?: Options): Patient | undefined { @@ -11,5 +12,5 @@ export function usePatient(options?: Options): Patient | undefined { if (!patientId && !options?.ignoreMissingPatientId) { throw new Error('Patient ID not found'); } - return useResource({ reference: `Patient/${patientId}` }); + return useResource({ reference: `Patient/${patientId}` }, options?.setOutcome); } diff --git a/examples/medplum-provider/src/pages/CreateResourcePage.tsx b/examples/medplum-provider/src/pages/CreateResourcePage.tsx index 8d79b380e7..6dbe55ab05 100644 --- a/examples/medplum-provider/src/pages/CreateResourcePage.tsx +++ b/examples/medplum-provider/src/pages/CreateResourcePage.tsx @@ -1,20 +1,59 @@ import { Stack, Text } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; -import { normalizeErrorString, normalizeOperationOutcome } from '@medplum/core'; -import { OperationOutcome, Resource, ResourceType } from '@medplum/fhirtypes'; -import { Document, ResourceForm, useMedplum } from '@medplum/react'; -import { useState } from 'react'; +import { createReference, normalizeErrorString, normalizeOperationOutcome } from '@medplum/core'; +import { OperationOutcome, Patient, Resource, ResourceType } from '@medplum/fhirtypes'; +import { Document, Loading, ResourceForm, useMedplum } from '@medplum/react'; +import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { usePatient } from '../hooks/usePatient'; import { prependPatientPath } from './patient/PatientPage.utils'; +const PatientReferencesElements: Partial> = { + Task: ['for'], + MedicationRequest: ['subject'], + ServiceRequest: ['subject'], + Device: ['patient'], + DiagnosticReport: ['subject'], + DocumentReference: ['subject'], + Appointment: ['participant.actor'], + CarePlan: ['subject'], +}; + +function getDefaultValue(resourceType: ResourceType, patient: Patient | undefined): Partial { + const dv = { resourceType } as Partial; + const refKeys = PatientReferencesElements[resourceType]; + if (patient && refKeys) { + for (const key of refKeys) { + const keyParts = key.split('.'); + if (keyParts.length === 1) { + (dv as any)[key] = createReference(patient); + } else if (keyParts.length === 2) { + const [first, second] = keyParts; + (dv as any)[first] = [{ [second]: createReference(patient) }]; + } else { + throw new Error('Can only process keys with one or two parts'); + } + } + } + + return dv; +} + export function CreateResourcePage(): JSX.Element { const medplum = useMedplum(); - const patient = usePatient({ ignoreMissingPatientId: true }); - const navigate = useNavigate(); - const { resourceType } = useParams() as { resourceType: ResourceType }; const [outcome, setOutcome] = useState(); - const defaultValue = { resourceType } as Partial; + const patient = usePatient({ ignoreMissingPatientId: true, setOutcome }); + const navigate = useNavigate(); + const { patientId, resourceType } = useParams() as { patientId: string | undefined; resourceType: ResourceType }; + const [loadingPatient, setLoadingPatient] = useState(Boolean(patientId)); + const [defaultValue, setDefaultValue] = useState>(() => getDefaultValue(resourceType, patient)); + + useEffect(() => { + if (patient) { + setDefaultValue(getDefaultValue(resourceType, patient)); + } + setLoadingPatient(false); + }, [patient, resourceType]); const handleSubmit = (newResource: Resource): void => { if (outcome) { @@ -36,6 +75,10 @@ export function CreateResourcePage(): JSX.Element { }); }; + if (loadingPatient) { + return ; + } + return ( diff --git a/examples/medplum-provider/src/pages/ResourcePage.tsx b/examples/medplum-provider/src/pages/ResourcePage.tsx deleted file mode 100644 index 59f9a6b759..0000000000 --- a/examples/medplum-provider/src/pages/ResourcePage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Title } from '@mantine/core'; -import { getDisplayString, getReferenceString } from '@medplum/core'; -import { Resource, ResourceType } from '@medplum/fhirtypes'; -import { Document, ResourceTable, useMedplum } from '@medplum/react'; -import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; - -/** - * This is an example of a generic "Resource Display" page. - * It uses the Medplum `` component to display a resource. - * @returns A React component that displays a resource. - */ -export function ResourcePage(): JSX.Element | null { - const medplum = useMedplum(); - const { resourceType, id } = useParams(); - const [resource, setResource] = useState(undefined); - - useEffect(() => { - if (resourceType && id) { - medplum - .readResource(resourceType as ResourceType, id) - .then(setResource) - .catch(console.error); - } - }, [medplum, resourceType, id]); - - if (!resource) { - return null; - } - - return ( - - {getDisplayString(resource)} - - - ); -} diff --git a/examples/medplum-provider/src/pages/resource/ResourceDetailPage.tsx b/examples/medplum-provider/src/pages/resource/ResourceDetailPage.tsx new file mode 100644 index 0000000000..cd97a049a6 --- /dev/null +++ b/examples/medplum-provider/src/pages/resource/ResourceDetailPage.tsx @@ -0,0 +1,25 @@ +import { Stack, Title } from '@mantine/core'; +import { getDisplayString } from '@medplum/core'; +import { ResourceTable, useResource } from '@medplum/react'; +import { useParams } from 'react-router-dom'; + +/** + * This is an example of a generic "Resource Display" page. + * It uses the Medplum `` component to display a resource. + * @returns A React component that displays a resource. + */ +export function ResourceDetailPage(): JSX.Element | null { + const { resourceType, id } = useParams(); + const resource = useResource({ reference: resourceType + '/' + id }); + + if (!resource) { + return null; + } + + return ( + + {getDisplayString(resource)} + + + ); +} diff --git a/examples/medplum-provider/src/pages/resource/ResourceEditPage.tsx b/examples/medplum-provider/src/pages/resource/ResourceEditPage.tsx new file mode 100644 index 0000000000..7debf54909 --- /dev/null +++ b/examples/medplum-provider/src/pages/resource/ResourceEditPage.tsx @@ -0,0 +1,51 @@ +import { showNotification } from '@mantine/notifications'; +import { deepClone, normalizeErrorString, normalizeOperationOutcome } from '@medplum/core'; +import { OperationOutcome, Resource, ResourceType } from '@medplum/fhirtypes'; +import { ResourceForm, useMedplum } from '@medplum/react'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +export function ResourceEditPage(): JSX.Element | null { + const medplum = useMedplum(); + const { resourceType, id } = useParams() as { resourceType: ResourceType | undefined; id: string | undefined }; + const [value, setValue] = useState(); + const navigate = useNavigate(); + const [outcome, setOutcome] = useState(); + + useEffect(() => { + if (resourceType && id) { + medplum + .readResource(resourceType as ResourceType, id) + .then((resource) => setValue(deepClone(resource))) + .catch((err) => { + setOutcome(normalizeOperationOutcome(err)); + showNotification({ color: 'red', message: normalizeErrorString(err), autoClose: false }); + }); + } + }, [medplum, resourceType, id]); + + const handleSubmit = useCallback( + (newResource: Resource): void => { + setOutcome(undefined); + medplum + .updateResource(newResource) + .then(() => { + navigate('..'); + showNotification({ color: 'green', message: 'Success' }); + }) + .catch((err) => { + setOutcome(normalizeOperationOutcome(err)); + showNotification({ color: 'red', message: normalizeErrorString(err), autoClose: false }); + }); + }, + [medplum, navigate] + ); + + const handleDelete = useCallback(() => navigate('..'), [navigate]); + + if (!value) { + return null; + } + + return ; +} diff --git a/examples/medplum-provider/src/pages/resource/ResourceHistoryPage.tsx b/examples/medplum-provider/src/pages/resource/ResourceHistoryPage.tsx new file mode 100644 index 0000000000..012b3b0322 --- /dev/null +++ b/examples/medplum-provider/src/pages/resource/ResourceHistoryPage.tsx @@ -0,0 +1,13 @@ +import { ResourceType } from '@medplum/fhirtypes'; +import { ResourceHistoryTable } from '@medplum/react'; +import { useParams } from 'react-router-dom'; + +export function ResourceHistoryPage(): JSX.Element | null { + const { resourceType, id } = useParams() as { resourceType: ResourceType | undefined; id: string | undefined }; + + if (!resourceType || !id) { + return null; + } + + return ; +} diff --git a/examples/medplum-provider/src/pages/resource/ResourcePage.module.css b/examples/medplum-provider/src/pages/resource/ResourcePage.module.css new file mode 100644 index 0000000000..e382ced24b --- /dev/null +++ b/examples/medplum-provider/src/pages/resource/ResourcePage.module.css @@ -0,0 +1,7 @@ +.tab { + color: var(--tabs-color); + + &[data-active] { + color: var(--mantine-color-white); + } +} diff --git a/examples/medplum-provider/src/pages/resource/ResourcePage.tsx b/examples/medplum-provider/src/pages/resource/ResourcePage.tsx new file mode 100644 index 0000000000..70ada7b9ae --- /dev/null +++ b/examples/medplum-provider/src/pages/resource/ResourcePage.tsx @@ -0,0 +1,70 @@ +import { Stack, Tabs } from '@mantine/core'; +import { getReferenceString } from '@medplum/core'; +import { Resource, ResourceType } from '@medplum/fhirtypes'; +import { Document, useMedplum } from '@medplum/react'; +import { useCallback, useEffect, useState } from 'react'; +import { Outlet, useNavigate, useParams } from 'react-router-dom'; +import classes from './ResourcePage.module.css'; + +const tabs = [ + { id: 'details', url: '', label: 'Details' }, + { id: 'edit', url: 'edit', label: 'Edit' }, + { id: 'history', url: 'history', label: 'History' }, +]; + +export function ResourcePage(): JSX.Element | null { + const navigate = useNavigate(); + const medplum = useMedplum(); + const { resourceType, id } = useParams(); + const [resource, setResource] = useState(undefined); + const [currentTab, setCurrentTab] = useState(() => { + const tabId = window.location.pathname.split('/').pop(); + const tab = tabId ? tabs.find((t) => t.id === tabId) : undefined; + return (tab ?? tabs[0]).id; + }); + + useEffect(() => { + if (resourceType && id) { + medplum + .readResource(resourceType as ResourceType, id) + .then(setResource) + .catch(console.error); + } + }, [medplum, resourceType, id]); + + const onTabChange = useCallback( + (newTabName: string | null): void => { + if (!newTabName) { + newTabName = tabs[0].id; + } + + const tab = tabs.find((t) => t.id === newTabName); + if (tab) { + setCurrentTab(tab.id); + navigate(tab.url); + } + }, + [navigate] + ); + + if (!resource) { + return null; + } + + return ( + + + + + {tabs.map((t) => ( + + {t.label} + + ))} + + + + + + ); +} diff --git a/examples/medplum-react-native-example/package.json b/examples/medplum-react-native-example/package.json index 5a46693fcc..aa06de41eb 100644 --- a/examples/medplum-react-native-example/package.json +++ b/examples/medplum-react-native-example/package.json @@ -1,6 +1,6 @@ { "name": "medplum-react-native-example", - "version": "3.1.2", + "version": "3.1.3", "main": "src/main.ts", "scripts": { "android": "expo start --android", @@ -20,11 +20,11 @@ }, "dependencies": { "@expo/metro-runtime": "3.1.3", - "@medplum/core": "3.1.2", - "@medplum/expo-polyfills": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react-hooks": "3.1.2", - "expo": "50.0.14", + "@medplum/core": "3.1.3", + "@medplum/expo-polyfills": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react-hooks": "3.1.3", + "expo": "50.0.15", "expo-status-bar": "1.11.1", "react": "18.2.0", "react-dom": "18.2.0", @@ -33,6 +33,6 @@ }, "devDependencies": { "@babel/core": "7.24.4", - "typescript": "5.4.4" + "typescript": "5.4.5" } } diff --git a/examples/medplum-react-native-example/src/Home.tsx b/examples/medplum-react-native-example/src/Home.tsx index 4a4fb8f60b..009fee3feb 100644 --- a/examples/medplum-react-native-example/src/Home.tsx +++ b/examples/medplum-react-native-example/src/Home.tsx @@ -1,6 +1,6 @@ import { LoginAuthenticationResponse, getDisplayString } from '@medplum/core'; import { Patient } from '@medplum/fhirtypes'; -import { useMedplum, useMedplumContext, useMedplumProfile } from '@medplum/react-hooks'; +import { useMedplum, useMedplumContext, useMedplumProfile, useSubscription } from '@medplum/react-hooks'; import { StatusBar } from 'expo-status-bar'; import { useState } from 'react'; import { ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; @@ -121,6 +121,7 @@ export default function Home(): JSX.Element { + )} @@ -130,6 +131,32 @@ export default function Home(): JSX.Element { ); } +interface NotificationsWidgitProps { + title?: string; + criteria: string; +} + +function NotificationsWidgit(props: NotificationsWidgitProps): JSX.Element { + const [notifications, setNotifications] = useState(0); + + useSubscription(props.criteria, () => { + setNotifications(notifications + 1); + }); + + function clearNotifications(): void { + setNotifications(0); + } + + return ( + + + {props.title ?? 'Notifications:'} {notifications} + + + + ); +} + const styles = StyleSheet.create({ container: { flex: 1, diff --git a/examples/medplum-task-demo/.gitignore b/examples/medplum-task-demo/.gitignore index 81aa27a05a..231199a085 100644 --- a/examples/medplum-task-demo/.gitignore +++ b/examples/medplum-task-demo/.gitignore @@ -13,6 +13,7 @@ dist-ssr *.local package-lock.json .env +data/example/example-bots.json # Editor directories and files .vscode/* @@ -23,4 +24,4 @@ package-lock.json *.ntvs* *.njsproj *.sln -*.sw? +*.sw? \ No newline at end of file diff --git a/examples/medplum-task-demo/data/core/business-status-value-sets.json b/examples/medplum-task-demo/data/core/business-status-value-sets.json deleted file mode 100644 index daead0fcf2..0000000000 --- a/examples/medplum-task-demo/data/core/business-status-value-sets.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "resourceType": "Bundle", - "type": "batch", - "entry": [ - { - "request": { "method": "POST", "url": "ValueSet" }, - "resource": { - "resourceType": "ValueSet", - "id": "business-status", - "url": "https://medplum.com/medplum-task-example-app/task-status-valueset", - "name": "example-business-status-value-set", - "title": "Example Business Status Value Set", - "status": "active", - "compose": { - "include": [ - { - "system": "https://medplum.com/medplum-task-example-app/task-status-valueset", - "concept": [ - { - "code": "doctor-sign-off-needed", - "display": "Doctor sign-off needed." - }, - { - "code": "doctor-review-needed", - "display": "Doctor review needed." - }, - { - "code": "follow-up-needed", - "display": "Follow-up needed." - } - ] - } - ] - } - } - } - ] -} diff --git a/examples/medplum-task-demo/data/core/business-status-valueset.json b/examples/medplum-task-demo/data/core/business-status-valueset.json new file mode 100644 index 0000000000..f0e7633dd0 --- /dev/null +++ b/examples/medplum-task-demo/data/core/business-status-valueset.json @@ -0,0 +1,33 @@ +{ + "resourceType": "ValueSet", + "id": "urn:uuid:39996991-94bd-4fdd-bba6-d72ec5e3cd21", + "url": "https://medplum.com/medplum-task-example-app/task-status", + "name": "example-business-status-value-set", + "title": "Example Business Status Value Set", + "status": "active", + "compose": { + "include": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "concept": [ + { + "code": "ready", + "display": "Ready" + }, + { + "code": "doctor-sign-off-needed", + "display": "Doctor sign-off needed" + }, + { + "code": "doctor-review-needed", + "display": "Doctor review needed" + }, + { + "code": "follow-up-needed", + "display": "Follow-up needed" + } + ] + } + ] + } +} diff --git a/examples/medplum-task-demo/data/core/practitioner-role-valueset.json b/examples/medplum-task-demo/data/core/practitioner-role-valueset.json index 095cf12d05..a07ec209db 100644 --- a/examples/medplum-task-demo/data/core/practitioner-role-valueset.json +++ b/examples/medplum-task-demo/data/core/practitioner-role-valueset.json @@ -1,6 +1,6 @@ { "resourceType": "ValueSet", - "id": "7869c2b5-77a3-428b-9088-0401e3632c87", + "id": "urn:uuid:7869c2b5-77a3-428b-9088-0401e3632c87", "url": "http://medplum.com/medplum-task-demo/practitioner-role-codes", "name": "Practitioner Role Codes", "title": "Practitioner Role Codes", diff --git a/examples/medplum-task-demo/data/core/task-type-valueset.json b/examples/medplum-task-demo/data/core/task-type-valueset.json new file mode 100644 index 0000000000..c6aa9d9d9c --- /dev/null +++ b/examples/medplum-task-demo/data/core/task-type-valueset.json @@ -0,0 +1,32 @@ +{ + "resourceType": "ValueSet", + "url": "https://medplum.com/medplum-task-demo/task-type", + "name": "Example Task Type Value Set", + "title": "Example Task Type Value Set", + "status": "active", + "compose": { + "include": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "concept": [ + { + "code": "respond-to-message", + "display": "Respond to Patient Message" + }, + { + "code": "schedule-appointment", + "display": "Schedule Appointment" + }, + { + "code": "review-diagnostic-report", + "display": "Review Diagnostic Report" + }, + { + "code": "verify-drivers-license", + "display": "Verify Drivers License" + } + ] + } + ] + } +} diff --git a/examples/medplum-task-demo/data/example/respond-to-message-data.json b/examples/medplum-task-demo/data/example/example-messages.json similarity index 72% rename from examples/medplum-task-demo/data/example/respond-to-message-data.json rename to examples/medplum-task-demo/data/example/example-messages.json index 93bc038b99..b91ce516bd 100644 --- a/examples/medplum-task-demo/data/example/respond-to-message-data.json +++ b/examples/medplum-task-demo/data/example/example-messages.json @@ -1,15 +1,55 @@ { "resourceType": "Bundle", - "type": "batch", + "type": "transaction", "entry": [ + { + "fullUrl": "urn:uuid:93e4cba2-9db5-11ee-8c90-0242ac876543", + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "SS" + } + ] + }, + "system": "http://hl7.org/fhir/sid/us-ssn", + "value": "333222222" + } + ], + "name": [ + { + "given": ["John"], + "family": "Smith " + } + ], + "birthDate": "1970-01-01", + "address": [ + { + "use": "home", + "line": ["123 Main St."], + "city": "Springfiled", + "state": "IL", + "postalCode": "98732" + } + ] + }, + "request": { + "method": "POST", + "url": "Patient" + } + }, { "fullUrl": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002", "resource": { "resourceType": "Communication", "status": "in-progress", "subject": { - "reference": "Patient/8f0d3209-0ee0-487f-b186-4328a949190f", - "display": "Mr. Lucien408 Bosco882 PharmD" + "reference": "urn:uuid:93e4cba2-9db5-11ee-8c90-0242ac876543", + "display": "John Smith" }, "topic": { "coding": [{ "code": "Lab test results", "display": "Lab test results" }] } }, @@ -22,7 +62,7 @@ "payload": [{ "contentString": "Do you have the results of my lab tests yet?" }], "topic": { "text": "December 15th lab tests." }, "partOf": [{ "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" }], - "sender": { "reference": "Patient/8f0d3209-0ee0-487f-b186-4328a949190f" }, + "sender": { "reference": "urn:uuid:93e4cba2-9db5-11ee-8c90-0242ac876543" }, "sent": "2023-12-18T14:26:06.531Z" }, "request": { "method": "POST", "url": "Communication" } @@ -46,7 +86,7 @@ "payload": [{ "contentString": "Yes, it is 12345" }], "topic": { "text": "December 15th lab tests." }, "partOf": [{ "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" }], - "sender": { "reference": "Patient/8f0d3209-0ee0-487f-b186-4328a949190f" }, + "sender": { "reference": "urn:uuid:93e4cba2-9db5-11ee-8c90-0242ac876543" }, "sent": "2023-12-18T14:46:06.531Z" }, "request": { "method": "POST", "url": "Communication" } @@ -88,7 +128,8 @@ } ], "sender": { - "reference": "Patient/8f0d3209-0ee0-487f-b186-4328a949190f" + "reference": "urn:uuid:93e4cba2-9db5-11ee-8c90-0242ac876543", + "display": "John Smith" }, "sent": "2023-12-18T14:01:15.175Z" }, @@ -103,7 +144,7 @@ "resourceType": "Communication", "status": "in-progress", "subject": { - "reference": "Patient/8f0d3209-0ee0-487f-b186-4328a949190f", + "reference": "urn:uuid:93e4cba2-9db5-11ee-8c90-0242ac876543", "display": "Mr. Lucien408 Bosco882 PharmD" }, "topic": { "coding": [{ "code": "Prescription Refill", "display": "Prescription Refill" }] } @@ -117,19 +158,10 @@ "payload": [{ "contentString": "My prescription ran out, can I come in to refill it?" }], "topic": { "text": "December 15th lab tests." }, "partOf": [{ "reference": "urn:uuid:ab308536-9e07-11ee-8c90-0242ac120002" }], - "sender": { "reference": "Patient/8f0d3209-0ee0-487f-b186-4328a949190f" }, + "sender": { "reference": "urn:uuid:93e4cba2-9db5-11ee-8c90-0242ac876543" }, "sent": "2023-12-18T14:26:06.531Z" }, "request": { "method": "POST", "url": "Communication" } - }, - { - "resource": { - "resourceType": "Task", - "status": "in-progress", - "focus": { "reference": "urn:uuid:ab308536-9e07-11ee-8c90-0242ac120002" }, - "code": { "text": "Respond to Message" } - }, - "request": { "method": "POST", "url": "Task" } } ] } diff --git a/examples/medplum-task-demo/data/example/example-practitioner-role.json b/examples/medplum-task-demo/data/example/example-practitioner-role.json new file mode 100644 index 0000000000..ad34aafc60 --- /dev/null +++ b/examples/medplum-task-demo/data/example/example-practitioner-role.json @@ -0,0 +1,29 @@ +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "fullUrl": "fa6ad189-d8ce-4a76-aafc-98aaf1acc347", + "resource": { + "resourceType": "PractitionerRole", + "code": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "158965000", + "display": "Doctor" + } + ] + } + ], + "practitioner": "$practitioner" + }, + "request": { + "method": "POST", + "url": "PractitionerRole", + "ifNoneExist": "practitioner=$practitionerReference" + } + } + ] +} diff --git a/examples/medplum-task-demo/data/example/example-reports.json b/examples/medplum-task-demo/data/example/example-reports.json new file mode 100644 index 0000000000..e9679cc6aa --- /dev/null +++ b/examples/medplum-task-demo/data/example/example-reports.json @@ -0,0 +1,297 @@ +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "fullUrl": "urn:uuid:9812cba2-9db5-11ee-8c90-0242ac104829", + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "SS" + } + ] + }, + "system": "http://hl7.org/fhir/sid/us-ssn", + "value": "5552222121" + } + ], + "name": [ + { + "given": ["Benny"], + "family": "Hill" + } + ], + "birthDate": "1970-01-01", + "address": [ + { + "use": "home", + "line": ["123 Main St."], + "city": "Springfiled", + "state": "IL", + "postalCode": "98732" + } + ] + }, + "request": { + "method": "POST", + "url": "Patient" + } + }, + { + "fullUrl": "urn:uuid:3385ee9e-f180-4e8b-8b12-cf4833144e7b", + "resource": { + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "35200-5", + "display": "Cholesterol [Mass or Moles/volume] in Serum or Plasma" + } + ], + "text": "Cholesterol" + }, + "subject": { + "reference": "urn:uuid:9812cba2-9db5-11ee-8c90-0242ac104829", + "display": "Benny Hill" + }, + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "valueQuantity": { + "value": 6.3, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "high": { + "value": 4.5, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "POST", + "url": "Observation" + } + }, + { + "fullUrl": "urn:uuid:94e4378e-9336-4538-98f5-47dcf1ded634", + "resource": { + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "35217-9", + "display": "Triglyceride [Mass or Moles/volume] in Serum or Plasma" + } + ], + "text": "Triglyceride" + }, + "subject": { + "reference": "urn:uuid:9812cba2-9db5-11ee-8c90-0242ac104829", + "display": "Benny Hill" + }, + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "valueQuantity": { + "value": 1.3, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "high": { + "value": 2, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "POST", + "url": "Observation" + } + }, + { + "fullUrl": "urn:uuid:3b7c71bb-1357-4cac-88d7-3e374676e988", + "resource": { + "resourceType": "Observation", + "id": "hdlcholesterol", + "text": { + "status": "generated", + "div": "

Generated Narrative: Observation

Resource Observation "hdlcholesterol"

status: final

code: Cholesterol in HDL (LOINC#2085-9 "Cholesterol in HDL [Mass/volume] in Serum or Plasma")

subject: Patient/pat2 "Duck DONALD"

performer: Organization/1832473e-2fe0-452d-abe9-3cdb9879522f: Acme Laboratory, Inc "Clinical Lab"

value: 1.3 mmol/L (Details: UCUM code mmol/L = 'mmol/L')

ReferenceRanges

-Low
*1.5 mmol/L (Details: UCUM code mmol/L = 'mmol/L')
" + }, + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "2085-9", + "display": "Cholesterol in HDL [Mass/volume] in Serum or Plasma" + } + ], + "text": "Cholesterol in HDL" + }, + "subject": { + "reference": "urn:uuid:9812cba2-9db5-11ee-8c90-0242ac104829", + "display": "Benny Hill" + }, + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "valueQuantity": { + "value": 1.3, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "low": { + "value": 1.5, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "POST", + "url": "Observation" + } + }, + { + "fullUrl": "urn:uuid:f2aedf86-166c-4f14-995e-9320ebab06e7", + "resource": { + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "13457-7", + "display": "Cholesterol in LDL [Mass/volume] in Serum or Plasma by calculation" + } + ], + "text": "LDL Chol. (Calc)" + }, + "subject": { + "reference": "urn:uuid:9812cba2-9db5-11ee-8c90-0242ac104829", + "display": "Benny Hill" + }, + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "valueQuantity": { + "value": 4.6, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "high": { + "value": 3, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "POST", + "url": "Observation" + } + }, + { + "fullUrl": "urn:uuid:9812cba2-9db5-11ee-8c90-0242ac104829", + "resource": { + "resourceType": "DiagnosticReport", + "identifier": [ + { + "system": "http://acme.com/lab/reports", + "value": "5234342" + } + ], + "status": "preliminary", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0074", + "code": "HM" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "57698-3", + "display": "Lipid panel with direct LDL - Serum or Plasma" + } + ], + "text": "Lipid Panel" + }, + "subject": { + "reference": "urn:uuid:9812cba2-9db5-11ee-8c90-0242ac104829", + "display": "Benny Hill" + }, + "effectiveDateTime": "2011-03-04T08:30:00+11:00", + "issued": "2013-01-27T11:45:33+11:00", + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "result": [ + { + "reference": "urn:uuid:3385ee9e-f180-4e8b-8b12-cf4833144e7b" + }, + { + "reference": "urn:uuid:94e4378e-9336-4538-98f5-47dcf1ded634" + }, + { + "reference": "urn:uuid:3b7c71bb-1357-4cac-88d7-3e374676e988" + }, + { + "reference": "urn:uuid:f2aedf86-166c-4f14-995e-9320ebab06e7" + } + ] + }, + "request": { + "method": "POST", + "url": "DiagnosticReport" + } + } + ] +} diff --git a/examples/medplum-task-demo/data/example/example-tasks.json b/examples/medplum-task-demo/data/example/example-tasks.json new file mode 100644 index 0000000000..500f4ff0ec --- /dev/null +++ b/examples/medplum-task-demo/data/example/example-tasks.json @@ -0,0 +1,1279 @@ +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "fullUrl": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "SS" + } + ] + }, + "system": "http://hl7.org/fhir/sid/us-ssn", + "value": "444222222" + } + ], + "name": [ + { + "given": ["Jane"], + "family": "California" + } + ], + "birthDate": "1970-01-01", + "address": [ + { + "use": "home", + "line": ["123 Main St."], + "city": "San Francisco", + "state": "CA", + "postalCode": "98732" + } + ] + }, + "request": { + "method": "POST", + "url": "Patient" + } + }, + { + "fullUrl": "urn:uuid:3d9d2d9c-6648-4583-812b-d0d389facca2", + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "SS" + } + ] + }, + "system": "http://hl7.org/fhir/sid/us-ssn", + "value": "923081234" + } + ], + "name": [ + { + "given": ["Walker", "Texas"], + "family": "Ranger" + } + ], + "birthDate": "1970-01-01", + "address": [ + { + "use": "home", + "line": ["123 Main St."], + "city": "Dallas", + "state": "TX", + "postalCode": "98732" + } + ] + }, + "request": { + "method": "POST", + "url": "Patient" + } + }, + { + "fullUrl": "urn:uuid:ba4a1ef7-c674-4acb-a506-6aac4a7aec53", + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "SS" + } + ] + }, + "system": "http://hl7.org/fhir/sid/us-ssn", + "value": "8370413840" + } + ], + "name": [ + { + "given": ["Tony"], + "family": "Soprano" + } + ], + "birthDate": "1970-01-01", + "address": [ + { + "use": "home", + "line": ["123 Main St."], + "city": "Albany", + "state": "NY", + "postalCode": "98732" + } + ] + }, + "request": { + "method": "POST", + "url": "Patient" + } + }, + { + "fullUrl": "urn:uuid:c9d3c09b-9474-11ee-8c90-0242ac120812", + "resource": { + "resourceType": "Practitioner", + "identifier": [ + { + "system": "http://hl7.org/fhir/sid/us-npi", + "value": "1234567890" + } + ], + "name": [ + { + "family": "Smith", + "given": ["Alice"], + "prefix": ["Dr."] + } + ], + "gender": "female", + "qualification": [ + { + "code": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0360", + "code": "MD" + } + ], + "text": "MD" + }, + "issuer": { + "display": "State of New York" + }, + "extension": [ + { + "url": "http://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition/practitioner-qualification", + "extension": [ + { + "url": "whereValid", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://www.usps.com/", + "code": "NY" + } + ] + } + } + ] + } + ] + } + ] + }, + "request": { + "method": "POST", + "url": "Practitioner" + } + }, + { + "fullUrl": "urn:uuid:c9d3c09b-1234-11ee-8c90-0242ac120002", + "resource": { + "resourceType": "Practitioner", + "active": true, + "name": [ + { + "prefix": ["Nurse"], + "given": ["Bob"], + "family": "Jones" + } + ], + "gender": "male", + "birthDate": "1973-11-02" + }, + "request": { + "method": "POST", + "url": "Practitioner" + } + }, + { + "fullUrl": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002", + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "subject": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "topic": { + "coding": [ + { + "code": "Lab test results", + "display": "Lab test results" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Communication" + } + }, + { + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "payload": [ + { + "contentString": "Do you have the results of my lab tests yet?" + } + ], + "topic": { + "text": "December 15th lab tests." + }, + "partOf": [ + { + "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" + } + ], + "sender": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "sent": "2023-12-18T14:26:06.531Z" + }, + "request": { + "method": "POST", + "url": "Communication" + } + }, + { + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "payload": [ + { + "contentString": "Do you have the test id number?" + } + ], + "topic": { + "text": "December 15th lab tests." + }, + "partOf": [ + { + "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" + } + ], + "sender": { + "reference": "Practitioner/b95651dc-448b-42c3-b427-f26d082a574d" + }, + "sent": "2023-12-18T14:28:06.531Z" + }, + "request": { + "method": "POST", + "url": "Communication" + } + }, + { + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "payload": [ + { + "contentString": "Yes, it is 12345" + } + ], + "topic": { + "text": "December 15th lab tests." + }, + "partOf": [ + { + "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" + } + ], + "sender": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "sent": "2023-12-18T14:46:06.531Z" + }, + "request": { + "method": "POST", + "url": "Communication" + } + }, + { + "fullUrl": "urn:uuid:b0fa2a73-1b87-4121-9a04-731bc0c177ea", + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "topic": { + "coding": [ + { + "code": "Schedule a Physical", + "display": "Schedule a Physical" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Communication" + } + }, + { + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "payload": [ + { + "contentString": "Can I schedule a physical for December 23rd?" + } + ], + "topic": { + "text": "Schedule a Physical" + }, + "partOf": [ + { + "reference": "urn:uuid:b0fa2a73-1b87-4121-9a04-731bc0c177ea" + } + ], + "sender": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002" + }, + "sent": "2023-12-18T14:01:15.175Z" + }, + "request": { + "method": "POST", + "url": "Communication" + } + }, + { + "fullUrl": "urn:uuid:ab308536-9e07-11ee-8c90-0242ac120002", + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "subject": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Mr. Lucien408 Bosco882 PharmD" + }, + "topic": { + "coding": [ + { + "code": "Prescription Refill", + "display": "Prescription Refill" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Communication" + } + }, + { + "resource": { + "resourceType": "Communication", + "status": "in-progress", + "payload": [ + { + "contentString": "My prescription ran out, can I come in to refill it?" + } + ], + "topic": { + "text": "December 15th lab tests." + }, + "partOf": [ + { + "reference": "urn:uuid:ab308536-9e07-11ee-8c90-0242ac120002" + } + ], + "sender": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "sent": "2023-12-18T14:26:06.531Z" + }, + "request": { + "method": "POST", + "url": "Communication" + } + }, + { + "resource": { + "resourceType": "Task", + "intent": "order", + "status": "ready", + "focus": { + "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" + }, + "for": { + "reference": "urn:uuid:3d9d2d9c-6648-4583-812b-d0d389facca2", + "display": "Walker Texas Ranger" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "224535009", + "display": "Registered Nurse" + } + ] + }, + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "restriction": { "period": { "end": "2024-04-01T16:00:00.000Z" } }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "respond-to-message", + "display": "Respond to Patient Message" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + { + "resource": { + "resourceType": "Task", + "intent": "order", + "status": "completed", + "focus": { + "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" + }, + "for": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "224535009", + "display": "Registered Nurse" + } + ] + }, + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "restriction": { "period": { "end": "2024-04-01T16:30:00.000Z" } }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "respond-to-message", + "display": "Respond to Patient Message" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + { + "resource": { + "resourceType": "Task", + "intent": "order", + "status": "completed", + "focus": { + "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" + }, + "for": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "224535009", + "display": "Registered Nurse" + } + ] + }, + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "respond-to-message", + "display": "Respond to Patient Message" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + { + "resource": { + "resourceType": "Task", + "intent": "order", + "status": "ready", + "focus": { + "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" + }, + "for": { + "reference": "urn:uuid:ba4a1ef7-c674-4acb-a506-6aac4a7aec53", + "display": "Tony Soprano" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "224535009", + "display": "Registered Nurse" + } + ] + }, + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "restriction": { "period": { "end": "2024-04-01T16:30:00.000Z" } }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "respond-to-message", + "display": "Respond to Patient Message" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + { + "resource": { + "resourceType": "Task", + "intent": "order", + "status": "completed", + "focus": { + "reference": "urn:uuid:d9d3cba2-9db5-11ee-8c90-0242ac120002" + }, + "for": { + "reference": "urn:uuid:ba4a1ef7-c674-4acb-a506-6aac4a7aec53", + "display": "Tony Soprano" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "224535009", + "display": "Registered Nurse" + } + ] + }, + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "respond-to-message", + "display": "Respond to Patient Message" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + + { + "fullUrl": "urn:uuid:ab308536-9e07-11ee-8c90-0242ac129872", + "resource": { + "resourceType": "Schedule", + "active": true, + "actor": [ + { + "reference": "urn:uuid:c9d3c09b-9474-11ee-8c90-0242ac120812" + } + ] + }, + "request": { + "method": "POST", + "url": "Schedule" + } + }, + { + "resource": { + "resourceType": "Task", + "status": "ready", + "focus": { + "reference": "urn:uuid:ab308536-9e07-11ee-8c90-0242ac129872" + }, + "intent": "order", + "for": { + "reference": "urn:uuid:ba4a1ef7-c674-4acb-a506-6aac4a7aec53", + "display": "Tony Soprano" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "schedule-appointment", + "display": "Schedule Appointment" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + { + "fullUrl": "urn:uuid:3385ee9e-f180-4e8b-8b12-cf4833144e7b", + "resource": { + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "35200-5", + "display": "Cholesterol [Mass or Moles/volume] in Serum or Plasma" + } + ], + "text": "Cholesterol" + }, + "subject": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "valueQuantity": { + "value": 6.3, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "high": { + "value": 4.5, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "POST", + "url": "Observation" + } + }, + { + "fullUrl": "urn:uuid:94e4378e-9336-4538-98f5-47dcf1ded634", + "resource": { + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "35217-9", + "display": "Triglyceride [Mass or Moles/volume] in Serum or Plasma" + } + ], + "text": "Triglyceride" + }, + "subject": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "valueQuantity": { + "value": 1.3, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "high": { + "value": 2, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "POST", + "url": "Observation" + } + }, + { + "fullUrl": "urn:uuid:3b7c71bb-1357-4cac-88d7-3e374676e988", + "resource": { + "resourceType": "Observation", + "id": "hdlcholesterol", + "text": { + "status": "generated", + "div": "

Generated Narrative: Observation

Resource Observation "hdlcholesterol"

status: final

code: Cholesterol in HDL (LOINC#2085-9 "Cholesterol in HDL [Mass/volume] in Serum or Plasma")

subject: Patient/pat2 "Duck DONALD"

performer: Organization/1832473e-2fe0-452d-abe9-3cdb9879522f: Acme Laboratory, Inc "Clinical Lab"

value: 1.3 mmol/L (Details: UCUM code mmol/L = 'mmol/L')

ReferenceRanges

-Low
*1.5 mmol/L (Details: UCUM code mmol/L = 'mmol/L')
" + }, + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "2085-9", + "display": "Cholesterol in HDL [Mass/volume] in Serum or Plasma" + } + ], + "text": "Cholesterol in HDL" + }, + "subject": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "valueQuantity": { + "value": 1.3, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "low": { + "value": 1.5, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "POST", + "url": "Observation" + } + }, + { + "fullUrl": "urn:uuid:f2aedf86-166c-4f14-995e-9320ebab06e7", + "resource": { + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "13457-7", + "display": "Cholesterol in LDL [Mass/volume] in Serum or Plasma by calculation" + } + ], + "text": "LDL Chol. (Calc)" + }, + "subject": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "valueQuantity": { + "value": 4.6, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + }, + "referenceRange": [ + { + "high": { + "value": 3, + "unit": "mmol/L", + "system": "http://unitsofmeasure.org", + "code": "mmol/L" + } + } + ] + }, + "request": { + "method": "POST", + "url": "Observation" + } + }, + { + "fullUrl": "urn:uuid:e2b02def-3968-476c-b7be-d404b4a459ed", + "resource": { + "resourceType": "DiagnosticReport", + "identifier": [ + { + "system": "http://acme.com/lab/reports", + "value": "5234342" + } + ], + "status": "final", + "category": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0074", + "code": "HM" + } + ] + } + ], + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "57698-3", + "display": "Lipid panel with direct LDL - Serum or Plasma" + } + ], + "text": "Lipid Panel" + }, + "subject": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "effectiveDateTime": "2011-03-04T08:30:00+11:00", + "issued": "2013-01-27T11:45:33+11:00", + "performer": [ + { + "display": "Acme Laboratory, Inc" + } + ], + "result": [ + { + "reference": "urn:uuid:3385ee9e-f180-4e8b-8b12-cf4833144e7b" + }, + { + "reference": "urn:uuid:94e4378e-9336-4538-98f5-47dcf1ded634" + }, + { + "reference": "urn:uuid:3b7c71bb-1357-4cac-88d7-3e374676e988" + }, + { + "reference": "urn:uuid:f2aedf86-166c-4f14-995e-9320ebab06e7" + } + ] + }, + "request": { + "method": "POST", + "url": "DiagnosticReport" + } + }, + { + "resource": { + "resourceType": "Task", + "status": "ready", + "focus": { + "reference": "urn:uuid:e2b02def-3968-476c-b7be-d404b4a459ed" + }, + "intent": "order", + "for": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "158965000", + "display": "Doctor" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "review-diagnostic-report", + "display": "Review Diagnostic Report" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + { + "fullUrl": "urn:uuid:569781a7-6ee9-42f1-88ac-ac4cacf721b8", + "resource": { + "resourceType": "DocumentReference", + "status": "current", + "docStatus": "final", + "type": { + "text": "Driver's License" + }, + "category": [ + { + "text": "Identity Document" + } + ], + "content": [ + { + "attachment": { + "contentType": "image/jpeg", + "url": "https://upload.wikimedia.org/wikipedia/commons/7/79/Californian_sample_driver%27s_license%2C_c._2019.jpg" + } + } + ] + }, + "request": { + "method": "POST", + "url": "DocumentReference" + } + }, + { + "fullUrl": "urn:uuid:569781a7-6ee9-42f1-88ac-ac4cacf721b8", + "resource": { + "resourceType": "DocumentReference", + "status": "current", + "docStatus": "final", + "type": { + "text": "Driver's License" + }, + "category": [ + { + "text": "Identity Document" + } + ], + "content": [ + { + "attachment": { + "contentType": "image/jpeg", + "url": "https://upload.wikimedia.org/wikipedia/commons/7/79/Californian_sample_driver%27s_license%2C_c._2019.jpg" + } + } + ] + }, + "request": { + "method": "POST", + "url": "DocumentReference" + } + }, + { + "fullUrl": "urn:uuid:569781a7-6ee9-42f1-88ac-ac4cacf721b8", + "resource": { + "resourceType": "DocumentReference", + "status": "current", + "docStatus": "final", + "type": { + "text": "Driver's License" + }, + "category": [ + { + "text": "Identity Document" + } + ], + "content": [ + { + "attachment": { + "contentType": "image/jpeg", + "url": "https://upload.wikimedia.org/wikipedia/commons/7/79/Californian_sample_driver%27s_license%2C_c._2019.jpg" + } + } + ] + }, + "request": { + "method": "POST", + "url": "DocumentReference" + } + }, + { + "resource": { + "resourceType": "Task", + "status": "ready", + "focus": { + "reference": "urn:uuid:569781a7-6ee9-42f1-88ac-ac4cacf721b8" + }, + "intent": "order", + "for": { + "reference": "urn:uuid:c9d3cba2-9db5-11ee-8c90-0242ac120002", + "display": "Jane California" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "verify-drivers-license", + "display": "Verify Drivers License" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + { + "resource": { + "resourceType": "Task", + "status": "ready", + "focus": { + "reference": "urn:uuid:569781a7-6ee9-42f1-88ac-ac4cacf721b8" + }, + "intent": "order", + "for": { + "reference": "urn:uuid:3d9d2d9c-6648-4583-812b-d0d389facca2", + "display": "Walker Texas Ranger" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "verify-drivers-license", + "display": "Verify Drivers License" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + }, + { + "resource": { + "resourceType": "Task", + "status": "ready", + "priority": "asap", + "focus": { + "reference": "urn:uuid:569781a7-6ee9-42f1-88ac-ac4cacf721b8" + }, + "intent": "order", + "for": { + "reference": "urn:uuid:ba4a1ef7-c674-4acb-a506-6aac4a7aec53", + "display": "Tony Soprano" + }, + "performerType": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "768820003", + "display": "Care Coordinator" + } + ] + } + ], + "businessStatus": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-example-app/task-status", + "code": "ready", + "display": "Ready" + } + ] + }, + "restriction": { "period": { "end": "2024-04-01T17:30:00.000Z" } }, + "code": { + "coding": [ + { + "system": "https://medplum.com/medplum-task-demo/task-type", + "code": "verify-drivers-license", + "display": "Verify Drivers License" + } + ] + } + }, + "request": { + "method": "POST", + "url": "Task" + } + } + ] +} diff --git a/examples/medplum-task-demo/package.json b/examples/medplum-task-demo/package.json index 79b6784e89..d0160ef44c 100644 --- a/examples/medplum-task-demo/package.json +++ b/examples/medplum-task-demo/package.json @@ -1,13 +1,13 @@ { "name": "medplum-task-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { - "build": "npm run clean && tsc && vite build", - "build:bots": "npm run clean && npm run lint && tsc", + "build": "npm run clean && npm run build:bots && tsc && vite build", + "build:bots": "npm run clean && npm run lint && tsc --project tsconfig-bots.json && node --loader ts-node/esm src/scripts/deploy-bots.ts", "clean": "rimraf dist", - "dev": "vite", + "dev": "npm run build:bots && vite", "lint": "eslint src/", "preview": "vite preview" }, @@ -22,25 +22,25 @@ ] }, "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-task-demo/src/App.tsx b/examples/medplum-task-demo/src/App.tsx index 993d8ec666..c79aa19659 100644 --- a/examples/medplum-task-demo/src/App.tsx +++ b/examples/medplum-task-demo/src/App.tsx @@ -1,15 +1,31 @@ +import { LoadingOverlay } from '@mantine/core'; import { + MedplumClient, Operator, SearchRequest, + capitalize, formatCodeableConcept, formatSearchQuery, + getExtension, getReferenceString, normalizeErrorString, } from '@medplum/core'; +import { Practitioner } from '@medplum/fhirtypes'; import { AppShell, Loading, Logo, NavbarLink, useMedplum, useMedplumProfile } from '@medplum/react'; -import { IconCategory, IconDatabaseImport, IconFileImport, IconGridDots, IconUser } from '@tabler/icons-react'; +import { + IconCategory, + IconChecklist, + IconDatabaseImport, + IconGridDots, + IconMail, + IconNurse, + IconReportMedical, + IconRibbonHealth, + IconRobot, + IconUser, +} from '@tabler/icons-react'; import { Suspense, useEffect, useState } from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import { LandingPage } from './pages/LandingPage'; import { ResourcePage } from './pages/ResourcePage'; import { SearchPage } from './pages/SearchPage'; @@ -17,98 +33,177 @@ import { SignInPage } from './pages/SignInPage'; import { TaskPage } from './pages/TaskPage'; import { UploadDataPage } from './pages/UploadDataPage'; +const SEARCH_TABLE_FIELDS = ['code', 'owner', 'for', 'priority', 'due-date', '_lastUpdated', 'performerType']; +const ALL_TASKS_LINK = { + icon: , + label: 'All Tasks', + href: `/Task?_fields=${SEARCH_TABLE_FIELDS.join(',')}`, +}; + export function App(): JSX.Element | null { const medplum = useMedplum(); const profile = useMedplumProfile(); + const [userLinks, setUserLinks] = useState([]); + const showLoadingOverlay = profile && (medplum.isLoading() || userLinks.length === 0); - const profileReference = profile && getReferenceString(profile); - const [userLinks, setUserLinks] = useState([ - { icon: , label: 'All Tasks', href: '/Task' }, - ]); - + // Update the sidebar links associated with the Medplum profiles useEffect(() => { - if (!profileReference) { + const profileReferenceString = profile && getReferenceString(profile); + + if (!profileReferenceString) { return; } - const myTasksQuery = formatSearchQuery({ - resourceType: 'Task', - fields: ['code', '_lastUpdated', 'owner', 'for', 'priority'], - sortRules: [{ code: '-priority-order,due-date' }], - filters: [ - { code: 'owner', operator: Operator.EQUALS, value: profileReference }, - { code: 'status:not', operator: Operator.EQUALS, value: 'completed' }, - ], - }); - - const myTasksLink = { icon: , label: 'My Tasks', href: `/Task${myTasksQuery}` }; - - medplum - .searchResources('PractitionerRole', { - practitioner: profileReference, - }) - .then((roles) => { - const roleLinks = []; - - for (const role of roles) { - const roleCode = role?.code?.[0]; - if (!roleCode?.coding?.[0]?.code) { - continue; - } - - const search: SearchRequest = { - resourceType: 'Task', - fields: ['code', '_lastUpdated', 'owner', 'for', 'priority'], - sortRules: [{ code: '-priority-order,due-date' }], - filters: [ - { code: 'owner:missing', operator: Operator.EQUALS, value: 'true' }, - { code: 'performer', operator: Operator.EQUALS, value: roleCode?.coding?.[0]?.code }, - ], - }; - const searchQuery = formatSearchQuery(search); - const roleDisplay = formatCodeableConcept(roleCode); - roleLinks.push({ icon: , label: `${roleDisplay} Tasks`, href: `/Task${searchQuery}` }); - } + // Construct the search for "My Tasks" + const myTasksLink = getMyTasksLink(profileReferenceString); - setUserLinks([myTasksLink, ...roleLinks, { icon: , label: 'All Tasks', href: '/Task' }]); + // Query the user's `PractitionerRole` resources to find all applicable roles + getTasksByRoleLinks(medplum, profileReferenceString) + .then((roleLinks) => { + setUserLinks([myTasksLink, ...roleLinks, ...stateLinks, ALL_TASKS_LINK]); }) .catch((error) => console.error('Failed to fetch PractitionerRoles', normalizeErrorString(error))); - }, [profileReference, medplum]); - if (medplum.isLoading()) { - return null; - } + // Construct Search links for all Tasks for patients in the current user's licensed states + const stateLinks = getTasksByState(profile as Practitioner); + }, [profile, medplum]); return ( - } - menus={[ - { - title: 'Tasks', - links: userLinks, - }, - { - title: 'Upload Data', - links: [ - { icon: , label: 'Upload Core Data', href: '/upload/core' }, - { icon: , label: 'Upload Example Data', href: '/upload/example' }, - ], - }, - ]} - resourceTypeSearchDisabled={true} - headerSearchDisabled={true} - > - }> - - : } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + <> + } + menus={[ + { + title: 'Tasks', + links: userLinks, + }, + { + title: 'Upload Data', + links: [ + { icon: , label: 'Upload Core ValueSets', href: '/upload/core' }, + { icon: , label: 'Upload Example Tasks', href: '/upload/task' }, + { icon: , label: 'Upload Example Certifications', href: '/upload/role' }, + { + icon: , + label: 'Upload Example Licenses', + href: '/upload/qualifications', + }, + { icon: , label: 'Upload Example Bots', href: '/upload/bots' }, + { icon: , label: 'Upload Example Report', href: '/upload/report' }, + { icon: , label: 'Upload Example Messages', href: '/upload/message' }, + ], + }, + ]} + headerSearchDisabled={true} + > + + }> + + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); } + +/** + * @param profileReference - string representing the current user's profile + * @returns a NavBar link to a search for all open `Tasks` assigned to the current user + */ +function getMyTasksLink(profileReference: string): NavbarLink { + const myTasksQuery = formatSearchQuery({ + resourceType: 'Task', + fields: SEARCH_TABLE_FIELDS, + sortRules: [{ code: '-priority-order,due-date' }], + filters: [ + { code: 'owner', operator: Operator.EQUALS, value: profileReference }, + { code: 'status:not', operator: Operator.EQUALS, value: 'completed' }, + ], + }); + + const myTasksLink = { icon: , label: 'My Tasks', href: `/Task${myTasksQuery}` }; + return myTasksLink; +} + +/** + * @param medplum - the MedplumClient + * @param profileReference - string representing the current user's profile + * @returns an array of NavBarLinks to searches for all open `Tasks` assigned to the current user's roles + */ +async function getTasksByRoleLinks(medplum: MedplumClient, profileReference: string): Promise { + const roles = await medplum.searchResources('PractitionerRole', { + practitioner: profileReference, + }); + + // Query the user's `PractitionerRole` resources to find all applicable roles + return roles + .map((role) => { + // For each role, generate a link to all open Tasks + const roleCode = role?.code?.[0]; + + if (!roleCode?.coding?.[0]?.code) { + return undefined; + } + + const search: SearchRequest = { + resourceType: 'Task', + fields: SEARCH_TABLE_FIELDS, + sortRules: [{ code: '-priority-order,due-date' }], + filters: [ + { code: 'owner:missing', operator: Operator.EQUALS, value: 'true' }, + { code: 'status:not', operator: Operator.EQUALS, value: 'completed' }, + { code: 'performer', operator: Operator.EQUALS, value: roleCode?.coding?.[0]?.code }, + ], + }; + + const searchQuery = formatSearchQuery(search); + const roleDisplay = formatCodeableConcept(roleCode); + return { icon: , label: `${roleDisplay} Tasks`, href: `/Task${searchQuery}` } as NavbarLink; + }) + .filter((link): link is NavbarLink => !!link); +} + +/** + * + * Read all the states for which this practitioner is licensed. + * Refer to [Modeling Provider Qualifications](https://www.medplum.com/docs/administration/provider-directory/provider-credentials) + * for more information on how to represent a clinician's licenses + * @param profile - The resource representing the current user + * @returns an array of NavBarLinks to searches for all open `Tasks` assigned to patients' in states + * where the current user is licensed + */ +function getTasksByState(profile: Practitioner): NavbarLink[] { + const myStates = + profile.qualification + ?.map( + (qualification) => + getExtension( + qualification, + 'http://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition/practitioner-qualification', + 'whereValid' + )?.valueCodeableConcept?.coding?.find((coding) => coding.system === 'https://www.usps.com/')?.code + ) + .filter((state): state is string => !!state) ?? []; + + return myStates.map((state) => { + const search: SearchRequest = { + resourceType: 'Task', + fields: SEARCH_TABLE_FIELDS, + sortRules: [{ code: '-priority-order,due-date' }], + filters: [ + { code: 'owner:missing', operator: Operator.EQUALS, value: 'true' }, + { code: 'status:not', operator: Operator.EQUALS, value: 'completed' }, + { code: 'patient.address-state', operator: Operator.EQUALS, value: state }, + ], + }; + const searchQuery = formatSearchQuery(search); + return { icon: , label: `${capitalize(state)} Tasks`, href: `/Task${searchQuery}` } as NavbarLink; + }); +} diff --git a/examples/medplum-task-demo/src/bots/example/create-review-report-task.ts b/examples/medplum-task-demo/src/bots/example/create-review-report-task.ts index 7ae9a6a2e2..1ae763c33c 100644 --- a/examples/medplum-task-demo/src/bots/example/create-review-report-task.ts +++ b/examples/medplum-task-demo/src/bots/example/create-review-report-task.ts @@ -15,10 +15,12 @@ export async function handler(medplum: MedplumClient, event: BotEvent (window.location.href = '/'), + cacheTime: 3000, // baseUrl: 'http://localhost:8103/', //Uncomment this to run against the server on your localhost; also change `googleClientId` in `./pages/SignInPage.tsx` }); @@ -40,7 +41,7 @@ root.render( - + diff --git a/examples/medplum-task-demo/src/pages/ResourcePage.tsx b/examples/medplum-task-demo/src/pages/ResourcePage.tsx index 5d27dc2eea..857f4a0798 100644 --- a/examples/medplum-task-demo/src/pages/ResourcePage.tsx +++ b/examples/medplum-task-demo/src/pages/ResourcePage.tsx @@ -1,8 +1,16 @@ import { Paper, Tabs, Title } from '@mantine/core'; import { getDisplayString, getReferenceString } from '@medplum/core'; -import { DefaultResourceTimeline, Document, ResourceTable, useMedplumNavigate, useResource } from '@medplum/react'; +import { + DefaultResourceTimeline, + DiagnosticReportDisplay, + Document, + ResourceTable, + useMedplumNavigate, + useResource, +} from '@medplum/react'; import { useParams } from 'react-router-dom'; import { ResourceHistoryTab } from '../components/ResourceHistoryTab'; +import { DiagnosticReport } from '@medplum/fhirtypes'; /** * This is an example of a generic "Resource Display" page. @@ -14,7 +22,11 @@ export function ResourcePage(): JSX.Element | null { const navigate = useMedplumNavigate(); const reference = { reference: resourceType + '/' + id }; const resource = useResource(reference); - const tabs = ['Details', 'Timeline', 'History']; + let tabs = ['Details', 'Timeline', 'History']; + // Special Case for Diagnostic Reporets + if (resourceType === 'DiagnosticReport') { + tabs = ['Report', ...tabs]; + } const tab = window.location.pathname.split('/').pop(); const currentTab = tab && tabs.map((t) => t.toLowerCase()).includes(tab) ? tab : tabs[0].toLowerCase(); @@ -41,7 +53,7 @@ export function ResourcePage(): JSX.Element | null { - + @@ -50,6 +62,11 @@ export function ResourcePage(): JSX.Element | null { + + + + +
); diff --git a/examples/medplum-task-demo/src/pages/SearchPage.tsx b/examples/medplum-task-demo/src/pages/SearchPage.tsx index a1ba054821..a61c0967fd 100644 --- a/examples/medplum-task-demo/src/pages/SearchPage.tsx +++ b/examples/medplum-task-demo/src/pages/SearchPage.tsx @@ -1,10 +1,10 @@ import { Tabs } from '@mantine/core'; -import { formatSearchQuery, getReferenceString, Operator, parseSearchRequest, SearchRequest } from '@medplum/core'; +import { Operator, SearchRequest, formatSearchQuery, getReferenceString, parseSearchRequest } from '@medplum/core'; import { Document, Loading, SearchControl, useMedplum } from '@medplum/react'; import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { CreateTaskModal } from '../components/actions/CreateTaskModal'; -import { getPopulatedSearch } from './utils'; +import { getPopulatedSearch } from '../utils/search-control'; export function SearchPage(): JSX.Element { const medplum = useMedplum(); @@ -13,22 +13,10 @@ export function SearchPage(): JSX.Element { const [search, setSearch] = useState(); const [isNewOpen, setIsNewOpen] = useState(false); - const [showTabs, setShowTabs] = useState(() => { - const search = parseSearchRequest(window.location.pathname + window.location.search); - return shouldShowTabs(search); - }); - const tabs = ['Active', 'Completed']; - const searchQuery = window.location.search; - const currentSearch = parseSearchRequest(searchQuery); - + const currentSearch = parseSearchRequest(window.location.toString()); const currentTab = handleInitialTab(currentSearch); - useEffect(() => { - const searchQuery = parseSearchRequest(location.pathname + location.search); - setShowTabs(shouldShowTabs(searchQuery)); - }, [location]); - useEffect(() => { // Parse the search definition from the url and get the correct fields for the resource type const parsedSearch = parseSearchRequest(location.pathname + location.search); @@ -67,7 +55,7 @@ export function SearchPage(): JSX.Element { return ( - {showTabs ? ( + {shouldShowTabs(search) ? ( {tabs.map((tab) => ( @@ -105,7 +93,7 @@ export function SearchPage(): JSX.Element { navigate(`/${getReferenceString(e.resource)}`)} - hideToolbar={true} + hideToolbar={false} hideFilters={true} onChange={(e) => { navigate(`/${search.resourceType}${formatSearchQuery(e.definition)}`); @@ -144,10 +132,12 @@ function shouldShowTabs(search: SearchRequest): boolean { return true; } - for (const filter of search.filters) { - if (filter.code === 'performer') { - return false; - } + if (search.filters.some((filter) => filter.code === 'performer')) { + return false; + } + + if (search.filters.some((filter) => filter.code === 'patient.address-state')) { + return false; } return true; diff --git a/examples/medplum-task-demo/src/pages/UploadDataPage.tsx b/examples/medplum-task-demo/src/pages/UploadDataPage.tsx index 57b050f8f1..3cb5a9c2d0 100644 --- a/examples/medplum-task-demo/src/pages/UploadDataPage.tsx +++ b/examples/medplum-task-demo/src/pages/UploadDataPage.tsx @@ -1,36 +1,72 @@ -import { Button } from '@mantine/core'; -import { MedplumClient, capitalize, normalizeErrorString } from '@medplum/core'; -import { Document, useMedplum } from '@medplum/react'; +import { Button, LoadingOverlay } from '@mantine/core'; +import { + MedplumClient, + capitalize, + createReference, + getReferenceString, + isOk, + normalizeErrorString, +} from '@medplum/core'; +import { Document, useMedplum, useMedplumProfile } from '@medplum/react'; import { useNavigate, useParams } from 'react-router-dom'; import { showNotification } from '@mantine/notifications'; -import { Bundle } from '@medplum/fhirtypes'; +import { Bot, Bundle, BundleEntry, Coding, Practitioner, ValueSet } from '@medplum/fhirtypes'; import { IconCircleCheck, IconCircleOff } from '@tabler/icons-react'; import { useCallback, useState } from 'react'; -import businessStatusValueSet from '../../data/core/business-status-value-sets.json'; -import exampleMessageData from '../../data/example/respond-to-message-data.json'; +import businessStatusValueSet from '../../data/core/business-status-valueset.json'; +import practitionerRoleValueSet from '../../data/core/practitioner-role-valueset.json'; +import taskTypeValueSet from '../../data/core/task-type-valueset.json'; +import exampleBotData from '../../data/example/example-bots.json'; +import exampleMessageData from '../../data/example/example-messages.json'; +import exampleRoleData from '../../data/example/example-practitioner-role.json'; +import exampleReportData from '../../data/example/example-reports.json'; +import exampleTaskData from '../../data/example/example-tasks.json'; + +type UploadFunction = + | ((medplum: MedplumClient, profile: Practitioner) => Promise) + | ((medplum: MedplumClient) => Promise); export function UploadDataPage(): JSX.Element { const medplum = useMedplum(); + const profile = useMedplumProfile(); const { dataType } = useParams(); const navigate = useNavigate(); - const [buttonDisabled, setButtonDisabled] = useState(false); + const [pageDisabled, setPageDisabled] = useState(false); const dataTypeDisplay = dataType ? capitalize(dataType) : ''; const handleUpload = useCallback(() => { - setButtonDisabled(true); - let uploadFunction: (medplum: MedplumClient) => Promise; - if (dataType === 'core') { - uploadFunction = uploadCoreData; - } else if (dataType === 'example') { - uploadFunction = uploadExampleData; - } else { - throw new Error(`Invalid upload type '${dataType}'`); + setPageDisabled(true); + let uploadFunction: UploadFunction; + switch (dataType) { + case 'core': + uploadFunction = uploadCoreData; + break; + case 'task': + uploadFunction = uploadExampleTaskData; + break; + case 'role': + uploadFunction = uploadExampleRoleData; + break; + case 'message': + uploadFunction = uploadExampleMessageData; + break; + case 'report': + uploadFunction = uploadExampleReportData; + break; + case 'qualifications': + uploadFunction = uploadExampleQualifications; + break; + case 'bots': + uploadFunction = uploadExampleBots; + break; + default: + throw new Error(`Invalid upload type '${dataType}'`); } - uploadFunction(medplum) - .then(() => navigate('/')) + uploadFunction(medplum, profile as Practitioner) + .then(() => navigate(-1)) .catch((error) => { showNotification({ color: 'red', @@ -39,30 +75,221 @@ export function UploadDataPage(): JSX.Element { message: normalizeErrorString(error), }); }) - .finally(() => setButtonDisabled(false)); - }, [medplum, dataType, navigate]); + .finally(() => setPageDisabled(false)); + }, [medplum, profile, dataType, navigate]); return ( - + + ); } async function uploadCoreData(medplum: MedplumClient): Promise { - await medplum.executeBatch(businessStatusValueSet as Bundle); + // Upload all the core ValueSets in a single batch request + const valueSets: ValueSet[] = [ + businessStatusValueSet as ValueSet, + taskTypeValueSet as ValueSet, + practitionerRoleValueSet as ValueSet, + ]; + + // Upsert the ValueSet (see: https://www.medplum.com/docs/fhir-datastore/fhir-batch-requests#performing-upserts) + const batch: Bundle = { + resourceType: 'Bundle', + type: 'transaction', + entry: valueSets.flatMap((valueSet) => { + const tempId = valueSet.id; + return [ + { + fullUrl: tempId, + request: { method: 'POST', url: valueSet.resourceType, ifNoneExist: `url=${valueSet.url}` }, + resource: valueSet, + }, + { + request: { method: 'PUT', url: tempId }, + resource: { id: tempId, ...valueSet }, + }, + ] as BundleEntry[]; + }), + }; + console.log(batch); + const result = await medplum.executeBatch(batch); + console.log(result); + showNotification({ icon: , title: 'Success', message: 'Uploaded Business Statuses', }); + + if (result.entry?.every((entry) => entry.response?.outcome && isOk(entry.response?.outcome))) { + await setTimeout( + () => + showNotification({ + icon: , + title: 'Success', + message: 'Uploaded Business Statuses', + }), + 1000 + ); + } else { + throw new Error('Error uploading core data'); + } } -async function uploadExampleData(medplum: MedplumClient): Promise { +async function uploadExampleMessageData(medplum: MedplumClient): Promise { await medplum.executeBatch(exampleMessageData as Bundle); showNotification({ icon: , title: 'Success', - message: 'Uploaded Example Message Data', + message: 'Uploaded Example Messages', + }); +} + +async function uploadExampleReportData(medplum: MedplumClient): Promise { + await medplum.executeBatch(exampleReportData as Bundle); + showNotification({ + icon: , + title: 'Success', + message: 'Uploaded Example Report', + }); +} + +async function uploadExampleTaskData(medplum: MedplumClient): Promise { + await medplum.executeBatch(exampleTaskData as Bundle); + showNotification({ + icon: , + title: 'Success', + message: 'Uploaded Example Tasks', + }); +} + +async function uploadExampleQualifications(medplum: MedplumClient, profile: Practitioner): Promise { + if (!profile) { + return; + } + + const states: Coding[] = [ + { code: 'NY', display: 'State of New York', system: 'https://www.usps.com/' }, + { code: 'CA', display: 'State of California', system: 'https://www.usps.com/' }, + { code: 'TX', display: 'State of Texas', system: 'https://www.usps.com/' }, + ]; + + await medplum.patchResource(profile.resourceType, profile.id as string, [ + { + path: '/qualification', + // JSON patch does not have an upsert operation. If the user already has qualifications, we should just replace them with these licences + op: profile.qualification ? 'replace' : 'add', + value: states.map((state) => ({ + code: { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/v2-0360', + code: 'MD', + }, + ], + text: 'MD', + }, + // Medical License Issuer: State of New York + issuer: { + display: state.display, + }, + // Extension: Medical License Valid in NY + extension: [ + { + url: 'http://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition/practitioner-qualification', + extension: [ + { + url: 'whereValid', + valueCodeableConcept: { + coding: [state], + }, + }, + ], + }, + ], + })), + }, + ]); + showNotification({ + icon: , + title: 'Success', + message: 'Uploaded Example Qualifications', + }); +} + +async function uploadExampleRoleData(medplum: MedplumClient, profile: Practitioner): Promise { + // Update the suffix of the current user to highlight the change + if (!profile?.name?.[0]?.suffix) { + await medplum.patchResource(profile.resourceType, profile.id as string, [ + { + op: 'add', + path: '/name/0/suffix', + value: ['MD'], + }, + ]); + } + + const bundleString = JSON.stringify(exampleRoleData, null, 2) + .replaceAll('$practitionerReference', getReferenceString(profile)) + .replaceAll('"$practitioner"', JSON.stringify(createReference(profile))); + + const transaction = JSON.parse(bundleString) as Bundle; + + // Create the practitioner role + await medplum.executeBatch(transaction); + + showNotification({ + icon: , + title: 'Success', + message: 'Uploaded Example Qualifications', + }); +} + +async function uploadExampleBots(medplum: MedplumClient, profile: Practitioner): Promise { + let transactionString = JSON.stringify(exampleBotData); + const botEntries: BundleEntry[] = + (exampleBotData as Bundle).entry?.filter((e) => e.resource?.resourceType === 'Bot') || []; + const botNames = botEntries.map((e) => (e.resource as Bot).name ?? ''); + const botIds: Record = {}; + + for (const botName of botNames) { + let existingBot = await medplum.searchOne('Bot', { name: botName }); + // Create a new Bot if it doesn't already exist + if (!existingBot) { + const projectId = profile.meta?.project; + const createBotUrl = new URL('admin/projects/' + (projectId as string) + '/bot', medplum.getBaseUrl()); + existingBot = (await medplum.post(createBotUrl, { + name: botName, + })) as Bot; + } + + botIds[botName] = existingBot.id as string; + + // Replace the Bot id placeholder in the bundle + transactionString = transactionString + .replaceAll(`$bot-${botName}-reference`, getReferenceString(existingBot)) + .replaceAll(`$bot-${botName}-id`, existingBot.id as string); + } + + // Execute the transaction to upload / update the bot + const transaction = JSON.parse(transactionString); + await medplum.executeBatch(transaction); + + // Deploy the new bots + for (const entry of botEntries) { + const botName = (entry?.resource as Bot)?.name as string; + const distUrl = (entry.resource as Bot).executableCode?.url; + const distBinaryEntry = exampleBotData.entry.find((e) => e.fullUrl === distUrl); + // Decode the base64 encoded code and deploy + const code = atob(distBinaryEntry?.resource.data as string); + await medplum.post(medplum.fhirUrl('Bot', botIds[botName], '$deploy'), { code }); + } + + showNotification({ + icon: , + title: 'Success', + message: 'Deployed Example Bots', }); } diff --git a/examples/medplum-task-demo/src/scripts/deploy-bots.ts b/examples/medplum-task-demo/src/scripts/deploy-bots.ts new file mode 100644 index 0000000000..25cbe1d90e --- /dev/null +++ b/examples/medplum-task-demo/src/scripts/deploy-bots.ts @@ -0,0 +1,131 @@ +import { ContentType } from '@medplum/core'; +import { Bot, Bundle, BundleEntry, Subscription } from '@medplum/fhirtypes'; +import fs from 'fs'; +import path from 'path'; + +interface BotDescription { + src: string; + dist: string; + criteria?: string; +} +const Bots: BotDescription[] = [ + { + src: 'src/bots/example/create-review-report-task.ts', + dist: 'dist/example/create-review-report-task.js', + criteria: 'DiagnosticReport', + }, + { + src: 'src/bots/example/create-respond-to-message-task.ts', + dist: 'dist/example/create-respond-to-message-task.js', + criteria: 'Communication?part-of:missing=true', + }, +]; + +async function main(): Promise { + const bundle: Bundle = { + resourceType: 'Bundle', + type: 'transaction', + entry: Bots.flatMap((botDescription): BundleEntry[] => { + const botName = path.parse(botDescription.src).name; + const botUrlPlaceholder = `$bot-${botName}-reference`; + const botIdPlaceholder = `$bot-${botName}-id`; + const results: BundleEntry[] = []; + const { srcEntry, distEntry } = readBotFiles(botDescription); + results.push(srcEntry, distEntry); + + results.push({ + request: { + url: botUrlPlaceholder, + method: 'PUT', + }, + resource: { + resourceType: 'Bot', + id: botIdPlaceholder, + name: botName, + runtimeVersion: 'awslambda', + sourceCode: { + contentType: ContentType.TYPESCRIPT, + url: srcEntry.fullUrl, + }, + executableCode: { + contentType: ContentType.JAVASCRIPT, + url: distEntry.fullUrl, + }, + } as Bot, + }); + + if (botDescription.criteria) { + results.push({ + request: { + url: 'Subscription', + method: 'POST', + ifNoneExist: `url=${botUrlPlaceholder}`, + }, + resource: { + resourceType: 'Subscription', + status: 'active', + reason: botName, + channel: { endpoint: botUrlPlaceholder, type: 'rest-hook' }, + criteria: botDescription.criteria, + } as Subscription, + }); + } + + return results; + }), + }; + + fs.writeFileSync('data/example/example-bots.json', JSON.stringify(bundle, null, 2)); +} + +function readBotFiles(description: BotDescription): Record { + const sourceFile = fs.readFileSync(description.src); + const distFile = fs.readFileSync(description.dist); + + const srcEntry: BundleEntry = { + fullUrl: 'urn:uuid:' + UUIDs.pop(), + request: { + method: 'POST', + url: 'Binary', + }, + resource: { + resourceType: 'Binary', + contentType: ContentType.TYPESCRIPT, + data: sourceFile.toString('base64'), + }, + }; + const distEntry: BundleEntry = { + fullUrl: 'urn:uuid:' + UUIDs.pop(), + request: { + method: 'POST', + url: 'Binary', + }, + resource: { + resourceType: 'Binary', + contentType: ContentType.JAVASCRIPT, + data: distFile.toString('base64'), + }, + }; + return { srcEntry, distEntry }; +} + +const UUIDs = [ + '1e816573-1e13-46d4-ae02-857ac10169e6', + 'b56f4407-800c-411f-bb7b-07f8c73730bf', + '09ba8367-1cf0-48b4-8965-59e494102af6', + '5ba14170-42b1-436d-9d46-9a566d534c8f', + '61750884-cf29-4690-84c6-1bcf5ad14b7e', + '73693d07-2ba1-4ddd-a6ee-9ea0b2d5aa9c', + '0ab3ff6c-7c38-4911-a49e-6e8e8fe379e6', + '4b1851e6-3ced-4f83-ad52-edb85408a1a6', + '2bf1d4a3-143d-4cbb-bf50-033805791b6d', + 'f3f2aeb8-43ac-49f9-a921-f7fba79348f7', + 'b5ffcef0-2f02-4c96-800b-b86eadc5423e', + '58019283-e86b-48b2-8aec-5bf0a9fe58f2', + 'a97b0a11-3e9f-42cd-af63-c33a736145b8', + '067a72c8-f24a-44c1-8145-cc6aa3049037', + 'e038a143-8c66-4b27-b69c-5430aeff6053', + '146feddc-7915-4ab3-800d-c98e312116cd', +]; + +main().catch(console.error); diff --git a/examples/medplum-task-demo/src/pages/utils.tsx b/examples/medplum-task-demo/src/utils/search-control.ts similarity index 100% rename from examples/medplum-task-demo/src/pages/utils.tsx rename to examples/medplum-task-demo/src/utils/search-control.ts diff --git a/examples/medplum-task-demo/tsconfig-bots.json b/examples/medplum-task-demo/tsconfig-bots.json new file mode 100644 index 0000000000..4eb853a197 --- /dev/null +++ b/examples/medplum-task-demo/tsconfig-bots.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "rootDir": "src/bots", + "outDir": "dist", + "lib": ["esnext"], + "types": ["vitest/globals"], + "target": "es2018", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "sourceMap": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "incremental": false, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": true, + "allowJs": true, + "resolveJsonModule": true, + "noEmit": false, + "isolatedModules": true + }, + "include": ["src/bots/**/*.ts"] +} diff --git a/examples/medplum-websocket-subscriptions-demo/package.json b/examples/medplum-websocket-subscriptions-demo/package.json index 3e308d4fae..41e5b767e2 100644 --- a/examples/medplum-websocket-subscriptions-demo/package.json +++ b/examples/medplum-websocket-subscriptions-demo/package.json @@ -1,6 +1,6 @@ { "name": "medplum-websocket-subscriptions-demo", - "version": "3.1.2", + "version": "3.1.3", "private": true, "type": "module", "scripts": { @@ -20,24 +20,24 @@ }, "devDependencies": { "@emotion/react": "11.11.4", - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhir-router": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhir-router": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } } diff --git a/examples/medplum-websocket-subscriptions-demo/src/components/BundleDisplay.tsx b/examples/medplum-websocket-subscriptions-demo/src/components/BundleDisplay.tsx index cc3b97f916..1e3a6fe94c 100644 --- a/examples/medplum-websocket-subscriptions-demo/src/components/BundleDisplay.tsx +++ b/examples/medplum-websocket-subscriptions-demo/src/components/BundleDisplay.tsx @@ -2,7 +2,7 @@ import { Accordion, ActionIcon, Chip, Group } from '@mantine/core'; import { Bundle, Communication, Reference } from '@medplum/fhirtypes'; import { useMedplum } from '@medplum/react'; import { IconArrowNarrowRight, IconCheck } from '@tabler/icons-react'; -import { useCallback } from 'react'; +import { SyntheticEvent, useCallback } from 'react'; export interface BundleDisplayProps { readonly bundle: Bundle; @@ -16,7 +16,7 @@ export function BundleDisplay(props: BundleDisplayProps): JSX.Element { const [recipientType, recipientId] = ((communication.recipient?.[0] as Reference).reference as string).split('/'); const markAsCompleted = useCallback( - (e: React.SyntheticEvent) => { + (e: SyntheticEvent) => { e.stopPropagation(); e.preventDefault(); medplum diff --git a/package-lock.json b/package-lock.json index e93c1e4ae6..7ad4d653ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "root", - "version": "3.1.2", + "version": "3.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "root", - "version": "3.1.2", + "version": "3.1.3", "workspaces": [ "packages/*", "examples/*" @@ -17,12 +17,13 @@ "@babel/preset-react": "7.24.1", "@babel/preset-typescript": "7.24.1", "@cyclonedx/cyclonedx-npm": "1.16.2", - "@microsoft/api-documenter": "7.24.1", - "@microsoft/api-extractor": "7.43.0", + "@microsoft/api-documenter": "7.24.2", + "@microsoft/api-extractor": "7.43.1", "@types/jest": "29.5.12", - "@types/node": "20.12.5", + "@types/node": "20.12.7", "babel-jest": "29.7.0", "babel-preset-vite": "1.1.3", + "concurrently": "8.2.2", "cross-env": "7.0.3", "danger": "11.3.1", "esbuild": "0.20.2", @@ -39,34 +40,34 @@ "ts-node": "10.9.2", "tslib": "2.6.2", "turbo": "1.13.2", - "typescript": "5.4.4" + "typescript": "5.4.5" }, "engines": { "node": ">=18.0.0" } }, "examples/foomedical": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { "@babel/core": "7.24.4", "@babel/preset-env": "7.24.4", "@babel/preset-react": "7.24.1", "@babel/preset-typescript": "7.24.1", - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", "@testing-library/jest-dom": "6.4.2", - "@testing-library/react": "14.3.0", + "@testing-library/react": "15.0.2", "@types/jest": "29.5.12", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "babel-jest": "29.7.0", "c8": "9.1.0", @@ -76,62 +77,920 @@ "jest-environment-jsdom": "29.7.0", "jest-transform-stub": "2.0.0", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-chartjs-2": "5.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, "examples/medplum-chart-demo": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, + "examples/medplum-chat-demo": { + "version": "3.1.3", + "devDependencies": { + "@mantine/core": "7.6.2", + "@mantine/hooks": "7.6.2", + "@mantine/notifications": "7.6.2", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.0.0", + "@types/node": "20.11.28", + "@types/react": "18.2.66", + "@types/react-dom": "18.2.22", + "@vitejs/plugin-react": "4.2.1", + "postcss": "8.4.36", + "postcss-preset-mantine": "1.13.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "6.22.3", + "typescript": "5.4.2", + "vite": "5.1.6" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "examples/medplum-chat-demo/node_modules/@mantine/core": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.6.2.tgz", + "integrity": "sha512-qmZhmQVc7ZZ8EKKhPkGuZbfBnLXR0xE45ikxfx+1E6/8hLY5Ypr4nWqh5Pk6p3b+K71yYnBqlbNXbtHLQH0h3g==", + "dev": true, + "dependencies": { + "@floating-ui/react": "^0.26.9", + "clsx": "2.1.0", + "react-number-format": "^5.3.1", + "react-remove-scroll": "^2.5.7", + "react-textarea-autosize": "8.5.3", + "type-fest": "^4.12.0" + }, + "peerDependencies": { + "@mantine/hooks": "7.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "examples/medplum-chat-demo/node_modules/@mantine/hooks": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.6.2.tgz", + "integrity": "sha512-ZrOgrZHoIGCDKrr2/9njDgK0al+jjusYQFlmR0YyEFyRtgY6eNSI4zuYLcAPx1haHmUm5RsLBrqY6Iy/TLdGXA==", + "dev": true, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "examples/medplum-chat-demo/node_modules/@mantine/notifications": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.6.2.tgz", + "integrity": "sha512-vLs6Y5nnxHipGkA5TSsxgjeus0N9uS+0+E9KZ5OG5QEtz7BdOsKPtNmytsQzBN8P8fjttFImhhoSUOLpYv0xtA==", + "dev": true, + "dependencies": { + "@mantine/store": "7.6.2", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "7.6.2", + "@mantine/hooks": "7.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "examples/medplum-chat-demo/node_modules/@mantine/store": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.6.2.tgz", + "integrity": "sha512-IEGbyIs7LIXYQtjR87GQPiw12klgsiKiqplGu9LekJb8uW/7XSRNs31ggqKmdF+cMWO/WyQhEXZdpWNib6tVOw==", + "dev": true, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "examples/medplum-chat-demo/node_modules/@tabler/icons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.0.0.tgz", + "integrity": "sha512-DT27tfUt7v6wqn+NVBp5zSxYwfVuyImdgih0ULTbs0qQrAANmjobyMSrD+ATWvIsS5QuhbA+lX4hv8pEe7Lm/w==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "examples/medplum-chat-demo/node_modules/@tabler/icons-react": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.0.0.tgz", + "integrity": "sha512-KI3rFTvH6k/4fJVVegvqdqiaaPyo2YZ4X/4A0HExwJKfqZrUeZZHB2/iCTXnvecI9REWrexmFlyYMpthA9dEDw==", + "dev": true, + "dependencies": { + "@tabler/icons": "3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "examples/medplum-chat-demo/node_modules/@types/node": { + "version": "20.11.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.28.tgz", + "integrity": "sha512-M/GPWVS2wLkSkNHVeLkrF2fD5Lx5UC4PxA0uZcKc6QqbIQUJyW1jVjueJYi1z8n0I5PxYrtpnPnWglE+y9A0KA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "examples/medplum-chat-demo/node_modules/@types/react": { + "version": "18.2.66", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz", + "integrity": "sha512-OYTmMI4UigXeFMF/j4uv0lBBEbongSgptPrHBxqME44h9+yNov+oL6Z3ocJKo0WyXR84sQUNeyIp9MRfckvZpg==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "examples/medplum-chat-demo/node_modules/@types/react-dom": { + "version": "18.2.22", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz", + "integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "examples/medplum-chat-demo/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "examples/medplum-chat-demo/node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.24.1.tgz", + "integrity": "sha512-kUpHOLiH5GB0ERSv4pxqlL0RYKnOXtgGtVe7shDGfhS0AZ4D1ouKFYAcLcZhql8aMspDNzaUCumGHZ78tb2fTg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^1.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.24.1", + "lightningcss-darwin-x64": "1.24.1", + "lightningcss-freebsd-x64": "1.24.1", + "lightningcss-linux-arm-gnueabihf": "1.24.1", + "lightningcss-linux-arm64-gnu": "1.24.1", + "lightningcss-linux-arm64-musl": "1.24.1", + "lightningcss-linux-x64-gnu": "1.24.1", + "lightningcss-linux-x64-musl": "1.24.1", + "lightningcss-win32-x64-msvc": "1.24.1" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss-darwin-arm64": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.24.1.tgz", + "integrity": "sha512-1jQ12jBy+AE/73uGQWGSafK5GoWgmSiIQOGhSEXiFJSZxzV+OXIx+a9h2EYHxdJfX864M+2TAxWPWb0Vv+8y4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss-darwin-x64": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.24.1.tgz", + "integrity": "sha512-R4R1d7VVdq2mG4igMU+Di8GPf0b64ZLnYVkubYnGG0Qxq1KaXQtAzcLI43EkpnoWvB/kUg8JKCWH4S13NfiLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.24.1.tgz", + "integrity": "sha512-NLQLnBQW/0sSg74qLNI8F8QKQXkNg4/ukSTa+XhtkO7v3BnK19TS1MfCbDHt+TTdSgNEBv0tubRuapcKho2EWw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.24.1.tgz", + "integrity": "sha512-AQxWU8c9E9JAjAi4Qw9CvX2tDIPjgzCTrZCSXKELfs4mCwzxRkHh2RCxX8sFK19RyJoJAjA/Kw8+LMNRHS5qEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.24.1.tgz", + "integrity": "sha512-JCgH/SrNrhqsguUA0uJUM1PvN5+dVuzPIlXcoWDHSv2OU/BWlj2dUYr3XNzEw748SmNZPfl2NjQrAdzaPOn1lA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.24.1.tgz", + "integrity": "sha512-TYdEsC63bHV0h47aNRGN3RiK7aIeco3/keN4NkoSQ5T8xk09KHuBdySltWAvKLgT8JvR+ayzq8ZHnL1wKWY0rw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss-linux-x64-musl": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.24.1.tgz", + "integrity": "sha512-HLfzVik3RToot6pQ2Rgc3JhfZkGi01hFetHt40HrUMoeKitLoqUUT5owM6yTZPTytTUW9ukLBJ1pc3XNMSvlLw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "examples/medplum-chat-demo/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.24.1.tgz", + "integrity": "sha512-joEupPjYJ7PjZtDsS5lzALtlAudAbgIBMGJPNeFe5HfdmJXFd13ECmEM+5rXNxYVMRHua2w8132R6ab5Z6K9Ow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "examples/medplum-chat-demo/node_modules/postcss": { + "version": "8.4.36", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.36.tgz", + "integrity": "sha512-/n7eumA6ZjFHAsbX30yhHup/IMkOmlmvtEi7P+6RMYf+bGJSUHc3geH4a0NSZxAz/RJfiS9tooCTs9LAVYUZKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.1.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "examples/medplum-chat-demo/node_modules/postcss-preset-mantine": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/postcss-preset-mantine/-/postcss-preset-mantine-1.13.0.tgz", + "integrity": "sha512-1bv/mQz2K+/FixIMxYd83BYH7PusDZaI7LpUtKbb1l/5N5w6t1p/V9ONHfRJeeAZyfa6Xc+AtR+95VKdFXRH1g==", + "dev": true, + "dependencies": { + "postcss-mixins": "^9.0.4", + "postcss-nested": "^6.0.1" + }, + "peerDependencies": { + "postcss": ">=8.0.0" + } + }, + "examples/medplum-chat-demo/node_modules/typescript": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "examples/medplum-chat-demo/node_modules/vite": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.6.tgz", + "integrity": "sha512-yYIAZs9nVfRJ/AiOLCA91zzhjsHUgMjB+EigzFb6W2XTLO8JixBCKCjvhKZaye+NKYHCrkv3Oh50dH9EdLU2RA==", + "dev": true, + "dependencies": { + "esbuild": "^0.19.3", + "postcss": "^8.4.35", + "rollup": "^4.2.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "examples/medplum-client-external-idp-demo": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "rimraf": "5.0.5", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, "examples/medplum-demo-bots": { - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { - "@medplum/cli": "3.1.2", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@types/node": "20.12.5", + "@medplum/cli": "3.1.3", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@types/node": "20.12.7", "@types/node-fetch": "2.6.11", "@types/ssh2-sftp-client": "9.0.3", - "@vitest/coverage-v8": "1.4.0", - "@vitest/ui": "1.4.0", + "@vitest/coverage-v8": "1.5.0", + "@vitest/ui": "1.5.0", "esbuild": "0.20.2", "form-data": "4.0.0", "glob": "^10.3.12", @@ -139,189 +998,190 @@ "pdfmake": "0.2.10", "rimraf": "5.0.5", "ssh2-sftp-client": "10.0.3", - "stripe": "14.24.0", - "typescript": "5.4.4", - "vitest": "1.4.0" + "stripe": "15.1.0", + "typescript": "5.4.5", + "vitest": "1.5.0" } }, "examples/medplum-eligibility-demo": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, "examples/medplum-fhircast-demo": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, "examples/medplum-hello-world": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, "examples/medplum-live-chat-demo": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, "examples/medplum-nextauth-demo": { - "version": "3.1.2", + "version": "3.1.3", "dependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "next": "14.1.4", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "next": "14.2.1", "next-auth": "4.24.7", "react": "18.2.0", "react-dom": "18.2.0" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@medplum/fhirtypes": "3.1.3", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "eslint": "8.57.0", - "eslint-config-next": "14.1.4", - "typescript": "5.4.4" + "eslint-config-next": "14.2.1", + "typescript": "5.4.5" } }, "examples/medplum-nextjs-demo": { - "version": "3.1.2", + "version": "3.1.3", "dependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/react": "3.1.2", - "next": "14.1.4", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/react": "3.1.3", + "@next/bundle-analyzer": "14.2.1", + "next": "14.2.1", "react": "18.2.0", "react-dom": "18.2.0", "rfc6902": "5.1.1" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@medplum/fhirtypes": "3.1.3", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "eslint": "8.57.0", - "eslint-config-next": "14.1.4", + "eslint-config-next": "14.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", - "typescript": "5.4.4" + "postcss-preset-mantine": "1.14.4", + "typescript": "5.4.5" } }, "examples/medplum-provider": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, "examples/medplum-react-native-example": { - "version": "3.1.2", + "version": "3.1.3", "dependencies": { "@expo/metro-runtime": "3.1.3", - "@medplum/core": "3.1.2", - "@medplum/expo-polyfills": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react-hooks": "3.1.2", - "expo": "50.0.14", + "@medplum/core": "3.1.3", + "@medplum/expo-polyfills": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react-hooks": "3.1.3", + "expo": "50.0.15", "expo-status-bar": "1.11.1", "react": "18.2.0", "react-dom": "18.2.0", @@ -330,56 +1190,56 @@ }, "devDependencies": { "@babel/core": "7.24.4", - "typescript": "5.4.4" + "typescript": "5.4.5" } }, "examples/medplum-task-demo": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, "examples/medplum-websocket-subscriptions-demo": { - "version": "3.1.2", + "version": "3.1.3", "devDependencies": { "@emotion/react": "11.11.4", - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/eslint-config": "3.1.2", - "@medplum/fhir-router": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/eslint-config": "3.1.3", + "@medplum/fhir-router": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "vite": "5.2.8" } }, @@ -444,82 +1304,82 @@ } }, "node_modules/@algolia/cache-browser-local-storage": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.23.2.tgz", - "integrity": "sha512-PvRQdCmtiU22dw9ZcTJkrVKgNBVAxKgD0/cfiqyxhA5+PHzA2WDt6jOmZ9QASkeM2BpyzClJb/Wr1yt2/t78Kw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.23.3.tgz", + "integrity": "sha512-vRHXYCpPlTDE7i6UOy2xE03zHF2C8MEFjPN2v7fRbqVpcOvAUQK81x3Kc21xyb5aSIpYCjWCZbYZuz8Glyzyyg==", "dev": true, "dependencies": { - "@algolia/cache-common": "4.23.2" + "@algolia/cache-common": "4.23.3" } }, "node_modules/@algolia/cache-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.23.2.tgz", - "integrity": "sha512-OUK/6mqr6CQWxzl/QY0/mwhlGvS6fMtvEPyn/7AHUx96NjqDA4X4+Ju7aXFQKh+m3jW9VPB0B9xvEQgyAnRPNw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.23.3.tgz", + "integrity": "sha512-h9XcNI6lxYStaw32pHpB1TMm0RuxphF+Ik4o7tcQiodEdpKK+wKufY6QXtba7t3k8eseirEMVB83uFFF3Nu54A==", "dev": true }, "node_modules/@algolia/cache-in-memory": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.23.2.tgz", - "integrity": "sha512-rfbi/SnhEa3MmlqQvgYz/9NNJ156NkU6xFxjbxBtLWnHbpj+qnlMoKd+amoiacHRITpajg6zYbLM9dnaD3Bczw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.23.3.tgz", + "integrity": "sha512-yvpbuUXg/+0rbcagxNT7un0eo3czx2Uf0y4eiR4z4SD7SiptwYTpbuS0IHxcLHG3lq22ukx1T6Kjtk/rT+mqNg==", "dev": true, "dependencies": { - "@algolia/cache-common": "4.23.2" + "@algolia/cache-common": "4.23.3" } }, "node_modules/@algolia/client-account": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.23.2.tgz", - "integrity": "sha512-VbrOCLIN/5I7iIdskSoSw3uOUPF516k4SjDD4Qz3BFwa3of7D9A0lzBMAvQEJJEPHWdVraBJlGgdJq/ttmquJQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.23.3.tgz", + "integrity": "sha512-hpa6S5d7iQmretHHF40QGq6hz0anWEHGlULcTIT9tbUssWUriN9AUXIFQ8Ei4w9azD0hc1rUok9/DeQQobhQMA==", "dev": true, "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/transporter": "4.23.2" + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/client-analytics": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.23.2.tgz", - "integrity": "sha512-lLj7irsAztGhMoEx/SwKd1cwLY6Daf1Q5f2AOsZacpppSvuFvuBrmkzT7pap1OD/OePjLKxicJS8wNA0+zKtuw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.23.3.tgz", + "integrity": "sha512-LBsEARGS9cj8VkTAVEZphjxTjMVCci+zIIiRhpFun9jGDUlS1XmhCW7CTrnaWeIuCQS/2iPyRqSy1nXPjcBLRA==", "dev": true, "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/client-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.23.2.tgz", - "integrity": "sha512-Q2K1FRJBern8kIfZ0EqPvUr3V29ICxCm/q42zInV+VJRjldAD9oTsMGwqUQ26GFMdFYmqkEfCbY4VGAiQhh22g==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.23.3.tgz", + "integrity": "sha512-l6EiPxdAlg8CYhroqS5ybfIczsGUIAC47slLPOMDeKSVXYG1n0qGiz4RjAHLw2aD0xzh2EXZ7aRguPfz7UKDKw==", "dev": true, "dependencies": { - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/client-personalization": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.23.2.tgz", - "integrity": "sha512-vwPsgnCGhUcHhhQG5IM27z8q7dWrN9itjdvgA6uKf2e9r7vB+WXt4OocK0CeoYQt3OGEAExryzsB8DWqdMK5wg==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.23.3.tgz", + "integrity": "sha512-3E3yF3Ocr1tB/xOZiuC3doHQBQ2zu2MPTYZ0d4lpfWads2WTKG7ZzmGnsHmm63RflvDeLK/UVx7j2b3QuwKQ2g==", "dev": true, "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" + "@algolia/client-common": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/client-search": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.23.2.tgz", - "integrity": "sha512-CxSB29OVGSE7l/iyoHvamMonzq7Ev8lnk/OkzleODZ1iBcCs3JC/XgTIKzN/4RSTrJ9QybsnlrN/bYCGufo7qw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.23.3.tgz", + "integrity": "sha512-P4VAKFHqU0wx9O+q29Q8YVuaowaZ5EM77rxfmGnkHUJggh28useXQdopokgwMeYw2XUht49WX5RcTQ40rZIabw==", "dev": true, "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" + "@algolia/client-common": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/events": { @@ -529,72 +1389,72 @@ "dev": true }, "node_modules/@algolia/logger-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.23.2.tgz", - "integrity": "sha512-jGM49Q7626cXZ7qRAWXn0jDlzvoA1FvN4rKTi1g0hxKsTTSReyYk0i1ADWjChDPl3Q+nSDhJuosM2bBUAay7xw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.23.3.tgz", + "integrity": "sha512-y9kBtmJwiZ9ZZ+1Ek66P0M68mHQzKRxkW5kAAXYN/rdzgDN0d2COsViEFufxJ0pb45K4FRcfC7+33YB4BLrZ+g==", "dev": true }, "node_modules/@algolia/logger-console": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.23.2.tgz", - "integrity": "sha512-oo+lnxxEmlhTBTFZ3fGz1O8PJ+G+8FiAoMY2Qo3Q4w23xocQev6KqDTA1JQAGPDxAewNA2VBwWOsVXeXFjrI/Q==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.23.3.tgz", + "integrity": "sha512-8xoiseoWDKuCVnWP8jHthgaeobDLolh00KJAdMe9XPrWPuf1by732jSpgy2BlsLTaT9m32pHI8CRfrOqQzHv3A==", "dev": true, "dependencies": { - "@algolia/logger-common": "4.23.2" + "@algolia/logger-common": "4.23.3" } }, "node_modules/@algolia/recommend": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.23.2.tgz", - "integrity": "sha512-Q75CjnzRCDzgIlgWfPnkLtrfF4t82JCirhalXkSSwe/c1GH5pWh4xUyDOR3KTMo+YxxX3zTlrL/FjHmUJEWEcg==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.23.3.tgz", + "integrity": "sha512-9fK4nXZF0bFkdcLBRDexsnGzVmu4TSYZqxdpgBW2tEyfuSSY54D4qSRkLmNkrrz4YFvdh2GM1gA8vSsnZPR73w==", "dev": true, "dependencies": { - "@algolia/cache-browser-local-storage": "4.23.2", - "@algolia/cache-common": "4.23.2", - "@algolia/cache-in-memory": "4.23.2", - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/logger-console": "4.23.2", - "@algolia/requester-browser-xhr": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/requester-node-http": "4.23.2", - "@algolia/transporter": "4.23.2" + "@algolia/cache-browser-local-storage": "4.23.3", + "@algolia/cache-common": "4.23.3", + "@algolia/cache-in-memory": "4.23.3", + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/logger-console": "4.23.3", + "@algolia/requester-browser-xhr": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/requester-node-http": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.23.2.tgz", - "integrity": "sha512-TO9wLlp8+rvW9LnIfyHsu8mNAMYrqNdQ0oLF6eTWFxXfxG3k8F/Bh7nFYGk2rFAYty4Fw4XUtrv/YjeNDtM5og==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.23.3.tgz", + "integrity": "sha512-jDWGIQ96BhXbmONAQsasIpTYWslyjkiGu0Quydjlowe+ciqySpiDUrJHERIRfELE5+wFc7hc1Q5hqjGoV7yghw==", "dev": true, "dependencies": { - "@algolia/requester-common": "4.23.2" + "@algolia/requester-common": "4.23.3" } }, "node_modules/@algolia/requester-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.23.2.tgz", - "integrity": "sha512-3EfpBS0Hri0lGDB5H/BocLt7Vkop0bTTLVUBB844HH6tVycwShmsV6bDR7yXbQvFP1uNpgePRD3cdBCjeHmk6Q==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.23.3.tgz", + "integrity": "sha512-xloIdr/bedtYEGcXCiF2muajyvRhwop4cMZo+K2qzNht0CMzlRkm8YsDdj5IaBhshqfgmBb3rTg4sL4/PpvLYw==", "dev": true }, "node_modules/@algolia/requester-node-http": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.23.2.tgz", - "integrity": "sha512-SVzgkZM/malo+2SB0NWDXpnT7nO5IZwuDTaaH6SjLeOHcya1o56LSWXk+3F3rNLz2GVH+I/rpYKiqmHhSOjerw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.23.3.tgz", + "integrity": "sha512-zgu++8Uj03IWDEJM3fuNl34s746JnZOWn1Uz5taV1dFyJhVM/kTNw9Ik7YJWiUNHJQXcaD8IXD1eCb0nq/aByA==", "dev": true, "dependencies": { - "@algolia/requester-common": "4.23.2" + "@algolia/requester-common": "4.23.3" } }, "node_modules/@algolia/transporter": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.23.2.tgz", - "integrity": "sha512-GY3aGKBy+8AK4vZh8sfkatDciDVKad5rTY2S10Aefyjh7e7UGBP4zigf42qVXwU8VOPwi7l/L7OACGMOFcjB0Q==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.23.3.tgz", + "integrity": "sha512-Wjl5gttqnf/gQKJA+dafnD0Y6Yw97yvfY8R9h0dQltX1GXTgNs1zWgvtWW0tHl1EgMdhAyw189uWiZMnL3QebQ==", "dev": true, "dependencies": { - "@algolia/cache-common": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/requester-common": "4.23.2" + "@algolia/cache-common": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/requester-common": "4.23.3" } }, "node_modules/@ampproject/remapping": { @@ -762,15 +1622,15 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "node_modules/@aws-sdk/client-acm": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-acm/-/client-acm-3.549.0.tgz", - "integrity": "sha512-RFFKR1idpM8MlSqJ0/QFz063iuGl5ITdDV3/+twwvt+Szr8ARuCduFV3X7q63ElnTJINL1zAoyEFZwQmfxUH3w==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-acm/-/client-acm-3.554.0.tgz", + "integrity": "sha512-6/YnhngMLETn3XmkpTqj1bzC4qS0InO35KBOikEfmj3lMRqePX5AIEUAjnwdY7wppiaUN+tE1dpNI1uW1ASc7w==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -781,26 +1641,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -813,15 +1673,15 @@ } }, "node_modules/@aws-sdk/client-cloudformation": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.549.0.tgz", - "integrity": "sha512-mEJUP2guSOdku5/+LIiKwAA9WE+nHKEtKPGOyOL0x72lkZCsfM3QSr35Xi6p1Aip3XB/LI+UCuoYpjiaceSpSw==", + "version": "3.555.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.555.0.tgz", + "integrity": "sha512-gm+qteiSwG/Y25lrIdjiP/GQkYSCdxhTAcHHUGmC85pDTLRsZbTXUm79rhvT3SfoLX3/Hh4JHoVSiFL+wxKeww==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -832,26 +1692,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -865,15 +1725,15 @@ } }, "node_modules/@aws-sdk/client-cloudfront": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.549.0.tgz", - "integrity": "sha512-XoTKP2sttLl7azal3baptnk4r1zSVX5KjRVim9dlLFViCRrSxpWwQNXKyKI53Hk1rsy0CNxJzPYd4/ag11kOCQ==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.554.0.tgz", + "integrity": "sha512-cFKqFew8xuQWVFH1ZAI2a/Gs66bI8+OlHj/5ED1ZKoBGiBQRC+EUT/0bhbzAYJLB9jeSRPGRL/jOgcL6Sw4Hcg==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -885,26 +1745,26 @@ "@aws-sdk/util-user-agent-node": "3.535.0", "@aws-sdk/xml-builder": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -918,15 +1778,15 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.549.0.tgz", - "integrity": "sha512-LAB4MWkx41wF+IImj4nucMmgAkGFD51sR5JkXsUoB5leLo0Oz84ZEFZ8RfqshF6ReoW5lmSRyB8x1663KdRcLA==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.554.0.tgz", + "integrity": "sha512-xwnCTljcrvEBVFbFoBcKfTQOhpD4ACJ17L/ISmBqqC2sZLANtxnsxGxJCpLiVwdDtoAIW43VvzFlZCZi7OEF2A==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -937,7 +1797,7 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/eventstream-serde-browser": "^2.2.0", "@smithy/eventstream-serde-config-resolver": "^2.2.0", "@smithy/eventstream-serde-node": "^2.2.0", @@ -945,21 +1805,21 @@ "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -972,15 +1832,15 @@ } }, "node_modules/@aws-sdk/client-ecs": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecs/-/client-ecs-3.549.0.tgz", - "integrity": "sha512-3eiIs8O0fDKyjy6ch7Y7Xgb2GWt8/n+AUR4uQXP0jQWnmou/UAqwYKp5S8tqusxDV8ZG47maBFd/dZ+xOlyq2A==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecs/-/client-ecs-3.554.0.tgz", + "integrity": "sha512-JyGbk0ulTtz8rjvVCWKqf+1Lobk6TDB+bPpfhMa3Z+ACyNsS+qU3W+XcDUXkgn+VGFd+nMfFoJOotSsT03ilFw==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -991,26 +1851,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -1024,15 +1884,15 @@ } }, "node_modules/@aws-sdk/client-lambda": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.549.0.tgz", - "integrity": "sha512-VdLltPf6fUDBFHPJBtYsWnM+nvdau7KuRlwwGnYzmYap79ifE538JTXE6AGyGcIp3YP44BlFtWgvW26kvDbX9g==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.554.0.tgz", + "integrity": "sha512-KNUAAZKcsCdUOB2/rbWpc96jsSM/ahw3hK5/Ru4RTLfNP27GitxqF0v+mzrVk9lTuj2ChJ3JDV+UfdGsqvZgpw==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -1043,7 +1903,7 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/eventstream-serde-browser": "^2.2.0", "@smithy/eventstream-serde-config-resolver": "^2.2.0", "@smithy/eventstream-serde-node": "^2.2.0", @@ -1051,21 +1911,21 @@ "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -1079,16 +1939,16 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.550.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.550.0.tgz", - "integrity": "sha512-45jjDQI0Q37PIteWhywhlExxYaiUeOsTsbE62b+U/FOjYV8tirC8uBY9eHeHaP4IPVGHeQWvEYrFJHNU+qsQLQ==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.554.0.tgz", + "integrity": "sha512-d5TKKtGWhN0vl9QovUFrf3UsM7jgFQkowDPx1O+E/yeQUj1FBDOoRfDCcQOKW/9ghloI6k7f0bBpNxdd+x0oKA==", "dependencies": { "@aws-crypto/sha1-browser": "3.0.0", "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-bucket-endpoint": "3.535.0", "@aws-sdk/middleware-expect-continue": "3.535.0", "@aws-sdk/middleware-flexible-checksums": "3.535.0", @@ -1096,19 +1956,19 @@ "@aws-sdk/middleware-location-constraint": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", - "@aws-sdk/middleware-sdk-s3": "3.535.0", - "@aws-sdk/middleware-signing": "3.535.0", + "@aws-sdk/middleware-sdk-s3": "3.552.0", + "@aws-sdk/middleware-signing": "3.552.0", "@aws-sdk/middleware-ssec": "3.537.0", "@aws-sdk/middleware-user-agent": "3.540.0", "@aws-sdk/region-config-resolver": "3.535.0", - "@aws-sdk/signature-v4-multi-region": "3.535.0", + "@aws-sdk/signature-v4-multi-region": "3.552.0", "@aws-sdk/types": "3.535.0", "@aws-sdk/util-endpoints": "3.540.0", "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@aws-sdk/xml-builder": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/eventstream-serde-browser": "^2.2.0", "@smithy/eventstream-serde-config-resolver": "^2.2.0", "@smithy/eventstream-serde-node": "^2.2.0", @@ -1119,21 +1979,21 @@ "@smithy/invalid-dependency": "^2.2.0", "@smithy/md5-js": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-retry": "^2.2.0", "@smithy/util-stream": "^2.2.0", @@ -1146,15 +2006,15 @@ } }, "node_modules/@aws-sdk/client-secrets-manager": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.549.0.tgz", - "integrity": "sha512-UcfU1vdghAJMWK1T6NnNe0ZhOIfSY6s0OBooXSybDgZvH47g+rYD/4VdtZRWRSjdCcF4PA+hfRa7vhfOyXdW/A==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.554.0.tgz", + "integrity": "sha512-Uk9rdO6nP1Ayg6maOCD7ZI7QlRzDCGoFQtp/hxBt0uGro5C47Rpg5N6Wn3Lblk/rGnDcq+nuX24WXo83jOi/HQ==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -1165,26 +2025,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -1197,15 +2057,15 @@ } }, "node_modules/@aws-sdk/client-sesv2": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.549.0.tgz", - "integrity": "sha512-o+78gx6E4aKB9dqbUfpCo7SP24zQn8BKmh17hWOXB6ryDqZUHeaoeJh0gtcrE6EXEsEtqGlXIIaTu9kYdYDepA==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.554.0.tgz", + "integrity": "sha512-l5x92adRLKX/PgvdsA4f/rAXyhFKoODW2KkrHdH98H9gXvWU8Tx4GRwwFw34S7ZlO+yg2j4mQZWo+4lLoJXfjw==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -1216,26 +2076,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -1247,15 +2107,15 @@ } }, "node_modules/@aws-sdk/client-ssm": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.549.0.tgz", - "integrity": "sha512-h5EvM1e09+ybmaCFXVjibWGyBSJ7yNIgKFq1SoifnZ5O4nf6SNNAhbuea1Gq4JCcjvDfzJ4Wl3EqVHf/Jb9oJg==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.554.0.tgz", + "integrity": "sha512-zqc5Pyb0agJ3erp1x2ILoll7mG6atQTD2AFWA5UBFhNa7R0+w+TLvSNnX813X4bv4OySqBYYEtAokoTvV66UZw==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", - "@aws-sdk/credential-provider-node": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", + "@aws-sdk/credential-provider-node": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -1266,26 +2126,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -1299,13 +2159,13 @@ } }, "node_modules/@aws-sdk/client-sso": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.549.0.tgz", - "integrity": "sha512-lz+yflOAj5Q263FlCsKpNqttaCb2NPh8jC76gVCqCt7TPxRDBYVaqg0OZYluDaETIDNJi4DwN2Azcck7ilwuPw==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.554.0.tgz", + "integrity": "sha512-yj6CgIxCT3UwMumEO481KH4QvwArkAPzD7Xvwe1QKgJATc9bKNEo/FxV8LfnWIJ7nOtMDxbNxYLMXH/Fs1qGaQ==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/core": "3.549.0", + "@aws-sdk/core": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -1316,26 +2176,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -1347,14 +2207,14 @@ } }, "node_modules/@aws-sdk/client-sso-oidc": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.549.0.tgz", - "integrity": "sha512-FbB4A78ILAb8sM4TfBd+3CrQcfZIhe0gtVZNbaxpq5cJZh1K7oZ8vPfKw4do9JWkDUXPLsD9Bwz12f8/JpAb6Q==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.554.0.tgz", + "integrity": "sha512-M86rkiRqbZBF5VyfTQ/vttry9VSoQkZ1oCqYF+SAGlXmD0Of8587yRSj2M4rYe0Uj7nRQIfSnhDYp1UzsZeRfQ==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.549.0", - "@aws-sdk/core": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", + "@aws-sdk/core": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -1365,26 +2225,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -1395,17 +2255,17 @@ "node": ">=14.0.0" }, "peerDependencies": { - "@aws-sdk/credential-provider-node": "^3.549.0" + "@aws-sdk/credential-provider-node": "^3.554.0" } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.549.0.tgz", - "integrity": "sha512-63IreJ598Dzvpb+6sy81KfIX5iQxnrWSEtlyeCdC2GO6gmSQVwJzc9kr5pAC83lHmlZcm/Q3KZr3XBhRQqP0og==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.554.0.tgz", + "integrity": "sha512-EhaA6T0M0DNg5M8TCF1a7XJI5D/ZxAF3dgVIchyF98iNzjYgl/7U8K6hJay2A11aFvVu70g46xYMpz3Meky4wQ==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/core": "3.549.0", + "@aws-sdk/core": "3.554.0", "@aws-sdk/middleware-host-header": "3.535.0", "@aws-sdk/middleware-logger": "3.535.0", "@aws-sdk/middleware-recursion-detection": "3.535.0", @@ -1416,26 +2276,26 @@ "@aws-sdk/util-user-agent-browser": "3.535.0", "@aws-sdk/util-user-agent-node": "3.535.0", "@smithy/config-resolver": "^2.2.0", - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/hash-node": "^2.2.0", "@smithy/invalid-dependency": "^2.2.0", "@smithy/middleware-content-length": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/middleware-retry": "^2.3.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", "@smithy/middleware-serde": "^2.3.0", "@smithy/middleware-stack": "^2.2.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/url-parser": "^2.2.0", "@smithy/util-base64": "^2.3.0", "@smithy/util-body-length-browser": "^2.2.0", "@smithy/util-body-length-node": "^2.3.0", - "@smithy/util-defaults-mode-browser": "^2.2.0", - "@smithy/util-defaults-mode-node": "^2.3.0", + "@smithy/util-defaults-mode-browser": "^2.2.1", + "@smithy/util-defaults-mode-node": "^2.3.1", "@smithy/util-endpoints": "^1.2.0", "@smithy/util-middleware": "^2.2.0", "@smithy/util-retry": "^2.2.0", @@ -1446,7 +2306,7 @@ "node": ">=14.0.0" }, "peerDependencies": { - "@aws-sdk/credential-provider-node": "^3.549.0" + "@aws-sdk/credential-provider-node": "^3.554.0" } }, "node_modules/@aws-sdk/cloudfront-signer": { @@ -1462,14 +2322,14 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.549.0.tgz", - "integrity": "sha512-jC61OxJn72r/BbuDRCcluiw05Xw9eVLG0CwxQpF3RocxfxyZqlrGYaGecZ8Wy+7g/3sqGRC/Ar5eUhU1YcLx7w==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.554.0.tgz", + "integrity": "sha512-JrG7ToTLeNf+/S3IiCUPVw9jEDB0DXl5ho8n/HwOa946mv+QyCepCuV2U/8f/1KAX0mD8Ufm/E4/cbCbFHgbSg==", "dependencies": { - "@smithy/core": "^1.4.1", + "@smithy/core": "^1.4.2", "@smithy/protocol-http": "^3.3.0", - "@smithy/signature-v4": "^2.2.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/signature-v4": "^2.2.1", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "fast-xml-parser": "4.2.5", "tslib": "^2.6.2" @@ -1493,16 +2353,16 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.535.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.535.0.tgz", - "integrity": "sha512-kdj1wCmOMZ29jSlUskRqN04S6fJ4dvt0Nq9Z32SA6wO7UG8ht6Ot9h/au/eTWJM3E1somZ7D771oK7dQt9b8yw==", + "version": "3.552.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.552.0.tgz", + "integrity": "sha512-vsmu7Cz1i45pFEqzVb4JcFmAmVnWFNLsGheZc8SCptlqCO5voETrZZILHYIl4cjKkSDk3pblBOf0PhyjqWW6WQ==", "dependencies": { "@aws-sdk/types": "3.535.0", "@smithy/fetch-http-handler": "^2.5.0", "@smithy/node-http-handler": "^2.5.0", "@smithy/property-provider": "^2.2.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/util-stream": "^2.2.0", "tslib": "^2.6.2" @@ -1512,15 +2372,15 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.549.0.tgz", - "integrity": "sha512-k6IIrluZjQpzui5Din8fW3bFFhHaJ64XrsfYx0Ks1mb7xan84dJxmYP3tdDDmLzUeJv5h95ag88taHfjY9rakA==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.554.0.tgz", + "integrity": "sha512-BQenhg43S6TMJHxrdjDVdVF+HH5tA1op9ZYLyJrvV5nn7CCO4kyAkkOuSAv1NkL+RZsIkW0/vHTXwQOQw3cUsg==", "dependencies": { - "@aws-sdk/client-sts": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", "@aws-sdk/credential-provider-env": "3.535.0", "@aws-sdk/credential-provider-process": "3.535.0", - "@aws-sdk/credential-provider-sso": "3.549.0", - "@aws-sdk/credential-provider-web-identity": "3.549.0", + "@aws-sdk/credential-provider-sso": "3.554.0", + "@aws-sdk/credential-provider-web-identity": "3.554.0", "@aws-sdk/types": "3.535.0", "@smithy/credential-provider-imds": "^2.3.0", "@smithy/property-provider": "^2.2.0", @@ -1533,16 +2393,16 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.549.0.tgz", - "integrity": "sha512-f3YgalsMuywEAVX4AUm9tojqrBdfpAac0+D320ePzas0Ntbp7ItYu9ceKIhgfzXO3No7P3QK0rCrOxL+ABTn8Q==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.554.0.tgz", + "integrity": "sha512-poX/+2OE3oxqp4f5MiaJh251p8l+bzcFwgcDBwz0e2rcpvMSYl9jw4AvGnCiG2bmf9yhNJdftBiS1A+KjxV0qA==", "dependencies": { "@aws-sdk/credential-provider-env": "3.535.0", - "@aws-sdk/credential-provider-http": "3.535.0", - "@aws-sdk/credential-provider-ini": "3.549.0", + "@aws-sdk/credential-provider-http": "3.552.0", + "@aws-sdk/credential-provider-ini": "3.554.0", "@aws-sdk/credential-provider-process": "3.535.0", - "@aws-sdk/credential-provider-sso": "3.549.0", - "@aws-sdk/credential-provider-web-identity": "3.549.0", + "@aws-sdk/credential-provider-sso": "3.554.0", + "@aws-sdk/credential-provider-web-identity": "3.554.0", "@aws-sdk/types": "3.535.0", "@smithy/credential-provider-imds": "^2.3.0", "@smithy/property-provider": "^2.2.0", @@ -1570,12 +2430,12 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.549.0.tgz", - "integrity": "sha512-BGopRKHs7W8zkoH8qmSHrjudj263kXbhVkAUPxVUz0I28+CZNBgJC/RfVCbOpzmysIQEpwSqvOv1y0k+DQzIJQ==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.554.0.tgz", + "integrity": "sha512-8QPpwBA31i/fZ7lDZJC4FA9EdxLg5SJ8sPB2qLSjp5UTGTYL2HRl0Eznkb7DXyp/wImsR/HFR1NxuFCCVotLCg==", "dependencies": { - "@aws-sdk/client-sso": "3.549.0", - "@aws-sdk/token-providers": "3.549.0", + "@aws-sdk/client-sso": "3.554.0", + "@aws-sdk/token-providers": "3.554.0", "@aws-sdk/types": "3.535.0", "@smithy/property-provider": "^2.2.0", "@smithy/shared-ini-file-loader": "^2.4.0", @@ -1587,11 +2447,11 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.549.0.tgz", - "integrity": "sha512-QzclVXPxuwSI7515l34sdvliVq5leroO8P7RQFKRgfyQKO45o1psghierwG3PgV6jlMiv78FIAGJBr/n4qZ7YA==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.554.0.tgz", + "integrity": "sha512-HN54DzLjepw5ZWSF9ycGevhFTyg6pjLuLKy5Y8t/f1jFDComzYdGEDe0cdV9YO653W3+PQwZZGz09YVygGYBLg==", "dependencies": { - "@aws-sdk/client-sts": "3.549.0", + "@aws-sdk/client-sts": "3.554.0", "@aws-sdk/types": "3.535.0", "@smithy/property-provider": "^2.2.0", "@smithy/types": "^2.12.0", @@ -1602,13 +2462,13 @@ } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.550.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.550.0.tgz", - "integrity": "sha512-zDUM4hV/t148DCXschwDusH9tzg7U1MpuUaUPJlklx9Va+NnjrjtWHwL/JeZ5sfGR/1wTZIg3sKho/4P2oAYrQ==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.554.0.tgz", + "integrity": "sha512-WMn2EObllRKI0ELi31SoUGPowQ23/LCAXkG1o1VEas5kqobwgVgp9D8zqs9A/MEaZYl0yDqd94uKQJd7rUM/yg==", "dependencies": { "@smithy/abort-controller": "^2.2.0", - "@smithy/middleware-endpoint": "^2.5.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/smithy-client": "^2.5.1", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", @@ -1621,6 +2481,15 @@ "@aws-sdk/client-s3": "^3.0.0" } }, + "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { "version": "3.535.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.535.0.tgz", @@ -1725,16 +2594,16 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.535.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.535.0.tgz", - "integrity": "sha512-/dLG/E3af6ohxkQ5GBHT8tZfuPIg6eItKxCXuulvYj0Tqgf3Mb+xTsvSkxQsJF06RS4sH7Qsg/PnB8ZfrJrXpg==", + "version": "3.552.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.552.0.tgz", + "integrity": "sha512-9KzOqsbwJJuQcpmrpkkIftjPahB1bsrcWalYzcVqKCgHCylhkSHW2tX+uGHRnvAl9iobQD5D7LUrS+cv0NeQ/Q==", "dependencies": { "@aws-sdk/types": "3.535.0", "@aws-sdk/util-arn-parser": "3.535.0", "@smithy/node-config-provider": "^2.3.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/signature-v4": "^2.2.0", - "@smithy/smithy-client": "^2.5.0", + "@smithy/signature-v4": "^2.2.1", + "@smithy/smithy-client": "^2.5.1", "@smithy/types": "^2.12.0", "@smithy/util-config-provider": "^2.3.0", "tslib": "^2.6.2" @@ -1744,14 +2613,14 @@ } }, "node_modules/@aws-sdk/middleware-signing": { - "version": "3.535.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.535.0.tgz", - "integrity": "sha512-Rb4sfus1Gc5paRl9JJgymJGsb/i3gJKK/rTuFZICdd1PBBE5osIOHP5CpzWYBtc5LlyZE1a2QoxPMCyG+QUGPw==", + "version": "3.552.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.552.0.tgz", + "integrity": "sha512-ZjOrlEmwjhbmkINa4Zx9LJh+xb/kgEiUrcfud2kq/r8ath1Nv1/4zalI9jHnou1J+R+yS+FQlXLXHSZ7vqyFbA==", "dependencies": { "@aws-sdk/types": "3.535.0", "@smithy/property-provider": "^2.2.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/signature-v4": "^2.2.0", + "@smithy/signature-v4": "^2.2.1", "@smithy/types": "^2.12.0", "@smithy/util-middleware": "^2.2.0", "tslib": "^2.6.2" @@ -1805,14 +2674,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.535.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.535.0.tgz", - "integrity": "sha512-tqCsEsEj8icW0SAh3NvyhRUq54Gz2pu4NM2tOSrFp7SO55heUUaRLSzYteNZCTOupH//AAaZvbN/UUTO/DrOog==", + "version": "3.552.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.552.0.tgz", + "integrity": "sha512-cC11/5ahp+LaBCq7cR+51AM2ftf6m9diRd2oWkbEpjSiEKQzZRAltUPZAJM6NXGypmDODQDJphLGt45tvS+8kg==", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.535.0", + "@aws-sdk/middleware-sdk-s3": "3.552.0", "@aws-sdk/types": "3.535.0", "@smithy/protocol-http": "^3.3.0", - "@smithy/signature-v4": "^2.2.0", + "@smithy/signature-v4": "^2.2.1", "@smithy/types": "^2.12.0", "tslib": "^2.6.2" }, @@ -1821,11 +2690,11 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.549.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.549.0.tgz", - "integrity": "sha512-rJyeXkXknLukRFGuMQOgKnPBa+kLODJtOqEBf929SpQ96f1I6ytdndmWbB5B/OQN5Fu5DOOQUQqJypDQVl5ibQ==", + "version": "3.554.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.554.0.tgz", + "integrity": "sha512-KMMQ5Cw0FUPL9H8g69Lp08xtzRo7r/MK+lBV6LznWBbCP/NwtZ8awVHaPy2P31z00cWtu9MYkUTviWPqJTaBvg==", "dependencies": { - "@aws-sdk/client-sso-oidc": "3.549.0", + "@aws-sdk/client-sso-oidc": "3.554.0", "@aws-sdk/types": "3.535.0", "@smithy/property-provider": "^2.2.0", "@smithy/shared-ini-file-loader": "^2.4.0", @@ -4122,9 +4991,9 @@ "peer": true }, "node_modules/@codemirror/view": { - "version": "6.26.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.26.1.tgz", - "integrity": "sha512-wLw0t3R9AwOSQThdZ5Onw8QQtem5asE7+bPlnzc57eubPqiuJKIzwjMZ+C42vQett+iva+J8VgFV4RYWDBh5FA==", + "version": "6.26.3", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.26.3.tgz", + "integrity": "sha512-gmqxkPALZjkgSxIeeweY/wGQXBfwTUaLs8h7OKtSwfbj9Ct3L11lD+u1sS7XHppxFQoMDiMDp07P9f3I2jWOHw==", "dev": true, "peer": true, "dependencies": { @@ -4166,9 +5035,9 @@ } }, "node_modules/@cyclonedx/cyclonedx-library": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-library/-/cyclonedx-library-6.4.2.tgz", - "integrity": "sha512-5nnjdPARe+CiiIsdk4M3plO7QccBl1JAkWjciPi4n2PZFiF5dXgyFdTTSRwRFA2xIb1LYMZotc0Gz0PKBPv+bA==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-library/-/cyclonedx-library-6.5.0.tgz", + "integrity": "sha512-mPlYdNdlYbDLqlwa8xIVLhVeJjNaW3u3rGCjyKxOg+UUIaskuRnOuNkEE7csSK8QavgC/0Px1lihz9OrpWhGQg==", "dev": true, "funding": [ { @@ -4231,7 +5100,6 @@ "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true, "engines": { "node": ">=10.0.0" } @@ -4916,15 +5784,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/@docusaurus/core/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/@docusaurus/cssnano-preset": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.2.1.tgz", @@ -6319,15 +7178,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@docusaurus/utils/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -7232,6 +8082,14 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@expo/cli/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/cli/node_modules/cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", @@ -7688,6 +8546,30 @@ "node": ">=8" } }, + "node_modules/@expo/cli/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/cli/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/@expo/cli/node_modules/tempy": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.7.1.tgz", @@ -8064,14 +8946,14 @@ } }, "node_modules/@expo/env": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-0.2.2.tgz", - "integrity": "sha512-m9nGuaSpzdvMzevQ1H60FWgf4PG5s4J0dfKUzdAGnDu7sMUerY/yUeDaA4+OBo3vBwGVQ+UHcQS9vPSMBNaPcg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-0.2.3.tgz", + "integrity": "sha512-a+uJ/e6MAVxPVVN/HbXU5qxzdqrqDwNQYxCfxtAufgmd5VZj54e5f3TJA3LEEUW3pTSZR8xK0H0EtVN297AZnw==", "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", - "dotenv": "~16.0.3", - "dotenv-expand": "~10.0.0", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", "getenv": "^1.0.0" } }, @@ -8104,12 +8986,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@expo/env/node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "node_modules/@expo/env/node_modules/dotenv-expand": { + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.6.tgz", + "integrity": "sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==", + "dependencies": { + "dotenv": "^16.4.4" + }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/@expo/env/node_modules/supports-color": { @@ -9176,9 +10064,9 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.26.11", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.11.tgz", - "integrity": "sha512-fo01Cu+jzLDVG/AYAV2OtV6flhXvxP5rDaR1Fk8WWhtsFqwk478Dr2HGtB8s0HqQCsFWVbdHYpPjMiQiR/A9VA==", + "version": "0.26.12", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.12.tgz", + "integrity": "sha512-D09o62HrWdIkstF2kGekIKAC0/N/Dl6wo3CQsnLcOmO3LkW6Ik8uIb3kw8JYkwxNCcg+uJ2bpWUiIijTBep05w==", "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@floating-ui/utils": "^0.2.0", @@ -9221,25 +10109,6 @@ "unicode-trie": "^2.0.0" } }, - "node_modules/@foliojs-fork/fontkit/node_modules/deep-equal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", - "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", - "dependencies": { - "is-arguments": "^1.1.1", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.5.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@foliojs-fork/linebreak": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@foliojs-fork/linebreak/-/linebreak-1.1.2.tgz", @@ -9501,9 +10370,9 @@ } }, "node_modules/@headlessui/react": { - "version": "1.7.18", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.18.tgz", - "integrity": "sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==", + "version": "1.7.19", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", + "integrity": "sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==", "dev": true, "dependencies": { "@tanstack/react-virtual": "^3.0.0-beta.60", @@ -9568,7 +10437,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -9581,6 +10449,17 @@ "node": ">=12" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.0.tgz", + "integrity": "sha512-S00nN1Qt3z3dSP6Db45fj/mksrAq5XWNIJ/SWXGP8XPT2jrzEuYRCSEx08JpJwBcG2F1xgiOtBMGDU0AZHmxew==", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -10456,9 +11335,9 @@ } }, "node_modules/@lhncbc/ucum-lhc": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@lhncbc/ucum-lhc/-/ucum-lhc-5.0.3.tgz", - "integrity": "sha512-FlWyCOE6+Oc73zwRiFaiNSYQD8xpMYe9f4Qzy/tvnM3j5tXUwM3U5W/aXh/znJmHZr+lu3Hx697Sefp/3efOog==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@lhncbc/ucum-lhc/-/ucum-lhc-5.0.4.tgz", + "integrity": "sha512-khuV9GV51DF80b0wJmhZTR5Bf23fhS6SSIWnyGT9X+Uvn0FsHFl2LKViQ2TTOuvwagUOUSq8/0SyoE2ZDGwrAA==", "dev": true, "dependencies": { "coffeescript": "^2.7.0", @@ -10474,9 +11353,9 @@ } }, "node_modules/@mantine/core": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.7.1.tgz", - "integrity": "sha512-SdPzjvqvEK7uHFuVD3a8w3OZyQVoCwIXLSUfOtRNouDMQgsq6Ac7QjKXBBOk3wNweOWFVOU1vATLHobSmow0lQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.8.0.tgz", + "integrity": "sha512-19RKuNdJ/s8pZjy2w2rvTsl4ybi/XM6vf+Kc0WY7kpLFCvdG+/UxNi1MuJF8t2Zs0QSFeb/H5yZQNe0XPbegHw==", "dependencies": { "@floating-ui/react": "^0.26.9", "clsx": "2.1.0", @@ -10486,53 +11365,53 @@ "type-fest": "^4.12.0" }, "peerDependencies": { - "@mantine/hooks": "7.7.1", + "@mantine/hooks": "7.8.0", "react": "^18.2.0", "react-dom": "^18.2.0" } }, "node_modules/@mantine/dropzone": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-7.7.1.tgz", - "integrity": "sha512-B5DmWGX0F7yDem+Y+fQy4ZIBy/DkTd9TmlqPhCy8MYdC33I3GSv9o/MAnqbeG0tEnS0X1Aw6h5mnqnsVFYpf2w==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-7.8.0.tgz", + "integrity": "sha512-rpNTR3NASvI3BnqhY5wg3BDhxkABT9UoZEGRrOGnS3YU7SYXg5rT9ch5Cm4iPwMNdwsyAIU6K2ii4wWk40dRpg==", "dev": true, "dependencies": { "react-dropzone-esm": "15.0.1" }, "peerDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", "react": "^18.2.0", "react-dom": "^18.2.0" } }, "node_modules/@mantine/hooks": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.7.1.tgz", - "integrity": "sha512-3YH2FzKMlg840tb04PBDcDXyBCi9puFOxEBVgc6Y/pN6KFqfOoAnQE/YvgOtwSNXZlbTWyDlQoYj+3je7pA7og==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.8.0.tgz", + "integrity": "sha512-+70fkgjhVJeJ+nJqnburIM3UAsfvxat1Low9HMPobLbv64FIdB4Nzu5ct3qojNQ58r5sK01tg5UoFIJYslaVrg==", "peerDependencies": { "react": "^18.2.0" } }, "node_modules/@mantine/notifications": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.7.1.tgz", - "integrity": "sha512-UGs3r4CU2hy1Vt0TVtdorDufpI7LWCd4P7qZP0US+mXVeeZqHkNTCiwRTwlledhfaIdqERmmQn9OD2lJu8Wblg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.8.0.tgz", + "integrity": "sha512-O7BnaCcwVg38fh+gSZ6GEsTFPPgJAiOTrRkOMXG+7pNqJT9YNa9KDZhiPZzn3WV4wexncjyK32a8gGSVtf+kdg==", "dependencies": { - "@mantine/store": "7.7.1", + "@mantine/store": "7.8.0", "react-transition-group": "4.4.5" }, "peerDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", "react": "^18.2.0", "react-dom": "^18.2.0" } }, "node_modules/@mantine/store": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.7.1.tgz", - "integrity": "sha512-dmuCOLCFlVHYhZARFsi5YckFQR2Vr4giOgs0X1hczqCVUnRDRIgRusAO5GjUhQrtNxfN0EWwFywjLdcrLkA6Lg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.8.0.tgz", + "integrity": "sha512-oN/BXGYdUywRi0zj9ppaShv2sw5QON2DaRisB4ewJ5tDDz8qyeckgdE0NMaaU2TwpoScs8ibSnOVWV5y+vYkMA==", "peerDependencies": { "react": "^18.2.0" } @@ -10558,6 +11437,42 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10605,6 +11520,29 @@ "node": "*" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -10637,6 +11575,24 @@ "node": ">=10" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -10805,16 +11761,16 @@ "link": true }, "node_modules/@microsoft/api-documenter": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.24.1.tgz", - "integrity": "sha512-DX332aznb5SWpOLGuymvzg2OZsQ5+dCbSm8yvVYqTylDgSAiPouvKrdlWPoEeicuLU8wzxSl3xv7DMb6xgYwPw==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.24.2.tgz", + "integrity": "sha512-q03DXLBj7nzAzLyLRAVklBynqKgSFI/JBmrhF/mEEIpg8orNo4qKXWO1RSkD2IYrqvZV63b13mcUPYgcFdifQA==", "dev": true, "dependencies": { - "@microsoft/api-extractor-model": "7.28.13", + "@microsoft/api-extractor-model": "7.28.14", "@microsoft/tsdoc": "0.14.2", - "@rushstack/node-core-library": "4.0.2", - "@rushstack/terminal": "0.10.0", - "@rushstack/ts-command-line": "4.19.1", + "@rushstack/node-core-library": "4.1.0", + "@rushstack/terminal": "0.10.1", + "@rushstack/ts-command-line": "4.19.2", "js-yaml": "~3.13.1", "resolve": "~1.22.1" }, @@ -10823,18 +11779,18 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.43.0", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.43.0.tgz", - "integrity": "sha512-GFhTcJpB+MI6FhvXEI9b2K0snulNLWHqC/BbcJtyNYcKUiw7l3Lgis5ApsYncJ0leALX7/of4XfmXk+maT111w==", + "version": "7.43.1", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.43.1.tgz", + "integrity": "sha512-ohg40SsvFFgzHFAtYq5wKJc8ZDyY46bphjtnSvhSSlXpPTG7GHwyyXkn48UZiUCBwr2WC7TRC1Jfwz7nreuiyQ==", "dev": true, "dependencies": { - "@microsoft/api-extractor-model": "7.28.13", + "@microsoft/api-extractor-model": "7.28.14", "@microsoft/tsdoc": "0.14.2", "@microsoft/tsdoc-config": "~0.16.1", - "@rushstack/node-core-library": "4.0.2", + "@rushstack/node-core-library": "4.1.0", "@rushstack/rig-package": "0.5.2", - "@rushstack/terminal": "0.10.0", - "@rushstack/ts-command-line": "4.19.1", + "@rushstack/terminal": "0.10.1", + "@rushstack/ts-command-line": "4.19.2", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", @@ -10847,14 +11803,14 @@ } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.28.13", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.28.13.tgz", - "integrity": "sha512-39v/JyldX4MS9uzHcdfmjjfS6cYGAoXV+io8B5a338pkHiSt+gy2eXQ0Q7cGFJ7quSa1VqqlMdlPrB6sLR/cAw==", + "version": "7.28.14", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.28.14.tgz", + "integrity": "sha512-Bery/c8A8SsKPSvA82cTTuy/+OcxZbLRmKhPkk91/AJOQzxZsShcrmHFAGeiEqSIrv1nPZ3tKq9kfMLdCHmsqg==", "dev": true, "dependencies": { "@microsoft/tsdoc": "0.14.2", "@microsoft/tsdoc-config": "~0.16.1", - "@rushstack/node-core-library": "4.0.2" + "@rushstack/node-core-library": "4.1.0" } }, "node_modules/@microsoft/api-extractor/node_modules/lru-cache": { @@ -11112,15 +12068,107 @@ "tar-fs": "^2.1.1" } }, + "node_modules/@next/bundle-analyzer": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-14.2.1.tgz", + "integrity": "sha512-Qwy3Mu/dfnu4rs2xzCy7gKZlwzZzYtiq/rjPcK/7xq3BHSyLthkHf1NAF8NNfjVTouDwo2KchisHrmAamUNWWw==", + "dependencies": { + "webpack-bundle-analyzer": "4.10.1" + } + }, + "node_modules/@next/bundle-analyzer/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@next/bundle-analyzer/node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@next/bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/bundle-analyzer/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@next/bundle-analyzer/node_modules/webpack-bundle-analyzer": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", + "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "is-plain-object": "^5.0.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/@next/bundle-analyzer/node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@next/env": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", - "integrity": "sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==" + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.1.tgz", + "integrity": "sha512-qsHJle3GU3CmVx7pUoXcghX4sRN+vINkbLdH611T8ZlsP//grzqVW87BSUgOZeSAD4q7ZdZicdwNe/20U2janA==" }, "node_modules/@next/eslint-plugin-next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.1.4.tgz", - "integrity": "sha512-n4zYNLSyCo0Ln5b7qxqQeQ34OZKXwgbdcx6kmkQbywr+0k6M3Vinft0T72R6CDAcDrne2IAgSud4uWCzFgc5HA==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.1.tgz", + "integrity": "sha512-Fp+mthEBjkn8r9qd6o4JgxKp0IDEzW0VYHD8ZC05xS5/lFNwHKuOdr2kVhWG7BQCO9L6eeepshM1Wbs2T+LgSg==", "dev": true, "dependencies": { "glob": "10.3.10" @@ -11172,19 +12220,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@next/eslint-plugin-next/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.4.tgz", - "integrity": "sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.1.tgz", + "integrity": "sha512-kGjnjcIJehEcd3rT/3NAATJQndAEELk0J9GmGMXHSC75TMnvpOhONcjNHbjtcWE5HUQnIHy5JVkatrnYm1QhVw==", "cpu": [ "arm64" ], @@ -11197,9 +12236,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.4.tgz", - "integrity": "sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.1.tgz", + "integrity": "sha512-dAdWndgdQi7BK2WSXrx4lae7mYcOYjbHJUhvOUnJjMNYrmYhxbbvJ2xElZpxNxdfA6zkqagIB9He2tQk+l16ew==", "cpu": [ "x64" ], @@ -11212,9 +12251,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.4.tgz", - "integrity": "sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.1.tgz", + "integrity": "sha512-2ZctfnyFOGvTkoD6L+DtQtO3BfFz4CapoHnyLTXkOxbZkVRgg3TQBUjTD/xKrO1QWeydeo8AWfZRg8539qNKrg==", "cpu": [ "arm64" ], @@ -11227,9 +12266,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.4.tgz", - "integrity": "sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.1.tgz", + "integrity": "sha512-jazZXctiaanemy4r+TPIpFP36t1mMwWCKMsmrTRVChRqE6putyAxZA4PDujx0SnfvZHosjdkx9xIq9BzBB5tWg==", "cpu": [ "arm64" ], @@ -11242,9 +12281,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.4.tgz", - "integrity": "sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.1.tgz", + "integrity": "sha512-VjCHWCjsAzQAAo8lkBOLEIkBZFdfW+Z18qcQ056kL4KpUYc8o59JhLDCBlhg+hINQRgzQ2UPGma2AURGOH0+Qg==", "cpu": [ "x64" ], @@ -11257,9 +12296,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.4.tgz", - "integrity": "sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.1.tgz", + "integrity": "sha512-7HZKYKvAp4nAHiHIbY04finRqjeYvkITOGOurP1aLMexIFG/1+oCnqhGogBdc4lao/lkMW1c+AkwWSzSlLasqw==", "cpu": [ "x64" ], @@ -11272,9 +12311,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.4.tgz", - "integrity": "sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.1.tgz", + "integrity": "sha512-YGHklaJ/Cj/F0Xd8jxgj2p8po4JTCi6H7Z3Yics3xJhm9CPIqtl8erlpK1CLv+HInDqEWfXilqatF8YsLxxA2Q==", "cpu": [ "arm64" ], @@ -11287,9 +12326,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.4.tgz", - "integrity": "sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.1.tgz", + "integrity": "sha512-o+ISKOlvU/L43ZhtAAfCjwIfcwuZstiHVXq/BDsZwGqQE0h/81td95MPHliWCnFoikzWcYqh+hz54ZB2FIT8RA==", "cpu": [ "ia32" ], @@ -11302,9 +12341,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.4.tgz", - "integrity": "sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.1.tgz", + "integrity": "sha512-GmRoTiLcvCLifujlisknv4zu9/C4i9r0ktsA8E51EMqJL4bD4CpO7lDYr7SrUxCR0tS4RVcrqKmCak24T0ohaw==", "cpu": [ "x64" ], @@ -12135,6 +13174,14 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-connect/node_modules/@types/connect": { + "version": "3.4.36", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", + "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@opentelemetry/instrumentation-cucumber": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.5.0.tgz", @@ -13174,7 +14221,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "optional": true, "engines": { "node": ">=14" @@ -13224,8 +14270,7 @@ "node_modules/@polka/url": { "version": "1.0.0-next.25", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", - "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", - "dev": true + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", @@ -14311,6 +15356,17 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/@react-native-community/cli-doctor/node_modules/yaml": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", + "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/@react-native-community/cli-hermes": { "version": "12.3.6", "resolved": "https://registry.npmjs.org/@react-native-community/cli-hermes/-/cli-hermes-12.3.6.tgz", @@ -15737,9 +16793,9 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.1.tgz", - "integrity": "sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", + "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", "cpu": [ "arm" ], @@ -15750,9 +16806,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.1.tgz", - "integrity": "sha512-Y/9OHLjzkunF+KGEoJr3heiD5X9OLa8sbT1lm0NYeKyaM3oMhhQFvPB0bNZYJwlq93j8Z6wSxh9+cyKQaxS7PQ==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", + "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", "cpu": [ "arm64" ], @@ -15763,9 +16819,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.1.tgz", - "integrity": "sha512-+kecg3FY84WadgcuSVm6llrABOdQAEbNdnpi5X3UwWiFVhZIZvKgGrF7kmLguvxHNQy+UuRV66cLVl3S+Rkt+Q==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", + "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", "cpu": [ "arm64" ], @@ -15776,9 +16832,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.1.tgz", - "integrity": "sha512-2pYRzEjVqq2TB/UNv47BV/8vQiXkFGVmPFwJb+1E0IFFZbIX8/jo1olxqqMbo6xCXf8kabANhp5bzCij2tFLUA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", + "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", "cpu": [ "x64" ], @@ -15789,9 +16845,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.1.tgz", - "integrity": "sha512-mS6wQ6Do6/wmrF9aTFVpIJ3/IDXhg1EZcQFYHZLHqw6AzMBjTHWnCG35HxSqUNphh0EHqSM6wRTT8HsL1C0x5g==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", + "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", + "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", "cpu": [ "arm" ], @@ -15802,9 +16871,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.1.tgz", - "integrity": "sha512-p9rGKYkHdFMzhckOTFubfxgyIO1vw//7IIjBBRVzyZebWlzRLeNhqxuSaZ7kCEKVkm/kuC9fVRW9HkC/zNRG2w==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", + "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", "cpu": [ "arm64" ], @@ -15815,9 +16884,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.1.tgz", - "integrity": "sha512-nDY6Yz5xS/Y4M2i9JLQd3Rofh5OR8Bn8qe3Mv/qCVpHFlwtZSBYSPaU4mrGazWkXrdQ98GB//H0BirGR/SKFSw==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", + "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", "cpu": [ "arm64" ], @@ -15828,11 +16897,11 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.1.tgz", - "integrity": "sha512-im7HE4VBL+aDswvcmfx88Mp1soqL9OBsdDBU8NqDEYtkri0qV0THhQsvZtZeNNlLeCUQ16PZyv7cqutjDF35qw==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", + "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", "cpu": [ - "ppc64le" + "ppc64" ], "dev": true, "optional": true, @@ -15841,9 +16910,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.1.tgz", - "integrity": "sha512-RWdiHuAxWmzPJgaHJdpvUUlDz8sdQz4P2uv367T2JocdDa98iRw2UjIJ4QxSyt077mXZT2X6pKfT2iYtVEvOFw==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", + "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", "cpu": [ "riscv64" ], @@ -15854,9 +16923,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.1.tgz", - "integrity": "sha512-VMgaGQ5zRX6ZqV/fas65/sUGc9cPmsntq2FiGmayW9KMNfWVG/j0BAqImvU4KTeOOgYSf1F+k6at1UfNONuNjA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", + "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", "cpu": [ "s390x" ], @@ -15867,9 +16936,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.1.tgz", - "integrity": "sha512-9Q7DGjZN+hTdJomaQ3Iub4m6VPu1r94bmK2z3UeWP3dGUecRC54tmVu9vKHTm1bOt3ASoYtEz6JSRLFzrysKlA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", + "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", "cpu": [ "x64" ], @@ -15880,9 +16949,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.1.tgz", - "integrity": "sha512-JNEG/Ti55413SsreTguSx0LOVKX902OfXIKVg+TCXO6Gjans/k9O6ww9q3oLGjNDaTLxM+IHFMeXy/0RXL5R/g==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", + "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", "cpu": [ "x64" ], @@ -15893,9 +16962,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.1.tgz", - "integrity": "sha512-ryS22I9y0mumlLNwDFYZRDFLwWh3aKaC72CWjFcFvxK0U6v/mOkM5Up1bTbCRAhv3kEIwW2ajROegCIQViUCeA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", + "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", "cpu": [ "arm64" ], @@ -15906,9 +16975,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.1.tgz", - "integrity": "sha512-TdloItiGk+T0mTxKx7Hp279xy30LspMso+GzQvV2maYePMAWdmrzqSNZhUpPj3CGw12aGj57I026PgLCTu8CGg==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", + "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", "cpu": [ "ia32" ], @@ -15919,9 +16988,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.1.tgz", - "integrity": "sha512-wQGI+LY/Py20zdUPq+XCem7JcPOyzIJBm3dli+56DJsQOHbnXZFEwgmnC6el1TPAfC8lBT3m+z69RmLykNUbew==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", + "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", "cpu": [ "x64" ], @@ -15932,15 +17001,15 @@ ] }, "node_modules/@rushstack/eslint-patch": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.1.tgz", - "integrity": "sha512-S3Kq8e7LqxkA9s7HKLqXGTGck1uwis5vAXan3FnU5yw1Ec5hsSGnq4s/UCaSqABPOnOTg7zASLyst7+ohgWexg==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.2.tgz", + "integrity": "sha512-hw437iINopmQuxWPSUEvqE56NCPsiU8N4AYtfHmJFckclktzK9YQJieD3XkDCDH4OjL+C7zgPUh73R/nrcHrqw==", "dev": true }, "node_modules/@rushstack/node-core-library": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-4.0.2.tgz", - "integrity": "sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-4.1.0.tgz", + "integrity": "sha512-qz4JFBZJCf1YN5cAXa1dP6Mki/HrsQxc/oYGAGx29dF2cwF2YMxHoly0FBhMw3IEnxo5fMj0boVfoHVBkpkx/w==", "dev": true, "dependencies": { "fs-extra": "~7.0.1", @@ -16003,12 +17072,12 @@ } }, "node_modules/@rushstack/terminal": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.10.0.tgz", - "integrity": "sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.10.1.tgz", + "integrity": "sha512-C6Vi/m/84IYJTkfzmXr1+W8Wi3MmBjVF/q3za91Gb3VYjKbpALHVxY6FgH625AnDe5Z0Kh4MHKWA3Z7bqgAezA==", "dev": true, "dependencies": { - "@rushstack/node-core-library": "4.0.2", + "@rushstack/node-core-library": "4.1.0", "supports-color": "~8.1.1" }, "peerDependencies": { @@ -16021,12 +17090,12 @@ } }, "node_modules/@rushstack/ts-command-line": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.19.1.tgz", - "integrity": "sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.19.2.tgz", + "integrity": "sha512-cqmXXmBEBlzo9WtyUrHtF9e6kl0LvBY7aTSVX4jfnBfXWZQWnPq9JTFPlQZ+L/ZwjZ4HrNwQsOVvhe9oOucZkw==", "dev": true, "dependencies": { - "@rushstack/terminal": "0.10.0", + "@rushstack/terminal": "0.10.1", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" @@ -16054,6 +17123,195 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-12.0.1.tgz", + "integrity": "sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg==", + "hasInstallScript": true, + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "11.0.0", + "debug": "4.3.4", + "node-addon-api": "7.0.0", + "node-gyp-build": "4.6.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-11.0.0.tgz", + "integrity": "sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-11.0.0.tgz", + "integrity": "sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA==", + "dependencies": { + "@serialport/parser-delimiter": "11.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-12.0.0.tgz", + "integrity": "sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-12.0.0.tgz", + "integrity": "sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-12.0.0.tgz", + "integrity": "sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-12.0.0.tgz", + "integrity": "sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ==", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-12.0.0.tgz", + "integrity": "sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-12.0.0.tgz", + "integrity": "sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-12.0.0.tgz", + "integrity": "sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-12.0.0.tgz", + "integrity": "sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-12.0.0.tgz", + "integrity": "sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.3.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -16581,9 +17839,9 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.2.1.tgz", - "integrity": "sha512-j5fHgL1iqKTsKJ1mTcw88p0RUcidDu95AWSeZTgiYJb+QcfwWU/UpBnaqiB59FNH5MiAZuSbOBnZlwzeeY2tIw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.3.0.tgz", + "integrity": "sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "@smithy/types": "^2.12.0", @@ -16825,12 +18083,12 @@ } }, "node_modules/@storybook/addon-actions": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.0.6.tgz", - "integrity": "sha512-3R/d2Td6+yeR+UnyCAeZ4tuiRGSm+6gKUQP9vB1bvEFQGuFBrV+zs3eakcYegOqZu3IXuejgaB0Knq987gUL5A==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.0.8.tgz", + "integrity": "sha512-F3qpN0n53d058EroW1A2IlzrsFNR5p2srLY4FmXB80nxAKV8oqoDI4jp15zYlf8ThcJoQl36plT8gx3r1BpANA==", "dev": true, "dependencies": { - "@storybook/core-events": "8.0.6", + "@storybook/core-events": "8.0.8", "@storybook/global": "^5.0.0", "@types/uuid": "^9.0.1", "dequal": "^2.0.2", @@ -16843,9 +18101,9 @@ } }, "node_modules/@storybook/addon-backgrounds": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.0.6.tgz", - "integrity": "sha512-NRTmSsJiqpXqJMVrRuQ+P1wt26ZCLjBNaMafcjgicfWeyUsdhNF63yYvyrHkMRuNmYPZm0hKvtjLhW3s9VohSA==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.0.8.tgz", + "integrity": "sha512-lrAJjVxDeXSK116rDajb56TureZiT76ygraP22/IvU3IcWCEcRiKYwlay8WgCTbJHtFmdBpelLBapoT46+IR9Q==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -16858,12 +18116,12 @@ } }, "node_modules/@storybook/addon-controls": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.0.6.tgz", - "integrity": "sha512-bNXDhi1xl7eat1dUsKTrUgu5mkwXjfFWDjIYxrzatqDOW1+rdkNaPFduQRJ2mpCs4cYcHKAr5chEcMm6byuTnA==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.0.8.tgz", + "integrity": "sha512-7xANN18CLYsVthuSXwxKezqpelEKJlT9xaYLtw5vvD00btW5g3vxq+Z/A31OkS2OuaH2bE0GfRCoG2OLR8yQQA==", "dev": true, "dependencies": { - "@storybook/blocks": "8.0.6", + "@storybook/blocks": "8.0.8", "lodash": "^4.17.21", "ts-dedent": "^2.0.0" }, @@ -16873,24 +18131,24 @@ } }, "node_modules/@storybook/addon-docs": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.0.6.tgz", - "integrity": "sha512-QOlOE2XEFcUaR85YytBuf/nfKFkbIlD0Qc9CI4E65FoZPTCMhRVKAEN2CpsKI63fs/qQxM2mWkPXb6w7QXGxvg==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.0.8.tgz", + "integrity": "sha512-HNiY4ESH9WxGS6QpIpURzdSbyDxbRh7VIgbvUrePSKajlsL4RFN/gdnn5TnSL00tOP/w+Cy/fXcbljMUKy7Ivg==", "dev": true, "dependencies": { "@babel/core": "^7.12.3", "@mdx-js/react": "^3.0.0", - "@storybook/blocks": "8.0.6", - "@storybook/client-logger": "8.0.6", - "@storybook/components": "8.0.6", - "@storybook/csf-plugin": "8.0.6", - "@storybook/csf-tools": "8.0.6", + "@storybook/blocks": "8.0.8", + "@storybook/client-logger": "8.0.8", + "@storybook/components": "8.0.8", + "@storybook/csf-plugin": "8.0.8", + "@storybook/csf-tools": "8.0.8", "@storybook/global": "^5.0.0", - "@storybook/node-logger": "8.0.6", - "@storybook/preview-api": "8.0.6", - "@storybook/react-dom-shim": "8.0.6", - "@storybook/theming": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/node-logger": "8.0.8", + "@storybook/preview-api": "8.0.8", + "@storybook/react-dom-shim": "8.0.8", + "@storybook/theming": "8.0.8", + "@storybook/types": "8.0.8", "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "fs-extra": "^11.1.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", @@ -16940,24 +18198,24 @@ } }, "node_modules/@storybook/addon-essentials": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.0.6.tgz", - "integrity": "sha512-L9SSsdN1EG2FZ1mNT59vwf0fpseLrzO1cWPwH6hVtp0+kci3tfropch2tEwO7Vr+YLSesJihfr4uvpI/l0jCsw==", - "dev": true, - "dependencies": { - "@storybook/addon-actions": "8.0.6", - "@storybook/addon-backgrounds": "8.0.6", - "@storybook/addon-controls": "8.0.6", - "@storybook/addon-docs": "8.0.6", - "@storybook/addon-highlight": "8.0.6", - "@storybook/addon-measure": "8.0.6", - "@storybook/addon-outline": "8.0.6", - "@storybook/addon-toolbars": "8.0.6", - "@storybook/addon-viewport": "8.0.6", - "@storybook/core-common": "8.0.6", - "@storybook/manager-api": "8.0.6", - "@storybook/node-logger": "8.0.6", - "@storybook/preview-api": "8.0.6", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.0.8.tgz", + "integrity": "sha512-bc9KJk7SPM2I5CCJEAP8R5leP+74IYxhWPiTN8Y1YFmf3MA1lpDJbwy+RfuRZ2ZKnSKszCXCVzU/T10HKUHLZw==", + "dev": true, + "dependencies": { + "@storybook/addon-actions": "8.0.8", + "@storybook/addon-backgrounds": "8.0.8", + "@storybook/addon-controls": "8.0.8", + "@storybook/addon-docs": "8.0.8", + "@storybook/addon-highlight": "8.0.8", + "@storybook/addon-measure": "8.0.8", + "@storybook/addon-outline": "8.0.8", + "@storybook/addon-toolbars": "8.0.8", + "@storybook/addon-viewport": "8.0.8", + "@storybook/core-common": "8.0.8", + "@storybook/manager-api": "8.0.8", + "@storybook/node-logger": "8.0.8", + "@storybook/preview-api": "8.0.8", "ts-dedent": "^2.0.0" }, "funding": { @@ -16966,9 +18224,9 @@ } }, "node_modules/@storybook/addon-highlight": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.0.6.tgz", - "integrity": "sha512-CxXzzgIK5sXy2RNIkwU5JXZNq+PNGhUptRm/5M5ylcB7rk0pdwnE0TLXsMU+lzD0ji+cj61LWVLdeXQa+/whSw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.0.8.tgz", + "integrity": "sha512-KKD7xiNhxZQM4fdDidtcla6jSzgN1f9qe1AwFSHLXwIW22+4c97Vgf+AookN7cJvB77HxRUnvQH//zV1CJEDug==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0" @@ -16979,9 +18237,9 @@ } }, "node_modules/@storybook/addon-links": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.0.6.tgz", - "integrity": "sha512-1UBNhQdwm17fXmuUKIsgvT6YenMbaGIYdr/9ApKmIMTKKO+emQ7APlsTbvasutcOkCd57rC1KZRfAHQpgU9wDQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.0.8.tgz", + "integrity": "sha512-iRI/W9I6fOom5zfZvsu53gfJtuhBSMmhgI/u5uZbAbfEoNL5D1PqpDXD4ygM8Vvlx90AZNZ2W5slEe7gCZOMyA==", "dev": true, "dependencies": { "@storybook/csf": "^0.1.2", @@ -17002,9 +18260,9 @@ } }, "node_modules/@storybook/addon-measure": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.0.6.tgz", - "integrity": "sha512-2PnytDaQzCxcgykEM5Njb71Olm+Z2EFERL5X+5RhsG2EQxEqobwh1fUtXLY4aqiImdSJOrjQnkMJchzzoTRtug==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.0.8.tgz", + "integrity": "sha512-akyoa+1F2ripV6ELF2UbxiSHv791LWSAVK7gsD/a5eJfKZMm5yoHjcY7Icdkc/ctE+pyjAQNhkXTixUngge09w==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -17016,9 +18274,9 @@ } }, "node_modules/@storybook/addon-outline": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.0.6.tgz", - "integrity": "sha512-PfTIy64kV5h7F0tXrj5rlwdPFpOQiGrn01AQudSJDVWaMsbVgjruPU+cHG4i/L1mzzERzeHYd46bNENWZiQgDw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.0.8.tgz", + "integrity": "sha512-8Gxs095ekpa5YZolLSs5cWbWK94GZTevEUX8GFeLGIz9sf1KO3kmEO3eC5ogzDoB0cloqvbmVAJvYJ3FWiUx8w==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -17030,12 +18288,12 @@ } }, "node_modules/@storybook/addon-storysource": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-8.0.6.tgz", - "integrity": "sha512-rglSDmwb5RdNSfZCGL9qYAYn5ycrGPA0zzTY4Do8D7HluSloBWRDb1UTLszPRfR7k/wY+MYkBx3ZYTrTHuvXXQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-8.0.8.tgz", + "integrity": "sha512-mhnT+Cd12DZIQvqK1O9NHeCJ9a8j/tJjKw/jxOQ222HfZBNS1UjhqSTEYkXSx3HKs1qiBSz8423+pzEybuRTGQ==", "dev": true, "dependencies": { - "@storybook/source-loader": "8.0.6", + "@storybook/source-loader": "8.0.8", "estraverse": "^5.2.0", "tiny-invariant": "^1.3.1" }, @@ -17045,9 +18303,9 @@ } }, "node_modules/@storybook/addon-toolbars": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.0.6.tgz", - "integrity": "sha512-g4GjrMEHKOIQVwG1DKUHBAn4B8xmdqlxFlVusOrYD9FVfakgMNllN6WBc02hg/IiuzqIDxVK5BXiY9MbXnoguQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.0.8.tgz", + "integrity": "sha512-PZxlK+/Fwk2xcrpr5kkXYjCbBaEjAWcEHWq7mhQReMFaAs5AJE8dvmeQ7rmPDOHnlg4+YsARDFKz5FJtthRIgg==", "dev": true, "funding": { "type": "opencollective", @@ -17055,9 +18313,9 @@ } }, "node_modules/@storybook/addon-viewport": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.0.6.tgz", - "integrity": "sha512-R6aGEPA5e05L/NPs6Nbj0u9L6oKmchnJ/x8Rr/Xuc+nqVgXC1rslI0BcjJuC571Bewz7mT8zJ+BjP/gs7T4lnQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.0.8.tgz", + "integrity": "sha512-nOuc6DquGvm24c/A0HFTgeEN/opd58ebs1KLaEEq1f6iYV0hT2Gpnk0Usg/seOiFtJnj3NyAM46HSkZz06T8Sw==", "dev": true, "dependencies": { "memoizerific": "^1.11.3" @@ -17068,23 +18326,23 @@ } }, "node_modules/@storybook/blocks": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.0.6.tgz", - "integrity": "sha512-ycuPJwxyngSor4YNa4kkX3rAmX+w2pXNsIo+Zs4fEdAfCvha9+GZ/3jQSdrsHxjeIm9l9guiv4Ag8QTnnllXkw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.0.8.tgz", + "integrity": "sha512-kwsjhvnmFEaIl51QHJt/83G7mZ5YbzFKnWCwy8WUpi0xvVcyoFQSGGgwR3XRrzGfUEPK8P2FDHeKw1bLzyIejA==", "dev": true, "dependencies": { - "@storybook/channels": "8.0.6", - "@storybook/client-logger": "8.0.6", - "@storybook/components": "8.0.6", - "@storybook/core-events": "8.0.6", + "@storybook/channels": "8.0.8", + "@storybook/client-logger": "8.0.8", + "@storybook/components": "8.0.8", + "@storybook/core-events": "8.0.8", "@storybook/csf": "^0.1.2", - "@storybook/docs-tools": "8.0.6", + "@storybook/docs-tools": "8.0.8", "@storybook/global": "^5.0.0", "@storybook/icons": "^1.2.5", - "@storybook/manager-api": "8.0.6", - "@storybook/preview-api": "8.0.6", - "@storybook/theming": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/manager-api": "8.0.8", + "@storybook/preview-api": "8.0.8", + "@storybook/theming": "8.0.8", + "@storybook/types": "8.0.8", "@types/lodash": "^4.14.167", "color-convert": "^2.0.1", "dequal": "^2.0.2", @@ -17116,15 +18374,15 @@ } }, "node_modules/@storybook/builder-manager": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/builder-manager/-/builder-manager-8.0.6.tgz", - "integrity": "sha512-N61Gh9FKsSYvsbdBy5qFvq1anTIuUAjh2Z+ezDMlxnfMGG77nZP9heuy1NnCaYCTFzl+lq4BsmRfXXDcKtSPRA==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/builder-manager/-/builder-manager-8.0.8.tgz", + "integrity": "sha512-0uihNTpTou0RFMM6PQLlfCxDxse9nIDEb83AmWE/OUnpKDDY9+WFupVWGaZc9HfH9h4Yqre2fiuK1b7KNYe7AQ==", "dev": true, "dependencies": { "@fal-works/esbuild-plugin-global-externals": "^2.1.2", - "@storybook/core-common": "8.0.6", - "@storybook/manager": "8.0.6", - "@storybook/node-logger": "8.0.6", + "@storybook/core-common": "8.0.8", + "@storybook/manager": "8.0.8", + "@storybook/node-logger": "8.0.8", "@types/ejs": "^3.1.1", "@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.10", "browser-assert": "^1.2.1", @@ -17177,20 +18435,20 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.0.6.tgz", - "integrity": "sha512-uQe1tTXdWXhP1ZO7sBRLUS5WKoD/ibrBWhyG6gY0RHC8RtGIx1sYxbg7ZzUXXX8z1GH0QJlOKrlAfcHzIchscw==", - "dev": true, - "dependencies": { - "@storybook/channels": "8.0.6", - "@storybook/client-logger": "8.0.6", - "@storybook/core-common": "8.0.6", - "@storybook/core-events": "8.0.6", - "@storybook/csf-plugin": "8.0.6", - "@storybook/node-logger": "8.0.6", - "@storybook/preview": "8.0.6", - "@storybook/preview-api": "8.0.6", - "@storybook/types": "8.0.6", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.0.8.tgz", + "integrity": "sha512-ibWOxoHczCc6ttMQqiSXv29m/e44sKVoc1BJluApQcjCXl9g6QXyN45zV70odjCxMfNy7EQgUjCA0mgAgMHSIw==", + "dev": true, + "dependencies": { + "@storybook/channels": "8.0.8", + "@storybook/client-logger": "8.0.8", + "@storybook/core-common": "8.0.8", + "@storybook/core-events": "8.0.8", + "@storybook/csf-plugin": "8.0.8", + "@storybook/node-logger": "8.0.8", + "@storybook/preview": "8.0.8", + "@storybook/preview-api": "8.0.8", + "@storybook/types": "8.0.8", "@types/find-cache-dir": "^3.2.1", "browser-assert": "^1.2.1", "es-module-lexer": "^0.9.3", @@ -17258,13 +18516,13 @@ } }, "node_modules/@storybook/channels": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-8.0.6.tgz", - "integrity": "sha512-IbNvjxeyQKiMpb+gSpQ7yYsFqb8BM/KYgfypJM3yJV6iU/NFeevrC/DA6/R+8xWFyPc70unRNLv8fPvxhcIu8Q==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-8.0.8.tgz", + "integrity": "sha512-L3EGVkabv3fweXnykD/GlNUDO5HtwlIfSovC7BF4MmP7662j2/eqlZrJxDojGtbv11XHjWp/UJHUIfKpcHXYjQ==", "dev": true, "dependencies": { - "@storybook/client-logger": "8.0.6", - "@storybook/core-events": "8.0.6", + "@storybook/client-logger": "8.0.8", + "@storybook/core-events": "8.0.8", "@storybook/global": "^5.0.0", "telejson": "^7.2.0", "tiny-invariant": "^1.3.1" @@ -17275,22 +18533,22 @@ } }, "node_modules/@storybook/cli": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-8.0.6.tgz", - "integrity": "sha512-gAnl9soQUu1BtB4sANaqaaeTZAt/ThBSwCdzSLut5p21fP4ovi3FeP7hcDCJbyJZ/AvnD4k6leDrqRQxMVPr0A==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-8.0.8.tgz", + "integrity": "sha512-RnSdgykh2i7es1rQ7CNGpDrKK/PN1f0xjwpkAHXCEB6T9KpHBmqDquzZp+N127a1HBHHXy018yi4wT8mSQyEoA==", "dev": true, "dependencies": { "@babel/core": "^7.23.0", "@babel/types": "^7.23.0", "@ndelangen/get-tarball": "^3.0.7", - "@storybook/codemod": "8.0.6", - "@storybook/core-common": "8.0.6", - "@storybook/core-events": "8.0.6", - "@storybook/core-server": "8.0.6", - "@storybook/csf-tools": "8.0.6", - "@storybook/node-logger": "8.0.6", - "@storybook/telemetry": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/codemod": "8.0.8", + "@storybook/core-common": "8.0.8", + "@storybook/core-events": "8.0.8", + "@storybook/core-server": "8.0.8", + "@storybook/csf-tools": "8.0.8", + "@storybook/node-logger": "8.0.8", + "@storybook/telemetry": "8.0.8", + "@storybook/types": "8.0.8", "@types/semver": "^7.3.4", "@yarnpkg/fslib": "2.10.3", "@yarnpkg/libzip": "2.3.0", @@ -17456,9 +18714,9 @@ "dev": true }, "node_modules/@storybook/client-logger": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-8.0.6.tgz", - "integrity": "sha512-et/IHPHiiOwMg93l5KSgw47NZXz5xOyIrIElRcsT1wr8OJeIB9DzopB/suoHBZ/IML+t8x91atdutzUN2BLF6A==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-8.0.8.tgz", + "integrity": "sha512-a4BKwl9NLFcuRgMyI7S4SsJeLFK0LCQxIy76V6YyrE1DigoXz4nA4eQxdjLf7JVvU0EZFmNSfbVL/bXzzWKNXA==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0" @@ -17469,18 +18727,18 @@ } }, "node_modules/@storybook/codemod": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.0.6.tgz", - "integrity": "sha512-IMaTVI+EvmFxkz4leKWKForPC3LFxzfeTmd/QnTNF3nCeyvmIXvP01pQXRjro0+XcGDncEStuxa1d9ClMlac9Q==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-8.0.8.tgz", + "integrity": "sha512-ufEBLciLmLlAh+L6lGgBObTiny6odXMKqiJOewQ9XfIN0wdWdyRUf5QdZIPOdfgHhWF2Q2HeswiulsoHm8Z/hA==", "dev": true, "dependencies": { "@babel/core": "^7.23.2", "@babel/preset-env": "^7.23.2", "@babel/types": "^7.23.0", "@storybook/csf": "^0.1.2", - "@storybook/csf-tools": "8.0.6", - "@storybook/node-logger": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/csf-tools": "8.0.8", + "@storybook/node-logger": "8.0.8", + "@storybook/types": "8.0.8", "@types/cross-spawn": "^6.0.2", "cross-spawn": "^7.0.3", "globby": "^11.0.2", @@ -17496,18 +18754,18 @@ } }, "node_modules/@storybook/components": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.0.6.tgz", - "integrity": "sha512-6W2BAqAPJkrExk8D/ug2NPBPvMs05p6Bdt9tk3eWjiMrhG/CUKBzlBTEfNK/mzy3YVB6ijyT2DgsqzmWWYJ/Xw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.0.8.tgz", + "integrity": "sha512-EpBExH4kHWQJSfA8QXJJ5AsLRUGi5X/zWY7ffiYW8rtnBmEnk3T9FpmnyJlY1A8sdd3b1wQ07JGBDHfL1mdELw==", "dev": true, "dependencies": { "@radix-ui/react-slot": "^1.0.2", - "@storybook/client-logger": "8.0.6", + "@storybook/client-logger": "8.0.8", "@storybook/csf": "^0.1.2", "@storybook/global": "^5.0.0", "@storybook/icons": "^1.2.5", - "@storybook/theming": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/theming": "8.0.8", + "@storybook/types": "8.0.8", "memoizerific": "^1.11.3", "util-deprecate": "^1.0.2" }, @@ -17521,15 +18779,15 @@ } }, "node_modules/@storybook/core-common": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-8.0.6.tgz", - "integrity": "sha512-Z4cA52SjcW6SAV9hayqVm5kyr362O20Zmwz7+H2nYEhcu8bY69y5p45aaoyElMxL1GDNu84GrmTp7dY4URw1fQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-8.0.8.tgz", + "integrity": "sha512-CL15M2oeQW+Rb1l7ciunLDI2Re+ojL2lX1ZFAiDedcOU+JHsdq43zAuXoZVzp8icUi2AUSwEjZIxGCSingj+JQ==", "dev": true, "dependencies": { - "@storybook/core-events": "8.0.6", - "@storybook/csf-tools": "8.0.6", - "@storybook/node-logger": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/core-events": "8.0.8", + "@storybook/csf-tools": "8.0.8", + "@storybook/node-logger": "8.0.8", + "@storybook/types": "8.0.8", "@yarnpkg/fslib": "2.10.3", "@yarnpkg/libzip": "2.3.0", "chalk": "^4.1.0", @@ -17672,9 +18930,9 @@ "dev": true }, "node_modules/@storybook/core-events": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-8.0.6.tgz", - "integrity": "sha512-EwGmuMm8QTUAHPhab4yftQWoSCX3OzEk6cQdpLtbNFtRRLE9aPZzxhk5Z/d3KhLNSCUAGyCiDt5I9JxTBetT9A==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-8.0.8.tgz", + "integrity": "sha512-PtuvR7vS4glDEdCfKB4f1k3Vs1C3rTWP2DNbF+IjjPhNLMBznCdzTAPcz+NUIBvpjjGnhKwWikJ0yj931YjSVg==", "dev": true, "dependencies": { "ts-dedent": "^2.0.0" @@ -17685,28 +18943,28 @@ } }, "node_modules/@storybook/core-server": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-8.0.6.tgz", - "integrity": "sha512-COmcjrry8vZXDh08ZGbfDz2bFB4of5wnwOwYf8uwlVND6HnhQzV22On1s3/p8qw+dKOpjpwDdHWtMnndnPNuqQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-8.0.8.tgz", + "integrity": "sha512-tSEueEBttbSohzhZVN2bFNlFx3eoqQ7p57cjQLKXXwKygS2qKxISKnFy+Y0nj20APz68Wj51kx0rN0nGALeegw==", "dev": true, "dependencies": { "@aw-web-design/x-default-browser": "1.4.126", "@babel/core": "^7.23.9", "@discoveryjs/json-ext": "^0.5.3", - "@storybook/builder-manager": "8.0.6", - "@storybook/channels": "8.0.6", - "@storybook/core-common": "8.0.6", - "@storybook/core-events": "8.0.6", + "@storybook/builder-manager": "8.0.8", + "@storybook/channels": "8.0.8", + "@storybook/core-common": "8.0.8", + "@storybook/core-events": "8.0.8", "@storybook/csf": "^0.1.2", - "@storybook/csf-tools": "8.0.6", + "@storybook/csf-tools": "8.0.8", "@storybook/docs-mdx": "3.0.0", "@storybook/global": "^5.0.0", - "@storybook/manager": "8.0.6", - "@storybook/manager-api": "8.0.6", - "@storybook/node-logger": "8.0.6", - "@storybook/preview-api": "8.0.6", - "@storybook/telemetry": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/manager": "8.0.8", + "@storybook/manager-api": "8.0.8", + "@storybook/node-logger": "8.0.8", + "@storybook/preview-api": "8.0.8", + "@storybook/telemetry": "8.0.8", + "@storybook/types": "8.0.8", "@types/detect-port": "^1.3.0", "@types/node": "^18.0.0", "@types/pretty-hrtime": "^1.0.0", @@ -17740,9 +18998,9 @@ } }, "node_modules/@storybook/core-server/node_modules/@types/node": { - "version": "18.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.30.tgz", - "integrity": "sha512-453z1zPuJLVDbyahaa1sSD5C2sht6ZpHp5rgJNs+H8YGqhluCXcuOUmBYsAo0Tos0cHySJ3lVUGbGgLlqIkpyg==", + "version": "18.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", + "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -17877,21 +19135,21 @@ "dev": true }, "node_modules/@storybook/csf": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.3.tgz", - "integrity": "sha512-IPZvXXo4b3G+gpmgBSBqVM81jbp2ePOKsvhgJdhyZJtkYQCII7rg9KKLQhvBQM5sLaF1eU6r0iuwmyynC9d9SA==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.4.tgz", + "integrity": "sha512-B9UI/lsQMjF+oEfZCI6YXNoeuBcGZoOP5x8yKbe2tIEmsMjSztFKkpPzi5nLCnBk/MBtl6QJeI3ksJnbsWPkOw==", "dev": true, "dependencies": { "type-fest": "^2.19.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.0.6.tgz", - "integrity": "sha512-ULaAFGhdgDDbknGnCqxitzeBlSzYZJQvZT4HtFgxfNU2McOu+GLIzyUOx3xG5eoziLvvm+oW+lxLr5nDkSaBUg==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.0.8.tgz", + "integrity": "sha512-x9WspjZGcqXENj/Vn4Qmn0oTW93KN2V9wqpflWwCUJTByl2MugQsh5xRuDbs2yM7dD6zKcqRyPaTY+GFZBW+Vg==", "dev": true, "dependencies": { - "@storybook/csf-tools": "8.0.6", + "@storybook/csf-tools": "8.0.8", "unplugin": "^1.3.1" }, "funding": { @@ -17900,9 +19158,9 @@ } }, "node_modules/@storybook/csf-tools": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-8.0.6.tgz", - "integrity": "sha512-MEBVxpnzqkBPyYXdtYQrY0SQC3oflmAQdEM0qWFzPvZXTnIMk3Q2ft8JNiBht6RlrKGvKql8TodwpbOiPeJI/w==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-8.0.8.tgz", + "integrity": "sha512-Ji5fpoGym/MSyHJ6ALghVUUecwhEbN0On+jOZ2VPkrkATi9UDtryHQPdF60HKR63Iv53xRuWRzudB6zm43RTzw==", "dev": true, "dependencies": { "@babel/generator": "^7.23.0", @@ -17910,7 +19168,7 @@ "@babel/traverse": "^7.23.2", "@babel/types": "^7.23.0", "@storybook/csf": "^0.1.2", - "@storybook/types": "8.0.6", + "@storybook/types": "8.0.8", "fs-extra": "^11.1.0", "recast": "^0.23.5", "ts-dedent": "^2.0.0" @@ -17974,14 +19232,14 @@ "dev": true }, "node_modules/@storybook/docs-tools": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/docs-tools/-/docs-tools-8.0.6.tgz", - "integrity": "sha512-PsAA2b/Q1ki5IR0fa52MI+fdDkQ0W+mrZVRRj3eJzonGZYcQtXofTXQB7yi0CaX7zzI/N8JcdE4bO9sI6SrOTg==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/docs-tools/-/docs-tools-8.0.8.tgz", + "integrity": "sha512-p/MIrDshXMl/fiCRlfG9StkRYI1QlUyUSQQ/YDBFlBfWcJYARIt3TIvQyvs3Q/apnQNcDXIW663W57s7WHTO2w==", "dev": true, "dependencies": { - "@storybook/core-common": "8.0.6", - "@storybook/preview-api": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/core-common": "8.0.8", + "@storybook/preview-api": "8.0.8", + "@storybook/types": "8.0.8", "@types/doctrine": "^0.0.3", "assert": "^2.1.0", "doctrine": "^3.0.0", @@ -18012,9 +19270,9 @@ } }, "node_modules/@storybook/manager": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/manager/-/manager-8.0.6.tgz", - "integrity": "sha512-wdL3lG72qrCOLkxEUW49+hmwA4fIFXFvAEU7wVgEt4KyRRGWhHa8Dr/5Tnq54CWJrA+BTrTPHaoo/Vu4BAjgow==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/manager/-/manager-8.0.8.tgz", + "integrity": "sha512-pWYHSDmgT8p/XbQMKuDPdgB6KzjePI6dU5KQ5MERYfch1UiuGPVm1HHDlxxSfHW0IIXw9Qnwq4L0Awe4qhvJKQ==", "dev": true, "funding": { "type": "opencollective", @@ -18022,20 +19280,20 @@ } }, "node_modules/@storybook/manager-api": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.0.6.tgz", - "integrity": "sha512-khYA5CM+LY/B5VsqqUmt2ivNLNqyIKfcgGsXHkOs3Kr5BOz8LhEmSwZOB348ey2C2ejFJmvKlkcsE+rB9ixlww==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.0.8.tgz", + "integrity": "sha512-1HU4nfLRi0sD2uw229gb8EQyufNWrLvMNpg013kBsBXRd+Dj4dqF3v+KrYFNtteY7riC4mAJ6YcQ4tBUNYZDug==", "dev": true, "dependencies": { - "@storybook/channels": "8.0.6", - "@storybook/client-logger": "8.0.6", - "@storybook/core-events": "8.0.6", + "@storybook/channels": "8.0.8", + "@storybook/client-logger": "8.0.8", + "@storybook/core-events": "8.0.8", "@storybook/csf": "^0.1.2", "@storybook/global": "^5.0.0", "@storybook/icons": "^1.2.5", - "@storybook/router": "8.0.6", - "@storybook/theming": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/router": "8.0.8", + "@storybook/theming": "8.0.8", + "@storybook/types": "8.0.8", "dequal": "^2.0.2", "lodash": "^4.17.21", "memoizerific": "^1.11.3", @@ -18049,9 +19307,9 @@ } }, "node_modules/@storybook/node-logger": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-8.0.6.tgz", - "integrity": "sha512-mDRJLVAuTWauO0mnrwajfJV/6zKBJVPp/9g0ULccE3Q+cuqNfUefqfCd17cZBlJHeRsdB9jy9tod48d4qzGEkQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-8.0.8.tgz", + "integrity": "sha512-ymps3MMTxtMWq0eDiXk1iO7iv0Eg0PuUvOpPPohEJauGzU9THv81xx01aaHKSprFFJYD2LMQr1aFuUplItO12g==", "dev": true, "funding": { "type": "opencollective", @@ -18059,9 +19317,9 @@ } }, "node_modules/@storybook/preview": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/preview/-/preview-8.0.6.tgz", - "integrity": "sha512-NdVstxdUghv5goQJ4zFftyezfCEPKHOSNu8k02KU6u6g5IiK430jp5y71E/eiBK3m1AivtluC7tPRSch0HsidA==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/preview/-/preview-8.0.8.tgz", + "integrity": "sha512-J/ooKcvDV1s7ROH7lF/0vOyWDOgDB7bN6vS67J1WK0HLvMGaqUzU+q3ndakGzu0LU/jvUBqEFSZd1ALWyZINDQ==", "dev": true, "funding": { "type": "opencollective", @@ -18069,17 +19327,17 @@ } }, "node_modules/@storybook/preview-api": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.0.6.tgz", - "integrity": "sha512-O5SvBqlHIO/Cf5oGZUJV2npkp9bLqg9Sn0T0a5zXolJbRy+gP7MDyz4AnliLpTn5bT2rzVQ6VH8IDlhHBq3K6g==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.0.8.tgz", + "integrity": "sha512-khgw2mNiBrSZS3KNGQPzjneL3Csh3BOq0yLAtJpT7CRSrI/YjlE7jjcTkKzoxW+UCgvNTnLvsowcuzu82e69fA==", "dev": true, "dependencies": { - "@storybook/channels": "8.0.6", - "@storybook/client-logger": "8.0.6", - "@storybook/core-events": "8.0.6", + "@storybook/channels": "8.0.8", + "@storybook/client-logger": "8.0.8", + "@storybook/core-events": "8.0.8", "@storybook/csf": "^0.1.2", "@storybook/global": "^5.0.0", - "@storybook/types": "8.0.6", + "@storybook/types": "8.0.8", "@types/qs": "^6.9.5", "dequal": "^2.0.2", "lodash": "^4.17.21", @@ -18095,17 +19353,17 @@ } }, "node_modules/@storybook/react": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.0.6.tgz", - "integrity": "sha512-A1zivNti15nHkJ6EcVKpxKwlDkyMb5MlJMUb8chX/xBWxoR1f5R8eI484rhdPRYUzBY7JwvgZfy4y/murqg6hA==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.0.8.tgz", + "integrity": "sha512-pPTlQntl09kv7qkAFYsxUq6qCLeeZC/K3yGFBGMy2Dc+PFjBYdT6mt2I8GB3twK0Cq5gJESlLj48QnYLQ/9PbA==", "dev": true, "dependencies": { - "@storybook/client-logger": "8.0.6", - "@storybook/docs-tools": "8.0.6", + "@storybook/client-logger": "8.0.8", + "@storybook/docs-tools": "8.0.8", "@storybook/global": "^5.0.0", - "@storybook/preview-api": "8.0.6", - "@storybook/react-dom-shim": "8.0.6", - "@storybook/types": "8.0.6", + "@storybook/preview-api": "8.0.8", + "@storybook/react-dom-shim": "8.0.8", + "@storybook/types": "8.0.8", "@types/escodegen": "^0.0.6", "@types/estree": "^0.0.51", "@types/node": "^18.0.0", @@ -18141,9 +19399,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.0.6.tgz", - "integrity": "sha512-NC4k0dBIypvVqwqnMhKDUxNc1OeL6lgspn8V26PnmCYbvY97ZqoGQ7n2a5Kw/kubN6yWX1nxNkV6HcTRgEnYTw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.0.8.tgz", + "integrity": "sha512-vOMlAz2HH/xfgZmSO28fCEmp5/tPxINDEdBDVLdZeYG6R1j5jlMRyaNcXt4cPNDkyc///PkB/K767hg4goca/Q==", "dev": true, "funding": { "type": "opencollective", @@ -18155,16 +19413,16 @@ } }, "node_modules/@storybook/react-vite": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.0.6.tgz", - "integrity": "sha512-M6R6nl7dcXZ+wQHqFD1Qh/v4GPygqlC0pwE/cZ7FKUYA2wO3qm81OpuZYBKJoFIyHbRP/8oPKSvuzkgZvGY+/g==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.0.8.tgz", + "integrity": "sha512-3xN+/KgcjEAKJ0cM8yFYk8+T59kgKSMlQaavoIgQudbEErSubr9l7jDWXH44afQIEBVs++ayYWrbEN2wyMGoug==", "dev": true, "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "0.3.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "8.0.6", - "@storybook/node-logger": "8.0.6", - "@storybook/react": "8.0.6", + "@storybook/builder-vite": "8.0.8", + "@storybook/node-logger": "8.0.8", + "@storybook/react": "8.0.8", "find-up": "^5.0.0", "magic-string": "^0.30.0", "react-docgen": "^7.0.0", @@ -18185,9 +19443,9 @@ } }, "node_modules/@storybook/react/node_modules/@types/node": { - "version": "18.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.30.tgz", - "integrity": "sha512-453z1zPuJLVDbyahaa1sSD5C2sht6ZpHp5rgJNs+H8YGqhluCXcuOUmBYsAo0Tos0cHySJ3lVUGbGgLlqIkpyg==", + "version": "18.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", + "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -18239,12 +19497,12 @@ "dev": true }, "node_modules/@storybook/router": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/router/-/router-8.0.6.tgz", - "integrity": "sha512-ektN0+TyQPxVxcUvt9ksGizgDM1bKFEdGJeeqv0yYaOSyC4M1e4S8QZ+Iq/p/NFNt5XJWsWU+HtQ8AzQWagQfQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/router/-/router-8.0.8.tgz", + "integrity": "sha512-wdFdNsEKweigU9VkGZtpb7GhBJLWzbABcwOuEy2h0d5m7egB97hy9BxhANdqkC+PbAHrabxC99Ca3wTj50MoDg==", "dev": true, "dependencies": { - "@storybook/client-logger": "8.0.6", + "@storybook/client-logger": "8.0.8", "memoizerific": "^1.11.3", "qs": "^6.10.0" }, @@ -18254,13 +19512,13 @@ } }, "node_modules/@storybook/source-loader": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-8.0.6.tgz", - "integrity": "sha512-19R7iUNK2OVT/a/wWfGAMJwotKn51GrD5vqhGYtaUIp0DgqV7fj1rADvFWkqNrz1b1GWeRqCMcNr7p5l+ZFMEw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-8.0.8.tgz", + "integrity": "sha512-3xYr3/ziaiOrJ33r9+XHUZkWmY4Ej6I/ZXr6kTUMdCuia7d+WYkuzULk4DhJ/15Dv0TdygYNZZJKDkgw2sqlRg==", "dev": true, "dependencies": { "@storybook/csf": "^0.1.2", - "@storybook/types": "8.0.6", + "@storybook/types": "8.0.8", "estraverse": "^5.2.0", "lodash": "^4.17.21", "prettier": "^3.1.1" @@ -18271,14 +19529,14 @@ } }, "node_modules/@storybook/telemetry": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/telemetry/-/telemetry-8.0.6.tgz", - "integrity": "sha512-kzxhhzGRSBYR4oe/Vlp/adKVxD8KWbIDMCgLWaINe14ILfEmpyrC00MXRSjS1tMF1qfrtn600Oe/xkHFQUpivQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/telemetry/-/telemetry-8.0.8.tgz", + "integrity": "sha512-Uvj4nN01vQgjXZYKF/GKTFE85//Qm4ZTlJxTFWid+oYWc8NpAyJvlsJkj/dsEn4cLrgnJx2e4xvnx0Umr2ck+A==", "dev": true, "dependencies": { - "@storybook/client-logger": "8.0.6", - "@storybook/core-common": "8.0.6", - "@storybook/csf-tools": "8.0.6", + "@storybook/client-logger": "8.0.8", + "@storybook/core-common": "8.0.8", + "@storybook/csf-tools": "8.0.8", "chalk": "^4.1.0", "detect-package-manager": "^2.0.1", "fetch-retry": "^5.0.2", @@ -18369,13 +19627,13 @@ } }, "node_modules/@storybook/theming": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.0.6.tgz", - "integrity": "sha512-o/b12+nDp8WDFlE0qQilzJ2aIeOHD48MCoc+ouFRPRH4tUS5xNaBPYxBxTgdtFbwZNuOC2my4A37Uhjn6IwkuQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.0.8.tgz", + "integrity": "sha512-43hkNz7yo8Bl97AO2WbxIGprUqMhUZyK9g8383bd30gSxy9nfND/bdSdcgmA8IokDn8qp37Q4QmxtUZdhjMzZQ==", "dev": true, "dependencies": { "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", - "@storybook/client-logger": "8.0.6", + "@storybook/client-logger": "8.0.8", "@storybook/global": "^5.0.0", "memoizerific": "^1.11.3" }, @@ -18397,12 +19655,12 @@ } }, "node_modules/@storybook/types": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@storybook/types/-/types-8.0.6.tgz", - "integrity": "sha512-YKq4A+3diQ7UCGuyrB/9LkB29jjGoEmPl3TfV7mO1FvdRw22BNuV3GyJCiLUHigSKiZgFo+pfQhmsNRJInHUnQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@storybook/types/-/types-8.0.8.tgz", + "integrity": "sha512-NGsgCsXnWlaZmHenHDgHGs21zhweZACkqTNsEQ7hvsiF08QeiKAdgJLQg3YeGK73h9mFDRP9djprUtJYab6vnQ==", "dev": true, "dependencies": { - "@storybook/channels": "8.0.6", + "@storybook/channels": "8.0.8", "@types/express": "^4.7.0", "file-system-cache": "2.3.0" }, @@ -18680,11 +19938,17 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" + }, "node_modules/@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", "dependencies": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, @@ -18701,9 +19965,9 @@ } }, "node_modules/@tabler/icons": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.1.0.tgz", - "integrity": "sha512-CpZGyS1IVJKFcv88yZ2sYZIpWWhQ6oy76BQKQ5SF0fGgOqgyqKdBGG/YGyyMW632on37MX7VqQIMTzN/uQqmFg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.2.0.tgz", + "integrity": "sha512-h8GQ2rtxgiSjltrVz4vcopAxTPSpUSUi5nBfJ09H3Bk4fJk6wZ/dVUjzhv/BHfDwGTkAxZBiYe/Q/T95cPeg5Q==", "dev": true, "funding": { "type": "github", @@ -18711,12 +19975,12 @@ } }, "node_modules/@tabler/icons-react": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.1.0.tgz", - "integrity": "sha512-k/WTlax2vbj/LpxvaJ+BmaLAAhVUgyLj4Ftgaczz66tUSNzqrAZXCFdOU7cRMYPNVBqyqE2IdQd2rzzhDEJvkw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.2.0.tgz", + "integrity": "sha512-b1mZT1XpZrzvbM+eFe1YbYbxkzgJ18tM4knZKqXh0gnHDZ6XVLIH3TzJZ3HZ7PTkUqZLZ7XcGae3qQVGburlBw==", "dev": true, "dependencies": { - "@tabler/icons": "3.1.0" + "@tabler/icons": "3.2.0" }, "funding": { "type": "github", @@ -18727,12 +19991,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.2.0.tgz", - "integrity": "sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.3.0.tgz", + "integrity": "sha512-QFxmTSZBniq15S0vSZ55P4ToXquMXwJypPXyX/ux7sYo6a2FX3/zWoRLLc4eIOGWTjvzqcIVNKhcuFb+OZL3aQ==", "dev": true, "dependencies": { - "@tanstack/virtual-core": "3.2.0" + "@tanstack/virtual-core": "3.3.0" }, "funding": { "type": "github", @@ -18744,9 +20008,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.2.0.tgz", - "integrity": "sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.3.0.tgz", + "integrity": "sha512-A0004OAa1FcUkPHeeGoKgBrAgjH+uHdDPrw1L7RpkwnODYqRvoilqsHPs8cyTjMg1byZBbiNpQAq2TlFLIaQag==", "dev": true, "funding": { "type": "github", @@ -18948,135 +20212,23 @@ } }, "node_modules/@testing-library/react": { - "version": "14.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.0.tgz", - "integrity": "sha512-AYJGvNFMbCa5vt1UtDCa/dcaABrXq8gph6VN+cffIx0UeA0qiGqS+sT60+sb+Gjc8tGXdECWYQgaF0khf8b+Lg==", + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.2.tgz", + "integrity": "sha512-5mzIpuytB1ctpyywvyaY2TAAUQVCZIGqwiqFQf6u9lvj/SJQepGUzNV18Xpk+NLCaCE2j7CWrZE0tEf9xLZYiQ==", "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", + "@testing-library/dom": "^10.0.0", "@types/react-dom": "^18.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, - "node_modules/@testing-library/react/node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@testing-library/react/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/react/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/react/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/@testing-library/react/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@testing-library/react/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@testing-library/react/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/react/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true - }, - "node_modules/@testing-library/react/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@testing-library/user-event": { "version": "14.5.2", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", @@ -19337,9 +20489,9 @@ } }, "node_modules/@types/connect": { - "version": "3.4.36", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", - "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dependencies": { "@types/node": "*" } @@ -19464,9 +20616,9 @@ "dev": true }, "node_modules/@types/eslint": { - "version": "8.56.7", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.7.tgz", - "integrity": "sha512-SjDvI/x3zsZnOkYZ3lCt9lOZWZLB2jIlNKz+LBgCtDurK0JZcwucxYHn1w2BJkD34dgX9Tjnak0txtq4WTggEA==", + "version": "8.56.9", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.9.tgz", + "integrity": "sha512-W4W3KcqzjJ0sHg2vAq9vfml6OhsJ53TcUjUqfzzZf/EChUtwspszj/S0pzMxnfRcO55/iGq47dscXw71Fxc4Zg==", "dev": true, "dependencies": { "@types/estree": "*", @@ -19772,6 +20924,18 @@ "iconv-lite": "^0.6.3" } }, + "node_modules/@types/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@types/mdast": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", @@ -19782,9 +20946,9 @@ } }, "node_modules/@types/mdx": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.12.tgz", - "integrity": "sha512-H9VZ9YqE+H28FQVchC83RCs5xQ2J7mAAv6qdDEaWmXEVl3OpdH+xfrSUzQ1lp7U7oSTRZ0RvW08ASPJsYBi7Cw==", + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", "dev": true }, "node_modules/@types/memcached": { @@ -19832,9 +20996,9 @@ } }, "node_modules/@types/node": { - "version": "20.12.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz", - "integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==", + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", "dependencies": { "undici-types": "~5.26.4" } @@ -19908,9 +21072,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.4", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.4.tgz", - "integrity": "sha512-yw3Bwbda6vO+NvI1Ue/YKOwtl31AYvvd/e73O3V4ZkNzuGpTDndLSyc0dQRB2xrQqDePd20pEGIfqSp/GH3pRw==", + "version": "8.11.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.5.tgz", + "integrity": "sha512-2xMjVviMxneZHDHX5p5S6tsRRs7TpDHeeK7kTTMe/kAC/mRRNjWHjZg0rkiY+e17jXSZV3zJYDxXV8Cy72/Vuw==", "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -19954,9 +21118,9 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "node_modules/@types/react": { - "version": "18.2.74", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz", - "integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==", + "version": "18.2.78", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.78.tgz", + "integrity": "sha512-qOwdPnnitQY4xKlKayt42q5W5UQrSHjgoXNVEtxeqdITJ99k4VXJOP3vt8Rkm9HmgJpH50UNU+rlqfkfWOqp0A==", "devOptional": true, "dependencies": { "@types/prop-types": "*", @@ -19964,9 +21128,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.2.24", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz", - "integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==", + "version": "18.2.25", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz", + "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==", "dev": true, "dependencies": { "@types/react": "*" @@ -20034,6 +21198,12 @@ "@types/node": "*" } }, + "node_modules/@types/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==", + "dev": true + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -20129,9 +21299,9 @@ } }, "node_modules/@types/ssh2/node_modules/@types/node": { - "version": "18.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.30.tgz", - "integrity": "sha512-453z1zPuJLVDbyahaa1sSD5C2sht6ZpHp5rgJNs+H8YGqhluCXcuOUmBYsAo0Tos0cHySJ3lVUGbGgLlqIkpyg==", + "version": "18.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", + "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -20175,16 +21345,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/tar": { - "version": "6.1.12", - "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.12.tgz", - "integrity": "sha512-FwbJPi9YuovB6ilnHrz8Y4pb0Fh6N7guFkbnlCl39ua893Qi5gkXui7LSDpTQMJCmA4z5f6SeSrTPQEWLdtFVw==", - "dev": true, - "dependencies": { - "@types/node": "*", - "minipass": "^4.0.0" - } - }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", @@ -20275,22 +21435,22 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.5.0.tgz", - "integrity": "sha512-HpqNTH8Du34nLxbKgVMGljZMG0rJd2O9ecvr2QLYp+7512ty1j42KnsFwspPXg1Vh8an9YImf6CokUBltisZFQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz", + "integrity": "sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A==", "dev": true, "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.5.0", - "@typescript-eslint/type-utils": "7.5.0", - "@typescript-eslint/utils": "7.5.0", - "@typescript-eslint/visitor-keys": "7.5.0", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/type-utils": "7.6.0", + "@typescript-eslint/utils": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", "debug": "^4.3.4", "graphemer": "^1.4.0", - "ignore": "^5.2.4", + "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -20343,15 +21503,15 @@ "dev": true }, "node_modules/@typescript-eslint/parser": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.5.0.tgz", - "integrity": "sha512-cj+XGhNujfD2/wzR1tabNsidnYRaFfEkcULdcIyVBYcXjBvBKOes+mpMBP7hMpOyk+gBcfXsrg4NBGAStQyxjQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.6.0.tgz", + "integrity": "sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.5.0", - "@typescript-eslint/types": "7.5.0", - "@typescript-eslint/typescript-estree": "7.5.0", - "@typescript-eslint/visitor-keys": "7.5.0", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", "debug": "^4.3.4" }, "engines": { @@ -20371,13 +21531,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.5.0.tgz", - "integrity": "sha512-Z1r7uJY0MDeUlql9XJ6kRVgk/sP11sr3HKXn268HZyqL7i4cEfrdFuSSY/0tUqT37l5zT0tJOsuDP16kio85iA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz", + "integrity": "sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.5.0", - "@typescript-eslint/visitor-keys": "7.5.0" + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -20388,15 +21548,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.5.0.tgz", - "integrity": "sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz", + "integrity": "sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.5.0", - "@typescript-eslint/utils": "7.5.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/utils": "7.6.0", "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -20415,9 +21575,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.5.0.tgz", - "integrity": "sha512-tv5B4IHeAdhR7uS4+bf8Ov3k793VEVHd45viRRkehIUZxm0WF82VPiLgHzA/Xl4TGPg1ZD49vfxBKFPecD5/mg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.6.0.tgz", + "integrity": "sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -20428,19 +21588,19 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.5.0.tgz", - "integrity": "sha512-YklQQfe0Rv2PZEueLTUffiQGKQneiIEKKnfIqPIOxgM9lKSZFCjT5Ad4VqRKj/U4+kQE3fa8YQpskViL7WjdPQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz", + "integrity": "sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.5.0", - "@typescript-eslint/visitor-keys": "7.5.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -20477,9 +21637,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -20513,18 +21673,18 @@ "dev": true }, "node_modules/@typescript-eslint/utils": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.5.0.tgz", - "integrity": "sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.6.0.tgz", + "integrity": "sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.5.0", - "@typescript-eslint/types": "7.5.0", - "@typescript-eslint/typescript-estree": "7.5.0", - "semver": "^7.5.4" + "@types/json-schema": "^7.0.15", + "@types/semver": "^7.5.8", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "semver": "^7.6.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -20571,13 +21731,13 @@ "dev": true }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.5.0.tgz", - "integrity": "sha512-mcuHM/QircmA6O7fy6nn2w/3ditQkj+SgtOc8DW3uQ10Yfj42amm2i+6F2K4YAOPNNTmE6iM1ynM6lrSwdendA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz", + "integrity": "sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.5.0", - "eslint-visitor-keys": "^3.4.1" + "@typescript-eslint/types": "7.6.0", + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -20625,9 +21785,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.4.0.tgz", - "integrity": "sha512-4hDGyH1SvKpgZnIByr9LhGgCEuF9DKM34IBLCC/fVfy24Z3+PZ+Ii9hsVBsHvY1umM1aGPEjceRkzxCfcQ10wg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.5.0.tgz", + "integrity": "sha512-1igVwlcqw1QUMdfcMlzzY4coikSIBN944pkueGi0pawrX5I5Z+9hxdTR+w3Sg6Q3eZhvdMAs8ZaF9JuTG1uYOQ==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", @@ -20642,14 +21802,13 @@ "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", - "test-exclude": "^6.0.0", - "v8-to-istanbul": "^9.2.0" + "test-exclude": "^6.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "1.4.0" + "vitest": "1.5.0" } }, "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { @@ -20667,13 +21826,13 @@ } }, "node_modules/@vitest/expect": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.4.0.tgz", - "integrity": "sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.5.0.tgz", + "integrity": "sha512-0pzuCI6KYi2SIC3LQezmxujU9RK/vwC1U9R0rLuGlNGcOuDWxqWKu6nUdFsX9tH1WU0SXtAxToOsEjeUn1s3hA==", "dev": true, "dependencies": { - "@vitest/spy": "1.4.0", - "@vitest/utils": "1.4.0", + "@vitest/spy": "1.5.0", + "@vitest/utils": "1.5.0", "chai": "^4.3.10" }, "funding": { @@ -20681,12 +21840,12 @@ } }, "node_modules/@vitest/runner": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.4.0.tgz", - "integrity": "sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.5.0.tgz", + "integrity": "sha512-7HWwdxXP5yDoe7DTpbif9l6ZmDwCzcSIK38kTSIt6CFEpMjX4EpCgT6wUmS0xTXqMI6E/ONmfgRKmaujpabjZQ==", "dev": true, "dependencies": { - "@vitest/utils": "1.4.0", + "@vitest/utils": "1.5.0", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -20722,9 +21881,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.4.0.tgz", - "integrity": "sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.5.0.tgz", + "integrity": "sha512-qpv3fSEuNrhAO3FpH6YYRdaECnnRjg9VxbhdtPwPRnzSfHVXnNzzrpX4cJxqiwgRMo7uRMWDFBlsBq4Cr+rO3A==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -20736,9 +21895,9 @@ } }, "node_modules/@vitest/spy": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.4.0.tgz", - "integrity": "sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.5.0.tgz", + "integrity": "sha512-vu6vi6ew5N5MMHJjD5PoakMRKYdmIrNJmyfkhRpQt5d9Ewhw9nZ5Aqynbi3N61bvk9UvZ5UysMT6ayIrZ8GA9w==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -20748,12 +21907,12 @@ } }, "node_modules/@vitest/ui": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-1.4.0.tgz", - "integrity": "sha512-XC6CMhN1gzYcGbpn6/Oanj4Au2EXwQEX6vpcOeLlZv8dy7g11Ukx8zwtYQbwxs9duK2s9j2o5rbQiCP5DPAcmw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-1.5.0.tgz", + "integrity": "sha512-ETcToK2TzICf/Oartvt19IH7yR4oCs8GrQk5hRhZ5oZFaSdDHTh6o3EdzyxOaY24NZ20cXYYNGjj1se/5vHfFg==", "dev": true, "dependencies": { - "@vitest/utils": "1.4.0", + "@vitest/utils": "1.5.0", "fast-glob": "^3.3.2", "fflate": "^0.8.1", "flatted": "^3.2.9", @@ -20765,13 +21924,13 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "1.4.0" + "vitest": "1.5.0" } }, "node_modules/@vitest/utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.4.0.tgz", - "integrity": "sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.5.0.tgz", + "integrity": "sha512-BDU0GNL8MWkRkSRdNFvCUCAVOeHaUlVJ9Tx0TYBZyXaaOTmGtUFObzchCivIBrIwKzvZA7A9sCejVhXM2aY98A==", "dev": true, "dependencies": { "diff-sequences": "^29.6.3", @@ -21239,26 +22398,26 @@ } }, "node_modules/algoliasearch": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.23.2.tgz", - "integrity": "sha512-8aCl055IsokLuPU8BzLjwzXjb7ty9TPcUFFOk0pYOwsE5DMVhE3kwCMFtsCFKcnoPZK7oObm+H5mbnSO/9ioxQ==", - "dev": true, - "dependencies": { - "@algolia/cache-browser-local-storage": "4.23.2", - "@algolia/cache-common": "4.23.2", - "@algolia/cache-in-memory": "4.23.2", - "@algolia/client-account": "4.23.2", - "@algolia/client-analytics": "4.23.2", - "@algolia/client-common": "4.23.2", - "@algolia/client-personalization": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/logger-console": "4.23.2", - "@algolia/recommend": "4.23.2", - "@algolia/requester-browser-xhr": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/requester-node-http": "4.23.2", - "@algolia/transporter": "4.23.2" + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.23.3.tgz", + "integrity": "sha512-Le/3YgNvjW9zxIQMRhUHuhiUjAlKY/zsdZpfq4dlLqg6mEm0nL6yk+7f2hDOtLpxsgE4jSzDmvHL7nXdBp5feg==", + "dev": true, + "dependencies": { + "@algolia/cache-browser-local-storage": "4.23.3", + "@algolia/cache-common": "4.23.3", + "@algolia/cache-in-memory": "4.23.3", + "@algolia/client-account": "4.23.3", + "@algolia/client-analytics": "4.23.3", + "@algolia/client-common": "4.23.3", + "@algolia/client-personalization": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/logger-console": "4.23.3", + "@algolia/recommend": "4.23.3", + "@algolia/requester-browser-xhr": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/requester-node-http": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/algoliasearch-helper": { @@ -21398,7 +22557,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -21519,21 +22677,6 @@ "node": ">=10" } }, - "node_modules/are-we-there-yet/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -21573,7 +22716,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", - "dev": true, "dependencies": { "call-bind": "^1.0.5", "is-array-buffer": "^3.0.4" @@ -21723,7 +22865,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", - "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.5", @@ -21911,9 +23052,9 @@ } }, "node_modules/aws-cdk": { - "version": "2.136.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.136.0.tgz", - "integrity": "sha512-MVSE+AERoP0D1qXlkhKQOzs22QVulGleX1yJTkWzoYhEyseEmR8EiFJcmyEhJku/swmY0KDpVlT9R62dRG5+JQ==", + "version": "2.137.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.137.0.tgz", + "integrity": "sha512-3pf3SVDwNZvo3EfhO3yl1B+KbRHz7T4UmPifUEKfOwk7ABAFLRSNddZuUlF560XSBTFLkrZoeBDa0/MLJT6F4g==", "bin": { "cdk": "bin/cdk" }, @@ -21925,9 +23066,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.136.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.136.0.tgz", - "integrity": "sha512-zdkWNe91mvZH6ESghUoIxB8ORoreExg2wowTLEVfy3vWY1a6n69crxk8mkCG+vn6GhXEnEPpovoG1QV8BpXTpA==", + "version": "2.137.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.137.0.tgz", + "integrity": "sha512-pD3AGdKBa8q1+vVWRabiDHuecVMlP8ERGPHc9Pb0dVlpbC/ODC6XXC1S0TAMsr0JI5Lh6pk4vL5cC+spsMeotw==", "bundleDependencies": [ "@balena/dockerignore", "case", @@ -22612,15 +23753,6 @@ "node": ">=10" } }, - "node_modules/babel-plugin-macros/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.10", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", @@ -23253,19 +24385,6 @@ "node": "*" } }, - "node_modules/binary": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", - "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", - "dev": true, - "dependencies": { - "buffers": "~0.1.1", - "chainsaw": "~0.1.0" - }, - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -23298,19 +24417,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -23353,17 +24459,6 @@ "ms": "2.0.0" } }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -23525,12 +24620,6 @@ "pako": "~0.2.0" } }, - "node_modules/browserify-zlib/node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", - "dev": true - }, "node_modules/browserslist": { "version": "4.23.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", @@ -23595,12 +24684,26 @@ } }, "node_modules/buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, "node_modules/buffer-alloc": { @@ -23633,24 +24736,6 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, - "node_modules/buffer-indexof-polyfill": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", - "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/buffers": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", - "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", - "dev": true, - "engines": { - "node": ">=0.2.0" - } - }, "node_modules/buildcheck": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", @@ -23715,9 +24800,9 @@ "dev": true }, "node_modules/bullmq": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.5.4.tgz", - "integrity": "sha512-qqZ8lZNfCzqV46zlIq0/4hzgCklhkXEeREeHPBJD2FAoNa7/Gwa9V4ySFX0rJv7B7U2buPrawkBQ1/7ATAu39A==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.7.1.tgz", + "integrity": "sha512-t7FhF2mCGgmjZ1rHuBYIcLwzONm4QFGrO1+9mF7hpjWtXalGfy+nGciVcb69L7aPcdJMR2XTe6bNMWHGbKy8mQ==", "dependencies": { "cron-parser": "^4.6.0", "ioredis": "^5.3.2", @@ -23834,6 +24919,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/cacache/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/cacache/node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -23843,15 +24937,74 @@ "node": ">=12" } }, - "node_modules/cacache/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "node_modules/cacache/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cacache/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" } }, + "node_modules/cacache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -24018,9 +25171,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001607", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001607.tgz", - "integrity": "sha512-WcvhVRjXLKFB/kmOFVwELtMxyhq3iM/MvmXcyCe2PNf166c39mptscOc/45TTS96n2gpNV2z7+NakArTWZCQ3w==", + "version": "1.0.30001610", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001610.tgz", + "integrity": "sha512-QFutAY4NgaelojVMjY63o6XlZyORPaLfyMnsl3HgnWdJUcX6K0oaJymHjH8PT5Gk7sTm8rvC/c5COUQKXqmOMA==", "funding": [ { "type": "opencollective", @@ -24047,11 +25200,11 @@ } }, "node_modules/cdk": { - "version": "2.136.0", - "resolved": "https://registry.npmjs.org/cdk/-/cdk-2.136.0.tgz", - "integrity": "sha512-vcGhctRGJnsVH1LaVRjknbb0kaBk0LMqNUXkyrRhAkNRZyqDPfukqIlNaO513eNCSCO2oMAEfjl616xsaWBnaQ==", + "version": "2.137.0", + "resolved": "https://registry.npmjs.org/cdk/-/cdk-2.137.0.tgz", + "integrity": "sha512-VOGIVuyOqJ6bU3ZhJjow2w89CIPGdYMWLLUrPFR/xujYuOF/+6lKmNR+VPqTpwY8f96XtUGIessXrbaSvWph6Q==", "dependencies": { - "aws-cdk": "2.136.0" + "aws-cdk": "2.137.0" }, "bin": { "cdk": "bin/cdk" @@ -24061,18 +25214,18 @@ } }, "node_modules/cdk-nag": { - "version": "2.28.84", - "resolved": "https://registry.npmjs.org/cdk-nag/-/cdk-nag-2.28.84.tgz", - "integrity": "sha512-GRj7tjoomctFozzH0tFWOXRNaTf7mht01xu+bZzZ3794leJlZ8stl+Aw1D2RMSQy9CNGDsIxrUns5M4lQ1Hjjw==", + "version": "2.28.89", + "resolved": "https://registry.npmjs.org/cdk-nag/-/cdk-nag-2.28.89.tgz", + "integrity": "sha512-0K9roRBQNItmeGvwfZlP8DnETZaytmRFXxIva9hiKpdQOVN8HAfbNrVRQkqG/l30m9RFySf/pJHhOU0p/S5H7Q==", "peerDependencies": { "aws-cdk-lib": "^2.116.0", "constructs": "^10.0.5" } }, "node_modules/cdk-serverless-clamscan": { - "version": "2.6.145", - "resolved": "https://registry.npmjs.org/cdk-serverless-clamscan/-/cdk-serverless-clamscan-2.6.145.tgz", - "integrity": "sha512-OkvYRglr1Zv2M44A27Fvw7xvlLcURX4ZCiwWx90l1FYolM31mdyn3lqZxV5s0nnKWBr4WOLp25QGDW/wLalxSA==", + "version": "2.6.150", + "resolved": "https://registry.npmjs.org/cdk-serverless-clamscan/-/cdk-serverless-clamscan-2.6.150.tgz", + "integrity": "sha512-hberjqIDNqNMRUEmpYPg/cJrvAzMMQKvZCO9Nmi/AlJe+XsN58XE4TGep63WvAH9Nk/w9kz1PxCdrMR+fCvxlg==", "bin": { "0": "assets" }, @@ -24099,18 +25252,6 @@ "node": ">=4" } }, - "node_modules/chainsaw": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", - "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", - "dev": true, - "dependencies": { - "traverse": ">=0.3.0 <0.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -24287,11 +25428,11 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/chromatic": { @@ -25000,6 +26141,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -25019,17 +26165,90 @@ "typedarray": "^0.0.6" } }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" }, "engines": { - "node": ">= 6" + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" } }, "node_modules/config-chain": { @@ -25227,25 +26446,6 @@ "node": ">= 0.6" } }, - "node_modules/content-disposition/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -25260,9 +26460,9 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -25279,6 +26479,14 @@ "node": ">= 0.8.0" } }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -25928,15 +27136,6 @@ "postcss": "^8.2.15" } }, - "node_modules/cssnano/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/csso": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", @@ -26212,6 +27411,18 @@ "node": ">= 10" } }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -26601,7 +27812,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", - "dev": true, "dependencies": { "call-bind": "^1.0.6", "es-errors": "^1.3.0", @@ -26618,7 +27828,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", @@ -26635,7 +27844,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", - "dev": true, "dependencies": { "call-bind": "^1.0.6", "es-errors": "^1.3.0", @@ -26701,8 +27909,7 @@ "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "dev": true + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" }, "node_modules/debug": { "version": "4.3.4", @@ -26784,9 +27991,9 @@ } }, "node_modules/dedent": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", - "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", "dev": true, "peerDependencies": { "babel-plugin-macros": "^3.1.0" @@ -26810,29 +28017,16 @@ } }, "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", "is-date-object": "^1.0.5", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", "object-is": "^1.1.5", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" + "regexp.prototype.flags": "^1.5.1" }, "engines": { "node": ">= 0.4" @@ -26841,12 +28035,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/deep-equal/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -27564,6 +28752,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, "engines": { "node": ">=12" } @@ -27571,8 +28760,7 @@ "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" }, "node_modules/duplexer2": { "version": "0.1.4", @@ -27583,6 +28771,36 @@ "readable-stream": "^2.0.2" } }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -27595,6 +28813,36 @@ "stream-shift": "^1.0.0" } }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexify/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/duplexify/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/dynamic-dedupe": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", @@ -27607,8 +28855,7 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", @@ -27625,9 +28872,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -27640,9 +28887,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.730", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.730.tgz", - "integrity": "sha512-oJRPo82XEqtQAobHpJIR3zW5YO3sSRRkPz2an4yxi1UvqhsGm54vR/wzTFV74a3soDOJ8CKW7ajOOX5ESzddwg==" + "version": "1.4.736", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.736.tgz", + "integrity": "sha512-Rer6wc3ynLelKNM4lOCg7/zPQj8tPOCB2hzD32PX9wd3hgRRi9MxEbmkFCokzcEhRVMiOVLjnL9ig9cefJ+6+Q==" }, "node_modules/elkjs": { "version": "0.9.2", @@ -27674,8 +28921,7 @@ "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/emojilib": { "version": "2.4.0", @@ -27733,6 +28979,18 @@ "node": ">=8.10.0" } }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -27836,7 +29094,6 @@ "version": "1.23.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", - "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.1", "arraybuffer.prototype.slice": "^1.0.3", @@ -27911,32 +29168,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-get-iterator/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, "node_modules/es-iterator-helpers": { "version": "1.0.18", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.18.tgz", @@ -27972,7 +29203,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -27984,7 +29214,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.4", "has-tostringtag": "^1.0.2", @@ -28007,7 +29236,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, "dependencies": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -28208,14 +29436,14 @@ } }, "node_modules/eslint-config-next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.1.4.tgz", - "integrity": "sha512-cihIahbhYAWwXJwZkAaRPpUi5t9aOi/HdfWXOjZeUOqNWXHD8X22kd1KG58Dc3MVaRx3HoR/oMGk2ltcrqDn8g==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.1.tgz", + "integrity": "sha512-BgD0kPCWMlqoItRf3xe9fG0MqwObKfVch+f2ccwDpZiCJA8ghkz2wrASH+bI6nLZzGcOJOpMm1v1Q1euhfpt4Q==", "dev": true, "dependencies": { - "@next/eslint-plugin-next": "14.1.4", + "@next/eslint-plugin-next": "14.2.1", "@rushstack/eslint-patch": "^1.3.3", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.28.1", @@ -28234,15 +29462,15 @@ } }, "node_modules/eslint-config-next/node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", "debug": "^4.3.4" }, "engines": { @@ -28253,7 +29481,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -28262,13 +29490,13 @@ } }, "node_modules/eslint-config-next/node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -28279,9 +29507,9 @@ } }, "node_modules/eslint-config-next/node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -28292,13 +29520,13 @@ } }, "node_modules/eslint-config-next/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -28320,12 +29548,12 @@ } }, "node_modules/eslint-config-next/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/types": "7.2.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -29441,9 +30669,9 @@ } }, "node_modules/expo": { - "version": "50.0.14", - "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.14.tgz", - "integrity": "sha512-yLPdxCMVAbmeEIpzzyAuJ79wvr6ToDDtQmuLDMAgWtjqP8x3CGddXxUe07PpKEQgzwJabdHvCLP5Bv94wMFIjQ==", + "version": "50.0.15", + "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.15.tgz", + "integrity": "sha512-tsyRmMHjA8lPlM7AsqH1smSH8hzmn1+x/vsP+xgbKYJTGtYccdY/wsm6P84VJWeK5peWSVqrWNos+YuPqXKLSQ==", "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.17.8", @@ -29457,7 +30685,7 @@ "expo-font": "~11.10.3", "expo-keep-awake": "~12.8.2", "expo-modules-autolinking": "1.10.3", - "expo-modules-core": "1.11.12", + "expo-modules-core": "1.11.13", "fbemitter": "^3.0.0", "whatwg-url-without-unicode": "8.0.0-3" }, @@ -29626,9 +30854,9 @@ } }, "node_modules/expo-modules-core": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.11.12.tgz", - "integrity": "sha512-/e8g4kis0pFLer7C0PLyx98AfmztIM6gU9jLkYnB1pU9JAfQf904XEi3bmszO7uoteBQwSL6FLp1m3TePKhDaA==", + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.11.13.tgz", + "integrity": "sha512-2H5qrGUvmLzmJNPDOnovH1Pfk5H/S/V0BifBmOQyDc9aUh9LaDwkqnChZGIXv8ZHDW8JRlUW0QqyWxTggkbw1A==", "dependencies": { "invariant": "^2.2.4" } @@ -29734,14 +30962,6 @@ "node": ">= 8.0.0" } }, - "node_modules/express/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -29769,25 +30989,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/express/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -30471,7 +31672,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -30487,7 +31687,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -30752,15 +31951,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -30884,6 +32074,36 @@ "readable-stream": "^2.0.0" } }, + "node_modules/from2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/from2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/from2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/fromentries": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", @@ -30923,6 +32143,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -30936,6 +32157,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -30952,15 +32174,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/fs-monkey": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", @@ -31068,7 +32281,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -31157,9 +32369,9 @@ } }, "node_modules/gaxios": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.4.0.tgz", - "integrity": "sha512-apAloYrY4dlBGlhauDAYSZveafb5U6+L9titing1wox6BvWM0TSXBp603zTrLpyLMGkrcFgohnUN150dFN/zOA==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.5.0.tgz", + "integrity": "sha512-R9QGdv8j4/dlNoQbX3hSaK/S0rkMijqjVvW3YM06CoBdbU/VdKd159j4hePpng0KuE6Lh6JJ7UdmVGJZFcAG1w==", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -31313,7 +32525,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", - "dev": true, "dependencies": { "call-bind": "^1.0.5", "es-errors": "^1.3.0", @@ -31365,6 +32576,83 @@ "giget": "dist/cli.mjs" } }, + "node_modules/giget/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/giget/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/giget/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/giget/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/giget/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/giget/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/giget/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/git-config-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/git-config-path/-/git-config-path-1.0.1.tgz", @@ -31409,7 +32697,6 @@ "version": "10.3.12", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", - "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.6", @@ -31448,7 +32735,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -31457,7 +32743,6 @@ "version": "9.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", - "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -31468,15 +32753,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", @@ -31557,7 +32833,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dev": true, "dependencies": { "define-properties": "^1.1.3" }, @@ -31737,7 +33012,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "dev": true, "dependencies": { "duplexer": "^0.1.2" }, @@ -31794,7 +33068,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -32284,6 +33557,36 @@ "wbuf": "^1.1.0" } }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -32315,8 +33618,7 @@ "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" }, "node_modules/html-minifier-terser": { "version": "7.2.0", @@ -32623,11 +33925,11 @@ "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "safer-buffer": ">= 2.1.2 < 3" }, "engines": { "node": ">=0.10.0" @@ -32965,7 +34267,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.0", @@ -33141,7 +34442,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.1" @@ -33177,7 +34477,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, "dependencies": { "has-bigints": "^1.0.1" }, @@ -33201,7 +34500,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -33271,7 +34569,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", - "dev": true, "dependencies": { "is-typed-array": "^1.1.13" }, @@ -33537,7 +34834,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -33569,7 +34865,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -33621,7 +34916,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -33699,7 +34993,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", - "dev": true, "dependencies": { "call-bind": "^1.0.7" }, @@ -33725,7 +35018,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -33740,7 +35032,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -33809,7 +35100,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -34120,7 +35410,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -34755,6 +36044,18 @@ "node": ">=12" } }, + "node_modules/jest-environment-jsdom/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jest-environment-jsdom/node_modules/jsdom": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", @@ -36262,17 +37563,6 @@ "is-buffer": "~1.1.1" } }, - "node_modules/json-schema-deref-sync/node_modules/traverse": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", - "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -36424,6 +37714,38 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/just-extend": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", @@ -36628,6 +37950,18 @@ "libqp": "2.1.0" } }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/libqp": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.0.tgz", @@ -36743,6 +38077,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.24.1.tgz", + "integrity": "sha512-z6NberUUw5ALES6Ixn2shmjRRrM1cmEn1ZQPiM5IrZ6xHHL5a1lPin9pRv+w6eWfcrEo+qGG6R9XfJrpuY3e4g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-linux-arm-gnueabihf": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.19.0.tgz", @@ -36891,12 +38246,6 @@ "uc.micro": "^2.0.0" } }, - "node_modules/listenercount": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", - "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", - "dev": true - }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -37428,20 +38777,20 @@ } }, "node_modules/magicast": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.3.tgz", - "integrity": "sha512-ZbrP1Qxnpoes8sz47AM0z08U+jW6TyRgZzcWy3Ma3vDhJttwMwAFDMMQFobwdBxByBD46JYmxRzeF7w2+wJEuw==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.4.tgz", + "integrity": "sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==", "dev": true, "dependencies": { - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", - "source-map-js": "^1.0.2" + "@babel/parser": "^7.24.4", + "@babel/types": "^7.24.0", + "source-map-js": "^1.2.0" } }, "node_modules/mailparser": { - "version": "3.6.9", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.6.9.tgz", - "integrity": "sha512-1fIDZlgN1NnuzmTSEUxkaViquXYkw5NbQehVc+kz55QRy98QgLdTtRSKv289Jy4NrCiDchRx6zAijB4HrPsvkA==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.0.tgz", + "integrity": "sha512-yFo1+4r3gHhTcazVGCv3D/uX5VJyyrx4iWkzKtJh8mujYIUm92kpG5BjKpZIJgEoKcKxbJVd4CPs+IImE1sKlQ==", "dev": true, "dependencies": { "encoding-japanese": "2.0.0", @@ -37456,6 +38805,18 @@ "tlds": "1.250.0" } }, + "node_modules/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mailparser/node_modules/nodemailer": { "version": "6.9.11", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.11.tgz", @@ -37476,6 +38837,18 @@ "libqp": "2.0.1" } }, + "node_modules/mailsplit/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mailsplit/node_modules/libbase64": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.2.1.tgz", @@ -38095,6 +39468,10 @@ "resolved": "examples/medplum-chart-demo", "link": true }, + "node_modules/medplum-chat-demo": { + "resolved": "examples/medplum-chat-demo", + "link": true + }, "node_modules/medplum-client-external-idp-demo": { "resolved": "examples/medplum-client-external-idp-demo", "link": true @@ -41139,12 +42516,11 @@ } }, "node_modules/minipass": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", - "dev": true, + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, "node_modules/minipass-collect": { @@ -41191,15 +42567,6 @@ "encoding": "^0.1.13" } }, - "node_modules/minipass-fetch/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/minipass-flush": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", @@ -41344,7 +42711,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, "bin": { "mkdirp": "dist/cjs/src/bin.js" }, @@ -41493,20 +42859,6 @@ "readable-stream": "^3.6.0" } }, - "node_modules/multistream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/mv": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", @@ -41673,12 +43025,12 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==" }, "node_modules/next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.4.tgz", - "integrity": "sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.1.tgz", + "integrity": "sha512-SF3TJnKdH43PMkCcErLPv+x/DY1YCklslk3ZmwaVoyUfDgHKexuKlf9sEfBQ69w+ue8jQ3msLb+hSj1T19hGag==", "dependencies": { - "@next/env": "14.1.4", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.1", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -41692,18 +43044,19 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.1.4", - "@next/swc-darwin-x64": "14.1.4", - "@next/swc-linux-arm64-gnu": "14.1.4", - "@next/swc-linux-arm64-musl": "14.1.4", - "@next/swc-linux-x64-gnu": "14.1.4", - "@next/swc-linux-x64-musl": "14.1.4", - "@next/swc-win32-arm64-msvc": "14.1.4", - "@next/swc-win32-ia32-msvc": "14.1.4", - "@next/swc-win32-x64-msvc": "14.1.4" + "@next/swc-darwin-arm64": "14.2.1", + "@next/swc-darwin-x64": "14.2.1", + "@next/swc-linux-arm64-gnu": "14.2.1", + "@next/swc-linux-arm64-musl": "14.2.1", + "@next/swc-linux-x64-gnu": "14.2.1", + "@next/swc-linux-x64-musl": "14.2.1", + "@next/swc-win32-arm64-msvc": "14.2.1", + "@next/swc-win32-ia32-msvc": "14.2.1", + "@next/swc-win32-x64-msvc": "14.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" @@ -41712,6 +43065,9 @@ "@opentelemetry/api": { "optional": true }, + "@playwright/test": { + "optional": true + }, "sass": { "optional": true } @@ -41893,6 +43249,11 @@ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, + "node_modules/node-addon-api": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz", + "integrity": "sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==" + }, "node_modules/node-cleanup": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", @@ -42002,6 +43363,16 @@ "node": "^12.13 || ^14.13 || >=16" } }, + "node_modules/node-gyp-build": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", + "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz", @@ -42117,6 +43488,15 @@ "node": ">=10" } }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/node-gyp/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -42293,20 +43673,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/node-gyp/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/node-gyp/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -42387,6 +43753,32 @@ "node": ">=8" } }, + "node_modules/node-gyp/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/node-gyp/node_modules/unique-filename": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", @@ -43542,7 +44934,6 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.5", "define-properties": "^1.2.1", @@ -43728,11 +45119,22 @@ "yaml": "^2.4.1" } }, + "node_modules/openapi3-ts/node_modules/yaml": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", + "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "dev": true, "bin": { "opener": "bin/opener-bin.js" } @@ -44130,6 +45532,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/pacote/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/pacote/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -44139,10 +45550,69 @@ "node": ">=8" } }, + "node_modules/pacote/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" }, "node_modules/param-case": { "version": "3.0.4", @@ -44387,7 +45857,6 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", - "dev": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -44403,20 +45872,10 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, "engines": { "node": "14 || >=16.14" } }, - "node_modules/path-scurry/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -44459,6 +45918,17 @@ "node": ">=12" } }, + "node_modules/pdfmake/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pdfmake/node_modules/sax": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", @@ -45819,9 +47289,9 @@ } }, "node_modules/postcss-preset-mantine": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/postcss-preset-mantine/-/postcss-preset-mantine-1.13.0.tgz", - "integrity": "sha512-1bv/mQz2K+/FixIMxYd83BYH7PusDZaI7LpUtKbb1l/5N5w6t1p/V9ONHfRJeeAZyfa6Xc+AtR+95VKdFXRH1g==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/postcss-preset-mantine/-/postcss-preset-mantine-1.14.4.tgz", + "integrity": "sha512-T1K3MVhU1hA9mJWfqoGvMcK5WKcHpVi4JUX6AYTbESvp78WneB/KFONUi+eXDG9Lpw62W/KNxEYl1ic3Dpm88w==", "dev": true, "dependencies": { "postcss-mixins": "^9.0.4", @@ -46139,9 +47609,9 @@ "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==" }, "node_modules/preact": { - "version": "10.20.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.20.1.tgz", - "integrity": "sha512-JIFjgFg9B2qnOoGiYMVBtrcFxHqn+dNXbq76bVmcaHYJFYR4lW67AOcXgAYQQTDYXDOg/kTZrKPNCdRgJ2UJmw==", + "version": "10.20.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.20.2.tgz", + "integrity": "sha512-S1d1ernz3KQ+Y2awUxKakpfOg2CEmJmwOP+6igPx6dgr6pgDvenqYviyokWso2rhHvGtTlWWnJDa7RaPbQerTg==", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -46572,9 +48042,9 @@ } }, "node_modules/qs": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", - "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", + "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", "dev": true, "dependencies": { "side-channel": "^1.0.6" @@ -46711,17 +48181,6 @@ "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/raw-loader": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", @@ -47159,9 +48618,9 @@ } }, "node_modules/react-intersection-observer": { - "version": "9.8.1", - "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.8.1.tgz", - "integrity": "sha512-QzOFdROX8D8MH3wE3OVKH0f3mLjKTtEN1VX/rkNuECCff+aKky0pIjulDhr3Ewqj5el/L+MhBkM3ef0Tbt+qUQ==", + "version": "9.8.2", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.8.2.tgz", + "integrity": "sha512-901naEiiZmse3p+AmtbQ3NL9xx+gQ8TXLiGDc+8GiE3JKJkNV3vP737aGuWTAXBA+1QqxPrDDE+fIEgYpGDlrQ==", "dev": true, "peerDependencies": { "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", @@ -48046,17 +49505,16 @@ } }, "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/readdirp": { @@ -48842,7 +50300,6 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", - "dev": true, "dependencies": { "glob": "^10.3.7" }, @@ -48863,9 +50320,9 @@ "dev": true }, "node_modules/rollup": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.1.tgz", - "integrity": "sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", + "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -48878,21 +50335,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.1", - "@rollup/rollup-android-arm64": "4.14.1", - "@rollup/rollup-darwin-arm64": "4.14.1", - "@rollup/rollup-darwin-x64": "4.14.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.1", - "@rollup/rollup-linux-arm64-gnu": "4.14.1", - "@rollup/rollup-linux-arm64-musl": "4.14.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.1", - "@rollup/rollup-linux-riscv64-gnu": "4.14.1", - "@rollup/rollup-linux-s390x-gnu": "4.14.1", - "@rollup/rollup-linux-x64-gnu": "4.14.1", - "@rollup/rollup-linux-x64-musl": "4.14.1", - "@rollup/rollup-win32-arm64-msvc": "4.14.1", - "@rollup/rollup-win32-ia32-msvc": "4.14.1", - "@rollup/rollup-win32-x64-msvc": "4.14.1", + "@rollup/rollup-android-arm-eabi": "4.14.3", + "@rollup/rollup-android-arm64": "4.14.3", + "@rollup/rollup-darwin-arm64": "4.14.3", + "@rollup/rollup-darwin-x64": "4.14.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", + "@rollup/rollup-linux-arm-musleabihf": "4.14.3", + "@rollup/rollup-linux-arm64-gnu": "4.14.3", + "@rollup/rollup-linux-arm64-musl": "4.14.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", + "@rollup/rollup-linux-riscv64-gnu": "4.14.3", + "@rollup/rollup-linux-s390x-gnu": "4.14.3", + "@rollup/rollup-linux-x64-gnu": "4.14.3", + "@rollup/rollup-linux-x64-musl": "4.14.3", + "@rollup/rollup-win32-arm64-msvc": "4.14.3", + "@rollup/rollup-win32-ia32-msvc": "4.14.3", + "@rollup/rollup-win32-x64-msvc": "4.14.3", "fsevents": "~2.3.2" } }, @@ -48960,6 +50418,15 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "dev": true }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -48976,7 +50443,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "get-intrinsic": "^1.2.4", @@ -48993,13 +50459,26 @@ "node_modules/safe-array-concat/node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" }, "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] }, "node_modules/safe-json-stringify": { "version": "1.2.0", @@ -49011,7 +50490,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", - "dev": true, "dependencies": { "call-bind": "^1.0.6", "es-errors": "^1.3.0", @@ -49273,6 +50751,33 @@ "randombytes": "^2.1.0" } }, + "node_modules/serialport": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-12.0.0.tgz", + "integrity": "sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "12.0.1", + "@serialport/parser-byte-length": "12.0.0", + "@serialport/parser-cctalk": "12.0.0", + "@serialport/parser-delimiter": "12.0.0", + "@serialport/parser-inter-byte-timeout": "12.0.0", + "@serialport/parser-packet-length": "12.0.0", + "@serialport/parser-readline": "12.0.0", + "@serialport/parser-ready": "12.0.0", + "@serialport/parser-regex": "12.0.0", + "@serialport/parser-slip-encoder": "12.0.0", + "@serialport/parser-spacepacket": "12.0.0", + "@serialport/stream": "12.0.0", + "debug": "4.3.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, "node_modules/serve-handler": { "version": "6.1.5", "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.5.tgz", @@ -49784,7 +51289,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", @@ -49798,7 +51302,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", - "dev": true, "engines": { "node": ">=10" } @@ -49947,9 +51450,9 @@ } }, "node_modules/socks": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", - "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "dependencies": { "ip-address": "^9.0.5", @@ -50345,6 +51848,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, "node_modules/spawn-please": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/spawn-please/-/spawn-please-2.0.2.tgz", @@ -50506,20 +52015,6 @@ "wbuf": "^1.7.3" } }, - "node_modules/spdy-transport/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/split": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", @@ -50611,15 +52106,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/ssri/node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -50742,18 +52228,6 @@ "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true }, - "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, - "dependencies": { - "internal-slot": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/store2": { "version": "2.14.3", "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.3.tgz", @@ -50761,12 +52235,12 @@ "dev": true }, "node_modules/storybook": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.0.6.tgz", - "integrity": "sha512-QcQl8Sj77scGl0s9pw+cSPFmXK9DPogEkOceG12B2PqdS23oGkaBt24292Y3W5TTMVNyHtRTRB/FqPwK3FOdmA==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.0.8.tgz", + "integrity": "sha512-9gTnnAakJBtMCg8oPGqnpy7g/C3Tj2IWiVflHiFg1SDD9zXBoc4mZhaYPTne4LRBUhXk7XuFagKfiRN2V/MuKA==", "dev": true, "dependencies": { - "@storybook/cli": "8.0.6" + "@storybook/cli": "8.0.8" }, "bin": { "sb": "index.js", @@ -50777,6 +52251,25 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/storybook-addon-mantine": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/storybook-addon-mantine/-/storybook-addon-mantine-4.0.2.tgz", + "integrity": "sha512-nOxaIDTuZ0+Fh3vgsn3JRyCW/uCYBe9m0JxIQyXjMCa0ecl8op6wWdewAQ6IoeMSw33l/gZWHL0BLyTuqqX+kg==", + "dev": true, + "peerDependencies": { + "@mantine/core": "^7.0.0", + "@mantine/hooks": "^7.0.0", + "@storybook/blocks": "^8.0.0", + "@storybook/components": "^8.0.0", + "@storybook/core-events": "^8.0.0", + "@storybook/manager-api": "^8.0.0", + "@storybook/preview-api": "^8.0.0", + "@storybook/theming": "^8.0.0", + "@storybook/types": "^8.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/stream": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz", @@ -50795,19 +52288,6 @@ "readable-stream": "^3.5.0" } }, - "node_modules/stream-browserify/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -50825,6 +52305,36 @@ "readable-stream": "^2.1.4" } }, + "node_modules/stream-meter/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/stream-meter/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/stream-meter/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -50855,11 +52365,11 @@ } }, "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dependencies": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" } }, "node_modules/string-argv": { @@ -50915,11 +52425,40 @@ "readable-stream": "^2.1.0" } }, + "node_modules/string-to-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/string-to-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/string-to-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -50937,7 +52476,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -50951,7 +52489,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -50959,14 +52496,12 @@ "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -51004,7 +52539,6 @@ "version": "1.2.9", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -51022,7 +52556,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -51036,7 +52569,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -51090,7 +52622,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -51106,7 +52637,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -51118,7 +52648,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -51203,9 +52732,9 @@ "dev": true }, "node_modules/stripe": { - "version": "14.24.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.24.0.tgz", - "integrity": "sha512-r0JWz2quThXsFbp1pevkAAoDk4sw3kFMQEc2qvxMFUOhw/SFGqtAGz4vQgP/fMWzO28ljBNEiz68KqRx0JS3dw==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-15.1.0.tgz", + "integrity": "sha512-yMsP9VWsQKkmR14E7MhtFDYKCoYCnhG/tLtiMnNiSiVisDezU2OKJ2T9myufYKl922H85nvCgaczyBLovaJTVA==", "dev": true, "dependencies": { "@types/node": ">=8.1.0", @@ -51596,19 +53125,19 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.0.1.tgz", + "integrity": "sha512-IjMhdQMZFpKsHEQT3woZVxBtCQY+0wk3CVxdRkGXEgyGa0dNS/ehPvOMr2nmfC7x5Zj2N+l6yZUpmICjLGS35w==", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/tar-fs": { @@ -51645,66 +53174,42 @@ "node": ">=6" } }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", "engines": { - "node": ">= 6" + "node": ">=8" } }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "node_modules/tar/node_modules/minizlib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", "dependencies": { - "minipass": "^3.0.0" + "minipass": "^7.0.4", + "rimraf": "^5.0.5" }, "engines": { - "node": ">= 8" + "node": ">= 18" } }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dependencies": { - "yallist": "^4.0.0" - }, + "node_modules/tar/node_modules/minizlib/node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/tar/node_modules/minipass": { + "node_modules/tar/node_modules/yallist": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "bin": { - "mkdirp": "bin/cmd.js" - }, + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/telejson": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/telejson/-/telejson-7.2.0.tgz", @@ -52107,6 +53612,33 @@ "xtend": "~4.0.1" } }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -52131,15 +53663,15 @@ "dev": true }, "node_modules/tinybench": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", - "integrity": "sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.7.0.tgz", + "integrity": "sha512-Qgayeb106x2o4hNzNjsZEfFziw8IbKqtbXBjVh7VIZfBxfD5M4gWtpyx5+YTae2gJ6Y6Dz/KLepiv16RFeQWNA==", "dev": true }, "node_modules/tinypool": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.3.tgz", - "integrity": "sha512-Ud7uepAklqRH1bvwy22ynrliC7Dljz7Tm8M/0RBUW+YRa4YHhZ6e4PpgE+fu1zr/WqB1kbeuVrdfeuyIBpy4tw==", + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", "dev": true, "engines": { "node": ">=14.0.0" @@ -52222,7 +53754,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, "engines": { "node": ">=6" } @@ -52264,12 +53795,19 @@ } }, "node_modules/traverse": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", - "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", - "dev": true, + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", + "integrity": "sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==", + "dependencies": { + "gopd": "^1.0.1", + "typedarray.prototype.slice": "^1.0.3", + "which-typed-array": "^1.1.15" + }, "engines": { - "node": "*" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/tree-kill": { @@ -52810,7 +54348,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", @@ -52824,7 +54361,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", @@ -52843,7 +54379,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", - "dev": true, "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.7", @@ -52863,7 +54398,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", @@ -52893,10 +54427,29 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typedarray.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.3.tgz", + "integrity": "sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-errors": "^1.3.0", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-offset": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz", - "integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -52957,7 +54510,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", @@ -53036,11 +54588,6 @@ "tiny-inflate": "^1.0.0" } }, - "node_modules/unicode-trie/node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" - }, "node_modules/unified": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.4.tgz", @@ -53249,21 +54796,16 @@ } }, "node_modules/unzipper": { - "version": "0.10.14", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", - "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.11.2.tgz", + "integrity": "sha512-SJZV4XldfDk3CQnTU3H/EUKOic3pwYvlavh7C0qvPOxzGC4XQxNFDl+RuIqhXyNbvRe4ocLIpFnlKY+2NpJ2bg==", "dev": true, "dependencies": { "big-integer": "^1.6.17", - "binary": "~0.3.0", "bluebird": "~3.4.1", - "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", - "graceful-fs": "^4.2.2", - "listenercount": "~1.0.1", - "readable-stream": "~2.3.6", - "setimmediate": "~1.0.4" + "graceful-fs": "^4.2.2" } }, "node_modules/update-browserslist-db": { @@ -53803,9 +55345,9 @@ } }, "node_modules/vite-node": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.4.0.tgz", - "integrity": "sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.5.0.tgz", + "integrity": "sha512-tV8h6gMj6vPzVCa7l+VGq9lwoJjW8Y79vst8QZZGiuRAfijU+EEWuc0kFpmndQrWhMMhet1jdSF+40KSZUqIIw==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -53831,16 +55373,16 @@ "dev": true }, "node_modules/vitest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.4.0.tgz", - "integrity": "sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.5.0.tgz", + "integrity": "sha512-d8UKgR0m2kjdxDWX6911uwxout6GHS0XaGH1cksSIVVG8kRlE7G7aBw7myKQCvDI5dT4j7ZMa+l706BIORMDLw==", "dev": true, "dependencies": { - "@vitest/expect": "1.4.0", - "@vitest/runner": "1.4.0", - "@vitest/snapshot": "1.4.0", - "@vitest/spy": "1.4.0", - "@vitest/utils": "1.4.0", + "@vitest/expect": "1.5.0", + "@vitest/runner": "1.5.0", + "@vitest/snapshot": "1.5.0", + "@vitest/spy": "1.5.0", + "@vitest/utils": "1.5.0", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", @@ -53852,9 +55394,9 @@ "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", - "tinypool": "^0.8.2", + "tinypool": "^0.8.3", "vite": "^5.0.0", - "vite-node": "1.4.0", + "vite-node": "1.5.0", "why-is-node-running": "^2.2.2" }, "bin": { @@ -53869,8 +55411,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.4.0", - "@vitest/ui": "1.4.0", + "@vitest/browser": "1.5.0", + "@vitest/ui": "1.5.0", "happy-dom": "*", "jsdom": "*" }, @@ -54198,9 +55740,9 @@ } }, "node_modules/webpack-bundle-analyzer": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", - "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", "dev": true, "dependencies": { "@discoveryjs/json-ext": "0.5.7", @@ -54211,7 +55753,6 @@ "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", - "is-plain-object": "^5.0.0", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", @@ -54701,6 +56242,18 @@ "node": ">=18" } }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", @@ -54767,7 +56320,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, "dependencies": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -54973,19 +56525,6 @@ "node": ">= 12.0.0" } }, - "node_modules/winston-transport/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/winston/node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -54994,19 +56533,6 @@ "node": ">=0.1.90" } }, - "node_modules/winston/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/wonka": { "version": "4.0.15", "resolved": "https://registry.npmjs.org/wonka/-/wonka-4.0.15.tgz", @@ -55022,7 +56548,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -55040,7 +56565,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -55057,7 +56581,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -55066,7 +56589,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -55080,14 +56602,12 @@ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -55101,7 +56621,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -55113,7 +56632,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "engines": { "node": ">=12" }, @@ -55322,14 +56840,12 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", - "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", - "bin": { - "yaml": "bin.mjs" - }, + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, "engines": { - "node": ">= 14" + "node": ">= 6" } }, "node_modules/yargs": { @@ -55456,18 +56972,19 @@ }, "packages/agent": { "name": "@medplum/agent", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/hl7": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/hl7": "3.1.3", "dcmjs-dimse": "0.1.27", "node-windows": "1.0.0-beta.8", + "serialport": "12.0.0", "ws": "8.16.0" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", "@types/async-eventemitter": "0.2.4", "@types/node-windows": "0.1.6", "@types/ws": "8.5.10", @@ -55480,26 +56997,26 @@ }, "packages/app": { "name": "@medplum/app", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/dropzone": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", + "@mantine/core": "7.8.0", + "@mantine/dropzone": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", "@testing-library/jest-dom": "6.4.2", - "@testing-library/react": "14.3.0", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@testing-library/react": "15.0.2", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", @@ -55512,11 +57029,11 @@ }, "packages/bot-layer": { "name": "@medplum/bot-layer", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", "form-data": "4.0.0", "jose": "5.2.4", "node-fetch": "2.7.0", @@ -55531,7 +57048,7 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "form-data": "^4.0.0", "node-fetch": "^2.7.0", "pdfmake": "^0.2.7", @@ -55541,15 +57058,15 @@ }, "packages/cdk": { "name": "@medplum/cdk", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.535.0", - "@medplum/core": "3.1.2", - "aws-cdk-lib": "2.136.0", - "cdk": "2.136.0", - "cdk-nag": "2.28.84", - "cdk-serverless-clamscan": "2.6.145", + "@medplum/core": "3.1.3", + "aws-cdk-lib": "2.137.0", + "cdk": "2.137.0", + "cdk-nag": "2.28.89", + "cdk-serverless-clamscan": "2.6.150", "constructs": "10.3.0" }, "engines": { @@ -55558,34 +57075,33 @@ }, "packages/cli": { "name": "@medplum/cli", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-acm": "3.549.0", - "@aws-sdk/client-cloudformation": "3.549.0", - "@aws-sdk/client-cloudfront": "3.549.0", - "@aws-sdk/client-ecs": "3.549.0", - "@aws-sdk/client-s3": "3.550.0", - "@aws-sdk/client-ssm": "3.549.0", - "@aws-sdk/client-sts": "3.549.0", + "@aws-sdk/client-acm": "3.554.0", + "@aws-sdk/client-cloudformation": "3.555.0", + "@aws-sdk/client-cloudfront": "3.554.0", + "@aws-sdk/client-ecs": "3.554.0", + "@aws-sdk/client-s3": "3.554.0", + "@aws-sdk/client-ssm": "3.554.0", + "@aws-sdk/client-sts": "3.554.0", "@aws-sdk/types": "3.535.0", - "@medplum/core": "3.1.2", - "@medplum/hl7": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/hl7": "3.1.3", "aws-sdk-client-mock": "4.0.0", "commander": "12.0.0", "dotenv": "16.4.5", "fast-glob": "3.3.2", "node-fetch": "2.7.0", - "tar": "6.2.1" + "tar": "7.0.1" }, "bin": { "medplum": "dist/cjs/index.cjs" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@types/node-fetch": "2.6.11", - "@types/tar": "6.1.12" + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@types/node-fetch": "2.6.11" }, "engines": { "node": ">=18.0.0" @@ -55601,11 +57117,11 @@ }, "packages/core": { "name": "@medplum/core", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", "jest-websocket-mock": "2.5.0" }, "engines": { @@ -55622,7 +57138,7 @@ }, "packages/definitions": { "name": "@medplum/definitions", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -55630,7 +57146,7 @@ }, "packages/docs": { "name": "@medplum/docs", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { "@docusaurus/core": "3.2.1", @@ -55640,9 +57156,9 @@ "@docusaurus/tsconfig": "3.2.1", "@docusaurus/types": "3.2.1", "@mdx-js/react": "3.0.1", - "@medplum/core": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", "@svgr/webpack": "8.1.0", "clsx": "2.1.0", "file-loader": "6.2.0", @@ -55650,9 +57166,9 @@ "raw-loader": "4.0.2", "react": "18.2.0", "react-dom": "18.2.0", - "react-intersection-observer": "9.8.1", + "react-intersection-observer": "9.8.2", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "url-loader": "4.1.1" }, "engines": { @@ -55661,11 +57177,11 @@ }, "packages/eslint-config": { "name": "@medplum/eslint-config", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { - "@typescript-eslint/eslint-plugin": "7.5.0", - "@typescript-eslint/parser": "7.5.0", + "@typescript-eslint/eslint-plugin": "7.6.0", + "@typescript-eslint/parser": "7.6.0", "eslint": "8.57.0", "eslint-plugin-jsdoc": "48.2.3", "eslint-plugin-json-files": "4.1.0", @@ -55687,13 +57203,13 @@ }, "packages/examples": { "name": "@medplum/examples", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { "@jest/globals": "29.7.0", - "@medplum/core": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", "jest": "29.7.0" }, "engines": { @@ -55702,7 +57218,7 @@ }, "packages/expo-polyfills": { "name": "@medplum/expo-polyfills", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { "base-64": "1.0.0", @@ -55710,9 +57226,9 @@ "text-encoding": "0.7.0" }, "devDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "@types/base-64": "1.0.2", - "@types/react": "18.2.74", + "@types/react": "18.2.78", "@types/text-encoding": "0.0.39", "esbuild": "0.20.2", "esbuild-node-externals": "1.13.0", @@ -55722,7 +57238,7 @@ "ts-jest": "29.1.2" }, "peerDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "expo": "*", "expo-crypto": "^12.6.0", "expo-secure-store": "^12.3.1", @@ -55733,12 +57249,12 @@ }, "packages/fhir-router": { "name": "@medplum/fhir-router", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", "dataloader": "2.2.2", "graphql": "16.8.1", "rfc6902": "5.1.1" @@ -55749,7 +57265,7 @@ }, "packages/fhirtypes": { "name": "@medplum/fhirtypes", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -55757,22 +57273,22 @@ }, "packages/generator": { "name": "@medplum/generator", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", "@types/json-schema": "7.0.15", - "@types/pg": "8.11.4", + "@types/pg": "8.11.5", "@types/unzipper": "0.10.9", "fast-xml-parser": "4.3.6", "fhirpath": "3.11.0", "mkdirp": "3.0.1", "node-stream-zip": "1.15.0", "pg": "8.11.5", - "tinybench": "2.6.0", - "unzipper": "0.10.14" + "tinybench": "2.7.0", + "unzipper": "0.11.2" }, "engines": { "node": ">=18.0.0" @@ -55802,23 +57318,23 @@ }, "packages/graphiql": { "name": "@medplum/graphiql", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { "@graphiql/react": "0.21.0", "@graphiql/toolkit": "0.9.1", - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "graphiql": "3.2.0", "graphql": "16.8.1", "graphql-ws": "5.16.0", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "vite": "5.2.8" @@ -55829,14 +57345,14 @@ }, "packages/health-gorilla": { "name": "@medplum/health-gorilla", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/fhirtypes": "3.1.2" + "@medplum/core": "3.1.3", + "@medplum/fhirtypes": "3.1.3" }, "devDependencies": { - "@medplum/mock": "3.1.2" + "@medplum/mock": "3.1.3" }, "engines": { "node": ">=18.0.0" @@ -55844,13 +57360,13 @@ }, "packages/hl7": { "name": "@medplum/hl7", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { - "@medplum/core": "3.1.2" + "@medplum/core": "3.1.3" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2" + "@medplum/fhirtypes": "3.1.3" }, "engines": { "node": ">=18.0.0" @@ -55858,13 +57374,13 @@ }, "packages/mock": { "name": "@medplum/mock", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhir-router": "3.1.2", - "@medplum/fhirtypes": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhir-router": "3.1.3", + "@medplum/fhirtypes": "3.1.3", "dataloader": "2.2.2", "jest-websocket-mock": "2.5.0", "rfc6902": "5.1.1" @@ -55878,47 +57394,48 @@ }, "packages/react": { "name": "@medplum/react", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react-hooks": "3.1.2", - "@storybook/addon-actions": "8.0.6", - "@storybook/addon-essentials": "8.0.6", - "@storybook/addon-links": "8.0.6", - "@storybook/addon-storysource": "8.0.6", - "@storybook/blocks": "^8.0.6", - "@storybook/builder-vite": "8.0.6", - "@storybook/react": "8.0.6", - "@storybook/react-vite": "8.0.6", - "@tabler/icons-react": "3.1.0", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react-hooks": "3.1.3", + "@storybook/addon-actions": "8.0.8", + "@storybook/addon-essentials": "8.0.8", + "@storybook/addon-links": "8.0.8", + "@storybook/addon-storysource": "8.0.8", + "@storybook/blocks": "^8.0.8", + "@storybook/builder-vite": "8.0.8", + "@storybook/react": "8.0.8", + "@storybook/react-vite": "8.0.8", + "@tabler/icons-react": "3.2.0", "@testing-library/dom": "10.0.0", "@testing-library/jest-dom": "6.4.2", - "@testing-library/react": "14.3.0", + "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "chromatic": "11.0.0", "jest": "29.7.0", "jest-each": "29.7.0", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "rfc6902": "5.1.1", "rimraf": "5.0.5", "sinon": "17.0.1", - "storybook": "8.0.6", - "typescript": "5.4.4", + "storybook": "8.0.8", + "storybook-addon-mantine": "4.0.2", + "typescript": "5.4.5", "vite-plugin-turbosnap": "^1.0.3" }, "engines": { @@ -55928,7 +57445,7 @@ "@mantine/core": "^7.0.0", "@mantine/hooks": "^7.0.0", "@mantine/notifications": "^7.0.0", - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "react": "^17.0.2 || ^18.0.0", "react-dom": "^17.0.2 || ^18.0.0", "rfc6902": "^5.0.1" @@ -55950,33 +57467,33 @@ }, "packages/react-hooks": { "name": "@medplum/react-hooks", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "devDependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", "@testing-library/dom": "10.0.0", "@testing-library/jest-dom": "6.4.2", - "@testing-library/react": "14.3.0", + "@testing-library/react": "15.0.2", "@types/jest": "29.5.12", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "jest": "29.7.0", "jest-each": "29.7.0", "jest-websocket-mock": "2.5.0", "react": "18.2.0", "react-dom": "18.2.0", "rimraf": "5.0.5", - "typescript": "5.4.4" + "typescript": "5.4.5" }, "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "react": "^17.0.2 || ^18.0.0", "react-dom": "^17.0.2 || ^18.0.0" } @@ -56022,21 +57539,21 @@ }, "packages/server": { "name": "@medplum/server", - "version": "3.1.2", + "version": "3.1.3", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-cloudwatch-logs": "3.549.0", - "@aws-sdk/client-lambda": "3.549.0", - "@aws-sdk/client-s3": "3.550.0", - "@aws-sdk/client-secrets-manager": "3.549.0", - "@aws-sdk/client-sesv2": "3.549.0", - "@aws-sdk/client-ssm": "3.549.0", + "@aws-sdk/client-cloudwatch-logs": "3.554.0", + "@aws-sdk/client-lambda": "3.554.0", + "@aws-sdk/client-s3": "3.554.0", + "@aws-sdk/client-secrets-manager": "3.554.0", + "@aws-sdk/client-sesv2": "3.554.0", + "@aws-sdk/client-ssm": "3.554.0", "@aws-sdk/cloudfront-signer": "3.541.0", - "@aws-sdk/lib-storage": "3.550.0", + "@aws-sdk/lib-storage": "3.554.0", "@aws-sdk/types": "3.535.0", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhir-router": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhir-router": "3.1.3", "@opentelemetry/auto-instrumentations-node": "0.44.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.50.0", "@opentelemetry/exporter-trace-otlp-proto": "0.50.0", @@ -56046,7 +57563,7 @@ "@smithy/util-stream": "2.2.0", "bcryptjs": "2.4.3", "body-parser": "1.20.2", - "bullmq": "5.5.4", + "bullmq": "5.7.1", "bytes": "3.1.2", "compression": "1.7.4", "cookie-parser": "1.4.6", @@ -56075,7 +57592,7 @@ }, "devDependencies": { "@jest/test-sequencer": "29.7.0", - "@medplum/fhirtypes": "3.1.2", + "@medplum/fhirtypes": "3.1.3", "@types/bcryptjs": "2.4.6", "@types/body-parser": "1.19.5", "@types/bytes": "3.1.4", @@ -56086,10 +57603,10 @@ "@types/express-rate-limit": "5.1.3", "@types/json-schema": "7.0.15", "@types/mailparser": "3.4.4", - "@types/node": "20.12.5", + "@types/node": "20.12.7", "@types/node-fetch": "2.6.11", "@types/nodemailer": "6.4.14", - "@types/pg": "8.11.2", + "@types/pg": "8.11.5", "@types/set-cookie-parser": "2.4.7", "@types/supertest": "6.0.2", "@types/ua-parser-js": "0.7.39", @@ -56098,7 +57615,7 @@ "@types/ws": "8.5.10", "aws-sdk-client-mock": "4.0.0", "aws-sdk-client-mock-jest": "4.0.0", - "mailparser": "3.6.9", + "mailparser": "3.7.0", "openapi3-ts": "4.3.1", "set-cookie-parser": "2.6.0", "supertest": "6.3.4", @@ -56108,17 +57625,6 @@ "engines": { "node": ">=18.0.0" } - }, - "packages/server/node_modules/@types/pg": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.2.tgz", - "integrity": "sha512-G2Mjygf2jFMU/9hCaTYxJrwdObdcnuQde1gndooZSOHsNSaCehAuwc7EIuSA34Do8Jx2yZ19KtvW8P0j4EuUXw==", - "dev": true, - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^4.0.1" - } } } } diff --git a/package.json b/package.json index 34717586e7..fc1b07a932 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "root", - "version": "3.1.2", + "version": "3.1.3", "private": true, "workspaces": [ "packages/*", @@ -16,7 +16,7 @@ "lint": "eslint .", "prettier": "prettier --write \"**/*.{ts,tsx,cts,mts,js,jsx,cjs,mjs,json}\"", "sort-package-json": "sort-package-json package.json \"packages/*/package.json\" \"examples/*/package.json\"", - "test": "turbo run test" + "test": "turbo run test --filter=!@medplum/docs" }, "prettier": { "printWidth": 120, @@ -38,12 +38,13 @@ "@babel/preset-react": "7.24.1", "@babel/preset-typescript": "7.24.1", "@cyclonedx/cyclonedx-npm": "1.16.2", - "@microsoft/api-documenter": "7.24.1", - "@microsoft/api-extractor": "7.43.0", + "@microsoft/api-documenter": "7.24.2", + "@microsoft/api-extractor": "7.43.1", "@types/jest": "29.5.12", - "@types/node": "20.12.5", + "@types/node": "20.12.7", "babel-jest": "29.7.0", "babel-preset-vite": "1.1.3", + "concurrently": "8.2.2", "cross-env": "7.0.3", "danger": "11.3.1", "esbuild": "0.20.2", @@ -60,7 +61,7 @@ "ts-node": "10.9.2", "tslib": "2.6.2", "turbo": "1.13.2", - "typescript": "5.4.4" + "typescript": "5.4.5" }, "packageManager": "npm@9.8.1", "engines": { diff --git a/packages/agent/esbuild.mjs b/packages/agent/esbuild.mjs index 37e488c6c5..3509c70a03 100644 --- a/packages/agent/esbuild.mjs +++ b/packages/agent/esbuild.mjs @@ -2,7 +2,7 @@ /* eslint no-console: "off" */ import esbuild from 'esbuild'; -import { writeFileSync } from 'fs'; +import { writeFileSync } from 'node:fs'; const options = { entryPoints: ['./src/main.ts'], diff --git a/packages/agent/package.json b/packages/agent/package.json index 14ce8c8846..b4c045c266 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/agent", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Agent", "homepage": "https://www.medplum.com/", "bugs": { @@ -23,15 +23,16 @@ "test": "jest" }, "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/hl7": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/hl7": "3.1.3", "dcmjs-dimse": "0.1.27", "node-windows": "1.0.0-beta.8", + "serialport": "12.0.0", "ws": "8.16.0" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", "@types/async-eventemitter": "0.2.4", "@types/node-windows": "0.1.6", "@types/ws": "8.5.10", diff --git a/packages/agent/src/app.test.ts b/packages/agent/src/app.test.ts index 09629626cf..b7a1c05670 100644 --- a/packages/agent/src/app.test.ts +++ b/packages/agent/src/app.test.ts @@ -50,7 +50,7 @@ describe('App', () => { }); const app = new App(medplum, agent.id as string, LogLevel.INFO); - app.healthcheckPeriod = 1000; + app.heartbeatPeriod = 1000; await app.start(); // Wait for the WebSocket to connect @@ -108,7 +108,7 @@ describe('App', () => { }); const app = new App(medplum, agent.id as string, LogLevel.INFO); - app.healthcheckPeriod = 100; + app.heartbeatPeriod = 100; await app.start(); // Wait for the WebSocket to connect diff --git a/packages/agent/src/app.ts b/packages/agent/src/app.ts index 1cfe398240..60ed2644d6 100644 --- a/packages/agent/src/app.ts +++ b/packages/agent/src/app.ts @@ -1,5 +1,4 @@ import { - AgentError, AgentMessage, AgentTransmitRequest, AgentTransmitResponse, @@ -7,21 +6,36 @@ import { Hl7Message, LogLevel, Logger, + MEDPLUM_VERSION, MedplumClient, + isValidHostname, normalizeErrorString, } from '@medplum/core'; import { Endpoint, Reference } from '@medplum/fhirtypes'; import { Hl7Client } from '@medplum/hl7'; -import { exec as _exec } from 'node:child_process'; +import { ExecException, ExecOptions, exec } from 'node:child_process'; import { isIPv4, isIPv6 } from 'node:net'; -import { promisify } from 'node:util'; -import { platform } from 'os'; +import { platform } from 'node:os'; import WebSocket from 'ws'; import { Channel } from './channel'; import { AgentDicomChannel } from './dicom'; import { AgentHl7Channel } from './hl7'; +import { AgentSerialPortChannel } from './serialport'; + +async function execAsync(command: string, options: ExecOptions): Promise<{ stdout: string; stderr: string }> { + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + exec(command, options, (ex: ExecException | null, stdout: string, stderr: string) => { + if (ex) { + const err = ex as Error; + reject(err); + return; + } + resolve({ stdout, stderr }); + }); + }); +} -const exec = promisify(_exec); +export const DEFAULT_PING_TIMEOUT = 3600; export class App { static instance: App; @@ -29,8 +43,8 @@ export class App { readonly webSocketQueue: AgentMessage[] = []; readonly channels = new Map(); readonly hl7Queue: AgentMessage[] = []; - healthcheckPeriod = 10 * 1000; - private healthcheckTimer?: NodeJS.Timeout; + heartbeatPeriod = 10 * 1000; + private heartbeatTimer?: NodeJS.Timeout; private reconnectTimer?: NodeJS.Timeout; private webSocket?: WebSocket; private webSocketWorker?: Promise; @@ -66,11 +80,11 @@ export class App { private startWebSocket(): void { this.connectWebSocket(); - this.healthcheckTimer = setInterval(() => this.healthcheck(), this.healthcheckPeriod); + this.heartbeatTimer = setInterval(() => this.heartbeat(), this.heartbeatPeriod); } - private async healthcheck(): Promise { - if (!this.webSocket && !this.reconnectTimer) { + private async heartbeat(): Promise { + if (!(this.webSocket || this.reconnectTimer)) { this.log.warn('WebSocket not connected'); this.connectWebSocket(); return; @@ -132,7 +146,7 @@ export class App { this.startWebSocketWorker(); break; case 'agent:heartbeat:request': - await this.sendToWebSocket({ type: 'agent:heartbeat:response' }); + await this.sendToWebSocket({ type: 'agent:heartbeat:response', version: MEDPLUM_VERSION }); break; case 'agent:heartbeat:response': // Do nothing @@ -146,7 +160,7 @@ export class App { case 'push': case 'agent:transmit:request': if (command.contentType === ContentType.PING) { - await this.tryPingIp(command); + await this.tryPingHost(command); } else { this.pushMessage(command); } @@ -176,6 +190,8 @@ export class App { channel = new AgentDicomChannel(this, definition, endpoint); } else if (endpoint.address.startsWith('mllp')) { channel = new AgentHl7Channel(this, definition, endpoint); + } else if (endpoint.address.startsWith('serial')) { + channel = new AgentSerialPortChannel(this, definition, endpoint); } else { this.log.error(`Unsupported endpoint type: ${endpoint.address}`); } @@ -191,9 +207,9 @@ export class App { this.log.info('Medplum service stopping...'); this.shutdown = true; - if (this.healthcheckTimer) { - clearInterval(this.healthcheckTimer); - this.healthcheckTimer = undefined; + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; } if (this.reconnectTimer) { @@ -265,27 +281,51 @@ export class App { } } - private async tryPingIp(message: AgentTransmitRequest): Promise { + // This covers Windows, Linux, and Mac + private getPingCommand(host: string, count = 1): string { + return platform() === 'win32' ? `ping /n ${count} ${host}` : `ping -c ${count} ${host}`; + } + + private async tryPingHost(message: AgentTransmitRequest): Promise { try { - if (message.body && message.body !== 'PING') { - const warnMsg = 'Message body present but unused. Body should be empty for a ping request.'; + if (message.body && !message.body.startsWith('PING')) { + const warnMsg = + 'Message body present but unused. Body for a ping request should be empty or a message formatted as `PING[ count]`.'; this.log.warn(warnMsg); } - if (!isIPv4(message.remote)) { - let errMsg = `Attempted to ping invalid IP: ${message.remote}`; - if (isIPv6(message.remote)) { - errMsg = `Attempted to ping an IPv6 address: ${message.remote}\n\nIPv6 is currently unsupported.`; - } + + if (isIPv6(message.remote)) { + const errMsg = `Attempted to ping an IPv6 address: ${message.remote}\n\nIPv6 is currently unsupported.`; this.log.error(errMsg); throw new Error(errMsg); } - // This covers Windows, Linux, and Mac - const { stderr, stdout } = await exec( - platform() === 'win32' ? `ping ${message.remote}` : `ping -c 4 ${message.remote}` - ); + + if (!(isIPv4(message.remote) || isValidHostname(message.remote))) { + const errMsg = `Attempted to ping an invalid host.\n\n"${message.remote}" is not a valid IPv4 address or a resolvable hostname.`; + this.log.error(errMsg); + throw new Error(errMsg); + } + + const pingCountAsStr = message.body.startsWith('PING') ? message.body.split(' ')?.[1] ?? '' : ''; + let pingCount: number | undefined = undefined; + + if (pingCountAsStr !== '') { + pingCount = Number.parseInt(pingCountAsStr, 10); + if (Number.isNaN(pingCount)) { + throw new Error( + `Unable to ping ${message.remote} "${pingCountAsStr}" times. "${pingCountAsStr}" is not a number.` + ); + } + } + + const { stdout, stderr } = await execAsync(this.getPingCommand(message.remote, pingCount), { + timeout: DEFAULT_PING_TIMEOUT, + }); + if (stderr) { - throw new Error(`Received on stderr:\n\n${stderr}`); + throw new Error(`Received on stderr:\n\n${stderr.trim()}`); } + const result = stdout.trim(); this.log.info(`Ping result for ${message.remote}:\n\n${result}`); this.addToWebSocketQueue({ @@ -294,14 +334,20 @@ export class App { contentType: ContentType.PING, remote: message.remote, callback: message.callback, + statusCode: 200, body: result, } satisfies AgentTransmitResponse); } catch (err) { - this.log.error(`Error during ping attempt to ${message.remote ?? 'NO_IP_GIVEN'}: ${normalizeErrorString(err)}`); + this.log.error(`Error during ping attempt to ${message.remote ?? 'NO_HOST_GIVEN'}: ${normalizeErrorString(err)}`); this.addToWebSocketQueue({ - type: 'agent:error', - body: (err as Error).message, - } satisfies AgentError); + type: 'agent:transmit:response', + channel: message.channel, + contentType: ContentType.TEXT, + remote: message.remote, + callback: message.callback, + statusCode: 500, + body: normalizeErrorString(err), + } satisfies AgentTransmitResponse); } } @@ -327,7 +373,7 @@ export class App { const address = new URL(message.remote); const client = new Hl7Client({ host: address.hostname, - port: parseInt(address.port, 10), + port: Number.parseInt(address.port, 10), }); client @@ -340,11 +386,21 @@ export class App { remote: message.remote, callback: message.callback, contentType: ContentType.HL7_V2, + statusCode: 200, body: response.toString(), - }); + } satisfies AgentTransmitResponse); }) .catch((err) => { this.log.error(`HL7 error: ${normalizeErrorString(err)}`); + this.addToWebSocketQueue({ + type: 'agent:transmit:response', + channel: message.channel, + remote: message.remote, + callback: message.callback, + contentType: ContentType.TEXT, + statusCode: 500, + body: normalizeErrorString(err), + } satisfies AgentTransmitResponse); }) .finally(() => { client.close(); diff --git a/packages/agent/src/net-utils.test.ts b/packages/agent/src/net-utils.test.ts index 5c156e6ef8..2234f441cb 100644 --- a/packages/agent/src/net-utils.test.ts +++ b/packages/agent/src/net-utils.test.ts @@ -1,7 +1,17 @@ -import { AgentMessage, allOk, ContentType, LogLevel, sleep } from '@medplum/core'; +import { + AgentMessage, + AgentTransmitRequest, + AgentTransmitResponse, + allOk, + ContentType, + generateId, + LogLevel, + sleep, +} from '@medplum/core'; import { Agent, Resource } from '@medplum/fhirtypes'; import { MockClient } from '@medplum/mock'; import { Client, Server } from 'mock-socket'; +import child_process, { ChildProcess } from 'node:child_process'; import { App } from './app'; jest.mock('node-windows'); @@ -9,120 +19,455 @@ jest.mock('node-windows'); const medplum = new MockClient(); describe('Agent Net Utils', () => { - let mockServer: Server; - let mySocket: Client | undefined = undefined; - let wsClient: Client; - let app: App; - let onMessage: (command: AgentMessage) => void; + let originalLog: typeof console.log; - beforeAll(async () => { + beforeAll(() => { + originalLog = console.log; console.log = jest.fn(); + }); + + afterAll(() => { + console.log = originalLog; + }); + + describe('Ping -- Within One App Instance', () => { + let mockServer: Server; + let wsClient: Client; + let app: App; + let onMessage: (command: AgentMessage) => void; + let timer: ReturnType | undefined; + + beforeAll(async () => { + medplum.router.router.add('POST', ':resourceType/:id/$execute', async () => { + return [allOk, {} as Resource]; + }); + + mockServer = new Server('wss://example.com/ws/agent'); + + mockServer.on('connection', (socket) => { + wsClient = socket; + socket.on('message', (data) => { + const command = JSON.parse((data as Buffer).toString('utf8')) as AgentMessage; + if (command.type === 'agent:connect:request') { + socket.send( + Buffer.from( + JSON.stringify({ + type: 'agent:connect:response', + }) + ) + ); + } else { + onMessage(command); + } + }); + }); + + const agent = await medplum.createResource({ + resourceType: 'Agent', + } as Agent); + + // Start the app + app = new App(medplum, agent.id as string, LogLevel.INFO); + await app.start(); - medplum.router.router.add('POST', ':resourceType/:id/$execute', async () => { - return [allOk, {} as Resource]; + // Wait for the WebSocket to connect + // eslint-disable-next-line no-unmodified-loop-condition + while (!wsClient) { + await sleep(100); + } }); - mockServer = new Server('wss://example.com/ws/agent'); - - mockServer.on('connection', (socket) => { - mySocket = socket; - socket.on('message', (data) => { - const command = JSON.parse((data as Buffer).toString('utf8')) as AgentMessage; - if (command.type === 'agent:connect:request') { - socket.send( - Buffer.from( - JSON.stringify({ - type: 'agent:connect:response', - }) - ) - ); - } else { - onMessage(command); - } + afterAll(async () => { + app.stop(); + await new Promise((resolve) => { + mockServer.stop(resolve); }); + // @ts-expect-error We know by the time it's used again this will be redefined + wsClient = undefined; }); - const agent = await medplum.createResource({ - resourceType: 'Agent', - } as Agent); + afterEach(() => { + clearTimeout(timer); + }); - // Start the app - app = new App(medplum, agent.id as string, LogLevel.INFO); - await app.start(); + test('Valid ping to IP', async () => { + let resolve: (value: AgentMessage) => void; + let reject: (error: Error) => void; - // Wait for the WebSocket to connect - // eslint-disable-next-line no-unmodified-loop-condition - while (!mySocket) { - await sleep(100); - } + const messageReceived = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); - wsClient = mySocket as unknown as Client; - }); + onMessage = (command) => resolve(command); + expect(wsClient).toBeDefined(); - afterAll(() => { - app.stop(); - mockServer.stop(); - }); + const callback = generateId(); + wsClient.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:request', + contentType: ContentType.PING, + remote: '127.0.0.1', + callback, + body: 'PING', + } satisfies AgentTransmitRequest) + ) + ); + + timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + await expect(messageReceived).resolves.toMatchObject>({ + type: 'agent:transmit:response', + callback, + statusCode: 200, + body: expect.stringMatching(/ping statistics/), + }); + }); + + test('Valid ping to domain name', async () => { + let resolve: (value: AgentMessage) => void; + let reject: (error: Error) => void; + + const messageReceived = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); - test('Ping -- valid', async () => { - let resolve: (value: AgentMessage) => void; - let reject: (error: Error) => void; + onMessage = (command) => resolve(command); + expect(wsClient).toBeDefined(); + + const callback = generateId(); + wsClient.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:request', + contentType: ContentType.PING, + remote: 'localhost', + callback, + body: 'PING', + } satisfies AgentTransmitRequest) + ) + ); + + timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + await expect(messageReceived).resolves.toMatchObject>({ + type: 'agent:transmit:response', + callback, + statusCode: 200, + body: expect.stringMatching(/ping statistics/), + }); + }); + + test('Invalid remote', async () => { + let resolve: (value: AgentMessage) => void; + let reject: (error: Error) => void; + + const messageReceived = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + onMessage = (command) => resolve(command); + + expect(wsClient).toBeDefined(); + + const callback = generateId(); + wsClient.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:request', + contentType: ContentType.PING, + remote: 'https://localhost:3001', + callback, + body: 'PING 1', + } satisfies AgentTransmitRequest) + ) + ); + + timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + await expect(messageReceived).resolves.toMatchObject>({ + type: 'agent:transmit:response', + contentType: ContentType.TEXT, + statusCode: 500, + callback, + body: expect.stringMatching(/invalid host/i), + }); + }); + + test('Invalid ping body -- Random message', async () => { + let resolve: (value: AgentMessage) => void; + let reject: (error: Error) => void; + + const messageReceived = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + onMessage = (command) => resolve(command); + + expect(wsClient).toBeDefined(); + + const callback = generateId(); + wsClient.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:request', + contentType: ContentType.PING, + remote: '127.0.0.1', + callback, + body: 'Hello, Medplum!', + } satisfies AgentTransmitRequest) + ) + ); + + timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + await expect(messageReceived).resolves.toMatchObject>({ + type: 'agent:transmit:response', + contentType: ContentType.PING, + statusCode: 200, + callback, + body: expect.stringMatching(/ping statistics/i), + }); - const messageReceived = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; + expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/message body present but unused/i)); }); - onMessage = (command) => resolve(command); - - expect(wsClient).toBeDefined(); - wsClient.send( - Buffer.from( - JSON.stringify({ - type: 'agent:transmit:request', - contentType: ContentType.PING, - remote: '127.0.0.1', - body: 'PING', - }) - ) - ); - - const timer = setTimeout(() => { - reject(new Error('Timeout')); - }, 3500); - - await expect(messageReceived).resolves.toMatchObject({ type: 'agent:transmit:response', body: expect.any(String) }); - clearTimeout(timer); + test('Invalid ping body -- non-numeric first arg', async () => { + let resolve: (value: AgentMessage) => void; + let reject: (error: Error) => void; + + const messageReceived = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + onMessage = (command) => resolve(command); + + expect(wsClient).toBeDefined(); + + const callback = generateId(); + wsClient.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:request', + contentType: ContentType.PING, + remote: '127.0.0.1', + callback, + body: 'PING JOHN', + } satisfies AgentTransmitRequest) + ) + ); + + timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + await expect(messageReceived).resolves.toMatchObject>({ + type: 'agent:transmit:response', + contentType: ContentType.TEXT, + statusCode: 500, + callback, + body: expect.stringMatching(/is not a number/i), + }); + }); }); - test('Ping -- non-IP remote', async () => { - let resolve: (value: AgentMessage) => void; - let reject: (error: Error) => void; + describe('Ping -- Edge Cases', () => { + let mockServer: Server; + let wsClient: Client; + let app: App; + let onMessage: (command: AgentMessage) => void; + let timer: ReturnType; + + beforeEach(async () => { + medplum.router.router.add('POST', ':resourceType/:id/$execute', async () => { + return [allOk, {} as Resource]; + }); + + mockServer = new Server('wss://example.com/ws/agent'); + + mockServer.on('connection', (socket) => { + wsClient = socket; + socket.on('message', (data) => { + const command = JSON.parse((data as Buffer).toString('utf8')) as AgentMessage; + if (command.type === 'agent:connect:request') { + socket.send( + Buffer.from( + JSON.stringify({ + type: 'agent:connect:response', + }) + ) + ); + } else { + onMessage(command); + } + }); + }); + + const agent = await medplum.createResource({ + resourceType: 'Agent', + } as Agent); + + // Start the app + app = new App(medplum, agent.id as string, LogLevel.INFO); + await app.start(); + + // Wait for the WebSocket to connect + // eslint-disable-next-line no-unmodified-loop-condition + while (!wsClient) { + await sleep(100); + } + }); + + afterEach(async () => { + app.stop(); + await new Promise((resolve) => { + mockServer.stop(resolve); + }); + clearTimeout(timer); + // @ts-expect-error We know that in each beforeEach this is redefined + wsClient = undefined; + }); + + test('Ping times out', async () => { + let resolve: (value: AgentMessage) => void; + let reject: (error: Error) => void; + + let messageReceived = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + onMessage = (command) => resolve(command); + + expect(wsClient).toBeDefined(); + + let callback = generateId(); + wsClient.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:request', + contentType: ContentType.PING, + remote: '127.0.0.1', + callback, + body: 'PING 1', + } satisfies AgentTransmitRequest) + ) + ); + + timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + // We can ping localhost, woohoo + await expect(messageReceived).resolves.toMatchObject>({ + type: 'agent:transmit:response', + body: expect.stringMatching(/ping statistics/i), + contentType: ContentType.PING, + callback, + statusCode: 200, + }); + + // Setup for a ping that will timeout + messageReceived = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + clearTimeout(timer); + timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + onMessage = (command) => resolve(command); + + // We are gonna make ping fail after a timeout + jest.spyOn(child_process, 'exec').mockImplementationOnce((_command, _options, callback): ChildProcess => { + setTimeout(() => { + callback?.(new Error('Ping command timeout'), '', ''); + }, 50); + return new ChildProcess(); + }); + + callback = generateId(); + wsClient.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:request', + contentType: ContentType.PING, + remote: '127.0.0.1', + callback, + body: 'PING 1', + } satisfies AgentTransmitRequest) + ) + ); - const messageReceived = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; + // We should get a timeout error + await expect(messageReceived).resolves.toMatchObject>({ + type: 'agent:transmit:response', + contentType: ContentType.TEXT, + callback, + statusCode: 500, + body: expect.stringMatching(/Ping command timeout/), + }); }); - onMessage = (command) => resolve(command); - - expect(wsClient).toBeDefined(); - wsClient.send( - Buffer.from( - JSON.stringify({ - type: 'agent:transmit:request', - contentType: ContentType.PING, - remote: 'https://localhost:3001', - body: 'PING', - }) - ) - ); - - const timer = setTimeout(() => { - reject(new Error('Timeout')); - }, 3500); - - await expect(messageReceived).resolves.toMatchObject({ type: 'agent:error', body: expect.any(String) }); - clearTimeout(timer); + test('No ping command available', async () => { + // We are gonna make ping fail after a timeout + jest.spyOn(child_process, 'exec').mockImplementationOnce((_command, _options, callback): ChildProcess => { + setTimeout(() => { + callback?.(new Error('Ping not found'), '', ''); + }, 50); + return new ChildProcess(); + }); + + let resolve: (value: AgentMessage) => void; + let reject: (error: Error) => void; + + const messageReceived = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + onMessage = (command) => resolve(command); + expect(wsClient).toBeDefined(); + + const callback = generateId(); + wsClient.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:request', + contentType: ContentType.PING, + remote: '127.0.0.1', + callback, + body: 'PING 1', + } satisfies AgentTransmitRequest) + ) + ); + + timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + await expect(messageReceived).resolves.toMatchObject>({ + type: 'agent:transmit:response', + contentType: ContentType.TEXT, + statusCode: 500, + callback, + body: expect.stringMatching(/Ping not found/), + }); + }); }); }); diff --git a/packages/agent/src/serialport.test.ts b/packages/agent/src/serialport.test.ts new file mode 100644 index 0000000000..d6fed71b2c --- /dev/null +++ b/packages/agent/src/serialport.test.ts @@ -0,0 +1,173 @@ +import { allOk, ContentType, createReference, LogLevel, sleep } from '@medplum/core'; +import { Agent, Bot, Endpoint, Resource } from '@medplum/fhirtypes'; +import { MockClient } from '@medplum/mock'; +import { Client, Server } from 'mock-socket'; +import { SerialPort } from 'serialport'; +import { App } from './app'; +import { + ASCII_END_OF_TEXT, + ASCII_END_OF_TRANSMISSION, + ASCII_ENQUIRY, + ASCII_START_OF_HEADING, + ASCII_START_OF_TEXT, +} from './serialport'; + +jest.mock('node-windows'); +jest.mock('serialport'); + +describe('Serial port', () => { + const medplum = new MockClient(); + let bot: Bot; + let endpoint: Endpoint; + + beforeAll(async () => { + console.log = jest.fn(); + console.warn = jest.fn(); + + medplum.router.router.add('POST', ':resourceType/:id/$execute', async () => { + return [allOk, {} as Resource]; + }); + + bot = await medplum.createResource({ resourceType: 'Bot' }); + + const url = new URL('serial://COM1'); + url.searchParams.set('baudRate', '9600'); + url.searchParams.set('dataBits', '8'); + url.searchParams.set('stopBits', '1'); + url.searchParams.set('clearOnStartOfHeading', 'true'); + url.searchParams.set('clearOnStartOfText', 'true'); + url.searchParams.set('transmitOnEndOfText', 'true'); + url.searchParams.set('transmitOnEndOfTransmission', 'true'); + url.searchParams.set('ackOnEnquiry', 'true'); + url.searchParams.set('ackOnEndOfText', 'true'); + url.searchParams.set('ackOnEndOfTransmission', 'true'); + url.searchParams.set('ackOnNewLine', 'true'); + + endpoint = await medplum.createResource({ + resourceType: 'Endpoint', + status: 'active', + address: url.toString(), + connectionType: { code: ContentType.TEXT }, + payloadType: [{ coding: [{ code: ContentType.TEXT }] }], + }); + }); + + test('Send and receive', async () => { + const mockServer = new Server('wss://example.com/ws/agent'); + const state = { + socket: undefined as Client | undefined, + messages: [] as string[], + gotConnectRequest: false, + gotTransmitRequest: false, + }; + + mockServer.on('connection', (socket) => { + state.socket = socket; + + socket.on('message', (data) => { + const command = JSON.parse((data as Buffer).toString('utf8')); + if (command.type === 'agent:connect:request') { + state.gotConnectRequest = true; + socket.send( + Buffer.from( + JSON.stringify({ + type: 'agent:connect:response', + }) + ) + ); + } + + if (command.type === 'agent:transmit:request') { + state.gotTransmitRequest = true; + state.messages.push(command.body); + socket.send( + Buffer.from( + JSON.stringify({ + type: 'agent:transmit:response', + channel: command.channel, + remote: command.remote, + body: 'OK', + }) + ) + ); + } + }); + }); + + const agent = await medplum.createResource({ + resourceType: 'Agent', + channel: [ + { + name: 'test', + endpoint: createReference(endpoint), + targetReference: createReference(bot), + }, + ], + } as Agent); + + const app = new App(medplum, agent.id as string, LogLevel.INFO); + await app.start(); + + // Get the mocked instance of SerialPort + expect(SerialPort).toHaveBeenCalledTimes(1); + const serialPort = (SerialPort as unknown as jest.Mock).mock.instances[0]; + expect(serialPort.on).toHaveBeenCalledTimes(3); + + const onOpen = serialPort.on.mock.calls[0]; + expect(onOpen[0]).toBe('open'); + expect(onOpen[1]).toBeInstanceOf(Function); + + const onError = serialPort.on.mock.calls[1]; + expect(onError[0]).toBe('error'); + expect(onError[1]).toBeInstanceOf(Function); + + const onData = serialPort.on.mock.calls[2]; + expect(onData[0]).toBe('data'); + expect(onData[1]).toBeInstanceOf(Function); + + expect(serialPort.open).toHaveBeenCalledTimes(1); + + // Simulate an 'open' event + const onOpenCallback = onOpen[1] as () => void; + onOpenCallback(); + + // Simulate a recoverable 'error' event + const onErrorCallback = onError[1] as (err: Error) => void; + onErrorCallback(new Error('test error')); + + // Wait for the WebSocket to connect + while (!state.socket) { + await sleep(100); + } + + // At this point, we expect the websocket to be connected + expect(state.socket).toBeDefined(); + expect(state.gotConnectRequest).toBe(true); + + // Clear the messages + state.messages.length = 0; + + // Mock sending data to the serial port + const onDataCallback = onData[1] as (data: Buffer) => void; + onDataCallback(Buffer.from([ASCII_START_OF_HEADING])); + onDataCallback(Buffer.from([ASCII_START_OF_TEXT])); + onDataCallback(Buffer.from('test\n')); + onDataCallback(Buffer.from([ASCII_END_OF_TEXT])); + onDataCallback(Buffer.from([ASCII_END_OF_TRANSMISSION])); + onDataCallback(Buffer.from([ASCII_ENQUIRY])); + + // Wait for the WebSocket to transmit + while (!state.gotTransmitRequest) { + await sleep(100); + } + expect(state.gotTransmitRequest).toBe(true); + + // Wait for the WebSocket to receive a reply + while (state.messages.length === 0) { + await sleep(100); + } + + app.stop(); + mockServer.stop(); + }); +}); diff --git a/packages/agent/src/serialport.ts b/packages/agent/src/serialport.ts new file mode 100644 index 0000000000..1fd9eb2190 --- /dev/null +++ b/packages/agent/src/serialport.ts @@ -0,0 +1,184 @@ +import { AgentTransmitResponse, ContentType, normalizeErrorString } from '@medplum/core'; +import { AgentChannel, Endpoint } from '@medplum/fhirtypes'; +import { SerialPort } from 'serialport'; +import { App } from './app'; +import { Channel } from './channel'; + +export const ASCII_START_OF_HEADING = 0x01; +export const ASCII_START_OF_TEXT = 0x02; +export const ASCII_END_OF_TEXT = 0x03; +export const ASCII_END_OF_TRANSMISSION = 0x04; +export const ASCII_ENQUIRY = 0x05; +export const ASCII_ACKNOWLEDGE = 0x06; +export const ASCII_NEW_LINE = 0x0a; + +export class AgentSerialPortChannel implements Channel { + readonly port: SerialPort; + readonly url: URL; + readonly path: string; + private buffer = ''; + + constructor( + readonly app: App, + readonly definition: AgentChannel, + readonly endpoint: Endpoint + ) { + this.url = new URL(this.endpoint.address as string); + this.path = this.url.hostname + this.url.pathname; + + // Create a new port connection + this.port = new SerialPort({ + path: this.path, + baudRate: parseInt(this.url.searchParams.get('baudRate') || '9600', 10), + dataBits: parseInt(this.url.searchParams.get('dataBits') || '8', 10) as 5 | 6 | 7 | 8, + stopBits: parseFloat(this.url.searchParams.get('stopBits') || '1') as 1 | 1.5 | 2, + autoOpen: false, + }); + } + + start(): void { + this.app.log.info(`Channel starting on ${this.url}`); + + // Parse options + const clearOnStartOfHeading = this.url.searchParams.get('clearOnStartOfHeading') === 'true'; + const clearOnStartOfText = this.url.searchParams.get('clearOnStartOfText') === 'true'; + const ackOnEndOfText = this.url.searchParams.get('ackOnEndOfText') === 'true'; + const ackOnEndOfTransmission = this.url.searchParams.get('ackOnEndOfTransmission') === 'true'; + const transmitOnEndOfText = this.url.searchParams.get('transmitOnEndOfText') === 'true'; + const transmitOnEndOfTransmission = this.url.searchParams.get('transmitOnEndOfTransmission') === 'true'; + const ackOnEnquiry = this.url.searchParams.get('ackOnEnquiry') === 'true'; + const ackOnNewLine = this.url.searchParams.get('ackOnNewLine') === 'true'; + const transmitOnNewLine = this.url.searchParams.get('transmitOnNewLine') === 'true'; + + // Add event handler for the "open" event + // Just log a message + this.port.on('open', () => { + this.logInfo(`[${this.path}] Serial port open`); + this.sendToServer(`[${this.path}] Serial port open`); + }); + + // Add event handler for the "error" event + // Just log a message + this.port.on('error', (err) => { + this.logError(`[${this.path}] Serial port error: ` + err); + this.sendToServer(`[${this.path}] Serial port error: ` + err); + }); + + // Add event handler for the "data" event + // Convert the contents to ASCII + // Build up the data buffer + // When we receive the "end" code (0x03), then send to the cloud + this.port.on('data', (data) => { + const str = data.toString('ascii'); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + switch (code) { + case ASCII_START_OF_HEADING: + if (clearOnStartOfHeading) { + this.buffer = ''; + } + break; + + case ASCII_START_OF_TEXT: + if (clearOnStartOfText) { + this.buffer = ''; + } + break; + + case ASCII_END_OF_TEXT: + if (transmitOnEndOfText) { + this.sendToServer(this.buffer); + this.buffer = ''; + } + if (ackOnEndOfText) { + this.port.write(String.fromCharCode(ASCII_ACKNOWLEDGE)); + } + break; + + case ASCII_END_OF_TRANSMISSION: + if (transmitOnEndOfTransmission) { + this.sendToServer(this.buffer); + this.buffer = ''; + } + if (ackOnEndOfTransmission) { + this.port.write(String.fromCharCode(ASCII_ACKNOWLEDGE)); + } + break; + + case ASCII_ENQUIRY: + if (ackOnEnquiry) { + this.port.write(String.fromCharCode(ASCII_ACKNOWLEDGE)); + } + break; + + case ASCII_NEW_LINE: + if (transmitOnNewLine) { + this.sendToServer(this.buffer); + this.buffer = ''; + } else { + this.buffer += '\n'; + } + if (ackOnNewLine) { + this.port.write(String.fromCharCode(ASCII_ACKNOWLEDGE)); + } + break; + + default: + // Otherwise add to the buffer + this.buffer += str.charAt(i); + break; + } + } + }); + + // Open the connection + this.port.open((err) => { + if (err) { + this.logError(`[${this.path}] Error opening serial port: ` + err); + } + }); + + this.sendToServer(`[${this.path}] Running`); + + this.app.log.info('Channel started successfully'); + } + + stop(): void { + this.app.log.info('Channel stopping...'); + this.port.close((err) => { + if (err) { + this.logError(`[${this.path}] Error closing serial port: ` + err); + } + }); + this.app.log.info('Channel stopped successfully'); + } + + sendToRemote(msg: AgentTransmitResponse): void { + console.warn(`SerialPort sendToRemote not implemented (${msg.body})`); + } + + private sendToServer(body: string): void { + try { + this.app.log.info('Received:'); + this.app.log.info(body); + this.app.addToWebSocketQueue({ + type: 'agent:transmit:request', + accessToken: 'placeholder', + channel: this.definition.name as string, + remote: this.path, + contentType: ContentType.TEXT, + body, + }); + } catch (err) { + this.app.log.error(`HL7 error: ${normalizeErrorString(err)}`); + } + } + + private logInfo(message: string): void { + this.app.log.info(message); + } + + private logError(message: string): void { + this.app.log.error(message); + } +} diff --git a/packages/app/package.json b/packages/app/package.json index 8e3f02b674..8f1f8be524 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/app", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum App", "homepage": "https://www.medplum.com/", "bugs": { @@ -29,23 +29,23 @@ "last 1 Chrome versions" ], "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/dropzone": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react": "3.1.2", - "@tabler/icons-react": "3.1.0", + "@mantine/core": "7.8.0", + "@mantine/dropzone": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react": "3.1.3", + "@tabler/icons-react": "3.2.0", "@testing-library/jest-dom": "6.4.2", - "@testing-library/react": "14.3.0", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@testing-library/react": "15.0.2", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "6.22.3", diff --git a/packages/app/src/admin/SecretsPage.tsx b/packages/app/src/admin/SecretsPage.tsx index 4535d3f27a..ffa799261e 100644 --- a/packages/app/src/admin/SecretsPage.tsx +++ b/packages/app/src/admin/SecretsPage.tsx @@ -1,7 +1,7 @@ import { Button, Title } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { InternalSchemaElement, deepClone, getElementDefinition } from '@medplum/core'; -import { ProjectSecret } from '@medplum/fhirtypes'; +import { ProjectSetting } from '@medplum/fhirtypes'; import { ResourcePropertyInput, useMedplum } from '@medplum/react'; import { FormEvent, useEffect, useState } from 'react'; import { getProjectId } from '../utils'; @@ -11,7 +11,7 @@ export function SecretsPage(): JSX.Element { const projectId = getProjectId(medplum); const projectDetails = medplum.get(`admin/projects/${projectId}`).read(); const [schemaLoaded, setSchemaLoaded] = useState(false); - const [secrets, setSecrets] = useState(); + const [secrets, setSecrets] = useState(); useEffect(() => { medplum diff --git a/packages/app/src/resource/ProfilesPage.tsx b/packages/app/src/resource/ProfilesPage.tsx index 9ee6bc4f8b..14febcb7d3 100644 --- a/packages/app/src/resource/ProfilesPage.tsx +++ b/packages/app/src/resource/ProfilesPage.tsx @@ -3,10 +3,10 @@ import { showNotification } from '@mantine/notifications'; import { deepClone, normalizeErrorString, normalizeOperationOutcome } from '@medplum/core'; import { OperationOutcome, Resource, ResourceType } from '@medplum/fhirtypes'; import { Document, ResourceForm, SupportedProfileStructureDefinition, useMedplum } from '@medplum/react'; -import React, { useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { addProfileToResource, cleanResource, removeProfileFromResource } from './utils'; import { ProfileTabs } from './ProfileTabs'; +import { addProfileToResource, cleanResource, removeProfileFromResource } from './utils'; export function ProfilesPage(): JSX.Element | null { const medplum = useMedplum(); @@ -57,7 +57,7 @@ type ProfileDetailProps = { readonly onResourceUpdated: (newResource: Resource) => void; }; -const ProfileDetail: React.FC = ({ profile, resource, onResourceUpdated }) => { +const ProfileDetail: FC = ({ profile, resource, onResourceUpdated }) => { const medplum = useMedplum(); const [outcome, setOutcome] = useState(); const [active, setActive] = useState(() => resource.meta?.profile?.includes(profile.url)); diff --git a/packages/app/src/resource/ToolsPage.test.tsx b/packages/app/src/resource/ToolsPage.test.tsx index 6b11f9615d..bb18cc751d 100644 --- a/packages/app/src/resource/ToolsPage.test.tsx +++ b/packages/app/src/resource/ToolsPage.test.tsx @@ -1,5 +1,5 @@ import { Notifications } from '@mantine/notifications'; -import { allOk, getReferenceString } from '@medplum/core'; +import { ContentType, MEDPLUM_VERSION, allOk, getReferenceString } from '@medplum/core'; import { Agent } from '@medplum/fhirtypes'; import { MockClient } from '@medplum/mock'; import { MedplumProvider } from '@medplum/react'; @@ -10,7 +10,13 @@ import { act, fireEvent, render, screen } from '../test-utils/render'; const medplum = new MockClient(); medplum.router.router.add('GET', 'Agent/:id/$status', async () => [ allOk, - { resourceType: 'Parameters', parameter: [{ name: 'status', valueCode: 'disconnected' }] }, + { + resourceType: 'Parameters', + parameter: [ + { name: 'status', valueCode: 'disconnected' }, + { name: 'version', valueString: MEDPLUM_VERSION }, + ], + }, ]); describe('ToolsPage', () => { @@ -46,6 +52,7 @@ describe('ToolsPage', () => { }); await expect(screen.findByText('disconnected', { exact: false })).resolves.toBeInTheDocument(); + expect(screen.getByText(MEDPLUM_VERSION)).toBeInTheDocument(); }); test('Renders last ping', async () => { @@ -64,7 +71,7 @@ describe('ToolsPage', () => { expect(screen.getAllByText(agent.name)[0]).toBeInTheDocument(); await act(async () => { - fireEvent.change(screen.getByPlaceholderText('IP Address'), { target: { value: '8.8.8.8' } }); + fireEvent.change(screen.getByLabelText('IP Address / Hostname'), { target: { value: '8.8.8.8' } }); fireEvent.click(screen.getByLabelText('Ping')); }); @@ -80,7 +87,7 @@ describe('ToolsPage', () => { expect(screen.getAllByText(agent.name)[0]).toBeInTheDocument(); await act(async () => { - fireEvent.change(screen.getByPlaceholderText('IP Address'), { target: { value: 'abc123' } }); + fireEvent.change(screen.getByLabelText('IP Address / Hostname'), { target: { value: 'abc123' } }); fireEvent.click(screen.getByLabelText('Ping')); }); @@ -98,7 +105,7 @@ describe('ToolsPage', () => { expect(screen.getAllByText(agent.name)[0]).toBeInTheDocument(); await act(async () => { - fireEvent.change(screen.getByPlaceholderText('IP Address'), { target: { value: '8.8.8.8' } }); + fireEvent.change(screen.getByLabelText('IP Address / Hostname'), { target: { value: '8.8.8.8' } }); fireEvent.click(screen.getByLabelText('Ping')); }); @@ -106,4 +113,67 @@ describe('ToolsPage', () => { medplum.setAgentAvailable(true); }); + + test('Setting count for ping', async () => { + const pushToAgentSpy = jest.spyOn(medplum, 'pushToAgent'); + + // load agent page + await act(async () => { + setup(`/${getReferenceString(agent)}`); + }); + + const toolsTab = screen.getByRole('tab', { name: 'Tools' }); + + // click on Tools tab + await act(async () => { + fireEvent.click(toolsTab); + }); + + expect(screen.getAllByText(agent.name)[0]).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(screen.getByLabelText('IP Address / Hostname'), { target: { value: '8.8.8.8' } }); + }); + + await act(async () => { + fireEvent.change(screen.getByLabelText('Ping Count'), { target: { value: '2' } }); + fireEvent.click(screen.getByLabelText('Ping')); + }); + + await expect(screen.findByText('statistics', { exact: false })).resolves.toBeInTheDocument(); + expect(pushToAgentSpy).toHaveBeenLastCalledWith( + { reference: getReferenceString(agent) }, + '8.8.8.8', + 'PING 2', + ContentType.PING, + true + ); + pushToAgentSpy.mockRestore(); + }); + + test('No host entered for ping', async () => { + const pushToAgentSpy = jest.spyOn(medplum, 'pushToAgent'); + + // load agent page + await act(async () => { + setup(`/${getReferenceString(agent)}`); + }); + + const toolsTab = screen.getByRole('tab', { name: 'Tools' }); + + // click on Tools tab + await act(async () => { + fireEvent.click(toolsTab); + }); + + expect(screen.getAllByText(agent.name)[0]).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Ping')); + }); + + await expect(screen.findByText('statistics', { exact: false })).rejects.toThrow(); + expect(pushToAgentSpy).not.toHaveBeenCalled(); + pushToAgentSpy.mockRestore(); + }); }); diff --git a/packages/app/src/resource/ToolsPage.tsx b/packages/app/src/resource/ToolsPage.tsx index 2a18c396cf..846c8cc4c6 100644 --- a/packages/app/src/resource/ToolsPage.tsx +++ b/packages/app/src/resource/ToolsPage.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Button, Divider, Table, TextInput, Title } from '@mantine/core'; +import { ActionIcon, Button, Divider, Group, NumberInput, Table, TextInput, Title } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { ContentType, formatDateTime, normalizeErrorString } from '@medplum/core'; import { Agent, Parameters, Reference } from '@medplum/fhirtypes'; @@ -13,6 +13,7 @@ export function ToolsPage(): JSX.Element | null { const reference = useMemo(() => ({ reference: 'Agent/' + id }) as Reference, [id]); const [loadingStatus, setLoadingStatus] = useState(false); const [status, setStatus] = useState(); + const [version, setVersion] = useState(); const [lastUpdated, setLastUpdated] = useState(); const [lastPing, setLastPing] = useState(); const [pinging, setPinging] = useState(false); @@ -23,6 +24,7 @@ export function ToolsPage(): JSX.Element | null { .get(medplum.fhirUrl('Agent', id, '$status'), { cache: 'reload' }) .then((result: Parameters) => { setStatus(result.parameter?.find((p) => p.name === 'status')?.valueCode); + setVersion(result.parameter?.find((p) => p.name === 'version')?.valueString); setLastUpdated(result.parameter?.find((p) => p.name === 'lastUpdated')?.valueInstant); }) .catch((err) => showError(normalizeErrorString(err))) @@ -31,13 +33,14 @@ export function ToolsPage(): JSX.Element | null { const handlePing = useCallback( (formData: Record) => { - const ip = formData.ip; - if (!ip) { + const host = formData.host; + const pingCount = formData.pingCount || 1; + if (!host) { return; } setPinging(true); medplum - .pushToAgent(reference, ip, 'PING', ContentType.PING, true) + .pushToAgent(reference, host, `PING ${pingCount}`, ContentType.PING, true) .then((pingResult: string) => setLastPing(pingResult)) .catch((err: unknown) => showError(normalizeErrorString(err))) .finally(() => setPinging(false)); @@ -78,6 +81,10 @@ export function ToolsPage(): JSX.Element | null { + + Version + {version} + Last Updated {formatDateTime(lastUpdated)} @@ -88,19 +95,24 @@ export function ToolsPage(): JSX.Element | null { Ping from Agent

- Send a ping command from the agent to an IP address. Use this tool to troubleshoot local network connectivity. + Send a ping command from the agent to a valid IP address or hostname. Use this tool to troubleshoot local + network connectivity.

- - - - } - /> + + + + + } + /> + + {!pinging && lastPing && ( <> diff --git a/packages/app/src/test-utils/render.tsx b/packages/app/src/test-utils/render.tsx index cd5441f107..699d5ea5c1 100644 --- a/packages/app/src/test-utils/render.tsx +++ b/packages/app/src/test-utils/render.tsx @@ -5,15 +5,14 @@ import { MantineProvider } from '@mantine/core'; import { RenderResult, act, fireEvent, screen, render as testingLibraryRender, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; export { RenderResult, act, fireEvent, screen, userEvent, waitFor }; const theme = {}; -export function render(ui: React.ReactNode): RenderResult { +export function render(ui: ReactNode): RenderResult { return testingLibraryRender(<>{ui}, { - wrapper: ({ children }: { children: React.ReactNode }) => ( - {children} - ), + wrapper: ({ children }: { children: ReactNode }) => {children}, }); } diff --git a/packages/bot-layer/package.json b/packages/bot-layer/package.json index 67718f0e1a..6e1a78b217 100644 --- a/packages/bot-layer/package.json +++ b/packages/bot-layer/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/bot-layer", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Bot Lambda Layer", "keywords": [ "medplum", @@ -22,8 +22,8 @@ "author": "Medplum ", "type": "module", "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", "form-data": "4.0.0", "jose": "5.2.4", "node-fetch": "2.7.0", @@ -35,7 +35,7 @@ "@types/node-fetch": "2.6.11" }, "peerDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "form-data": "^4.0.0", "node-fetch": "^2.7.0", "pdfmake": "^0.2.7", diff --git a/packages/cdk/package.json b/packages/cdk/package.json index 6cccdbad4f..0711f44fee 100644 --- a/packages/cdk/package.json +++ b/packages/cdk/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/cdk", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum CDK Infra as Code", "homepage": "https://www.medplum.com/", "bugs": { @@ -24,11 +24,11 @@ }, "dependencies": { "@aws-sdk/types": "3.535.0", - "@medplum/core": "3.1.2", - "aws-cdk-lib": "2.136.0", - "cdk": "2.136.0", - "cdk-nag": "2.28.84", - "cdk-serverless-clamscan": "2.6.145", + "@medplum/core": "3.1.3", + "aws-cdk-lib": "2.137.0", + "cdk": "2.137.0", + "cdk-nag": "2.28.89", + "cdk-serverless-clamscan": "2.6.150", "constructs": "10.3.0" }, "engines": { diff --git a/packages/cdk/src/frontend.ts b/packages/cdk/src/frontend.ts index 8a382c9afc..d4294df1ec 100644 --- a/packages/cdk/src/frontend.ts +++ b/packages/cdk/src/frontend.ts @@ -77,7 +77,7 @@ export class FrontEnd extends Construct { `form-action 'self' *.gstatic.com *.google.com`, `frame-ancestors 'none'`, `frame-src 'self' ${config.storageDomainName} *.medplum.com *.gstatic.com *.google.com`, - `img-src 'self' data: ${config.storageDomainName} *.gstatic.com *.google.com *.googleapis.com`, + `img-src 'self' data: ${config.storageDomainName} *.gstatic.com *.google.com *.googleapis.com gravatar.com`, `manifest-src 'self'`, `media-src 'self' ${config.storageDomainName}`, `script-src 'self' *.medplum.com *.gstatic.com *.google.com`, diff --git a/packages/cli/package.json b/packages/cli/package.json index 3055f6b13d..790c836cb2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/cli", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Command Line Interface", "keywords": [ "medplum", @@ -41,28 +41,27 @@ "test": "jest" }, "dependencies": { - "@aws-sdk/client-acm": "3.549.0", - "@aws-sdk/client-cloudformation": "3.549.0", - "@aws-sdk/client-cloudfront": "3.549.0", - "@aws-sdk/client-ecs": "3.549.0", - "@aws-sdk/client-s3": "3.550.0", - "@aws-sdk/client-ssm": "3.549.0", - "@aws-sdk/client-sts": "3.549.0", + "@aws-sdk/client-acm": "3.554.0", + "@aws-sdk/client-cloudformation": "3.555.0", + "@aws-sdk/client-cloudfront": "3.554.0", + "@aws-sdk/client-ecs": "3.554.0", + "@aws-sdk/client-s3": "3.554.0", + "@aws-sdk/client-ssm": "3.554.0", + "@aws-sdk/client-sts": "3.554.0", "@aws-sdk/types": "3.535.0", - "@medplum/core": "3.1.2", - "@medplum/hl7": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/hl7": "3.1.3", "aws-sdk-client-mock": "4.0.0", "commander": "12.0.0", "dotenv": "16.4.5", "fast-glob": "3.3.2", "node-fetch": "2.7.0", - "tar": "6.2.1" + "tar": "7.0.1" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@types/node-fetch": "2.6.11", - "@types/tar": "6.1.12" + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@types/node-fetch": "2.6.11" }, "engines": { "node": ">=18.0.0" diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts index d9161d9019..98fffed29a 100644 --- a/packages/cli/src/utils.test.ts +++ b/packages/cli/src/utils.test.ts @@ -1,6 +1,7 @@ import { ContentType } from '@medplum/core'; +import { Stats } from 'fs'; import { Writable } from 'stream'; -import tar from 'tar'; +import tar, { Unpack } from 'tar'; import { getCodeContentType, safeTarExtractor } from './utils'; jest.mock('tar', () => ({ @@ -12,10 +13,10 @@ describe('CLI utils', () => { (tar as jest.Mocked).x.mockImplementationOnce((options) => { const writable = new Writable({ write(chunk, _, callback) { - options.filter?.(chunk.toString(), { size: 1 } as tar.FileStat); + options.filter?.(chunk.toString(), { size: 1 } as Stats); callback(); }, - }); + }) as unknown as Unpack; return writable; }); @@ -35,10 +36,10 @@ describe('CLI utils', () => { (tar as jest.Mocked).x.mockImplementationOnce((options) => { const writable = new Writable({ write(chunk, _, callback) { - options.filter?.(chunk.toString(), { size: 1024 * 1024 } as tar.FileStat); + options.filter?.(chunk.toString(), { size: 1024 * 1024 } as Stats); callback(); }, - }); + }) as unknown as Unpack; return writable; }); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 73666853e7..5ff7adb660 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -4,7 +4,6 @@ import { createHmac, createPrivateKey, randomBytes } from 'crypto'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { SignJWT } from 'jose'; import { basename, extname, resolve } from 'path'; -import internal from 'stream'; import tar from 'tar'; import { FileSystemStorage } from './storage'; @@ -211,7 +210,7 @@ function escapeRegex(str: string): string { * @param destinationDir - The destination directory where all files will be extracted. * @returns A tar file extractor. */ -export function safeTarExtractor(destinationDir: string): internal.Writable { +export function safeTarExtractor(destinationDir: string): NodeJS.WritableStream { const MAX_FILES = 100; const MAX_SIZE = 10 * 1024 * 1024; // 10 MB @@ -233,7 +232,9 @@ export function safeTarExtractor(destinationDir: string): internal.Writable { return true; }, - }); + + // Temporary cast for tar issue: https://github.com/isaacs/node-tar/issues/409 + }) as ReturnType & NodeJS.WritableStream; } export function getUnsupportedExtension(): Extension { diff --git a/packages/core/package.json b/packages/core/package.json index 43448a872f..604f561773 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/core", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum TS/JS Library", "keywords": [ "medplum", @@ -55,8 +55,8 @@ "test": "jest" }, "devDependencies": { - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", "jest-websocket-mock": "2.5.0" }, "peerDependencies": { diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 48e0d3c6ff..fa6257bc93 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -27,6 +27,7 @@ export interface AgentHeartbeatRequest extends BaseAgentRequestMessage { export interface AgentHeartbeatResponse extends BaseAgentMessage { type: 'agent:heartbeat:response'; + version: string; } export interface AgentTransmitRequest extends BaseAgentRequestMessage { @@ -42,6 +43,7 @@ export interface AgentTransmitResponse extends BaseAgentMessage { channel?: string; remote: string; contentType: string; + statusCode?: number; body: string; } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 49592e182a..1fb4505c10 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -22,7 +22,7 @@ import { Project, ProjectMembership, ProjectMembershipAccess, - ProjectSecret, + ProjectSetting, Reference, Resource, ResourceType, @@ -88,7 +88,7 @@ import { sortStringArray, } from './utils'; -export const MEDPLUM_VERSION = import.meta.env.MEDPLUM_VERSION ?? ''; +export const MEDPLUM_VERSION: string = import.meta.env.MEDPLUM_VERSION ?? ''; export const MEDPLUM_CLI_CLIENT_ID = 'medplum-cli'; export const DEFAULT_ACCEPT = ContentType.FHIR_JSON + ', */*; q=0.1'; @@ -429,7 +429,7 @@ export interface BotEvent; readonly contentType: string; readonly input: T; - readonly secrets: Record; + readonly secrets: Record; readonly traceId?: string; } @@ -906,7 +906,9 @@ export class MedplumClient extends EventTarget { */ clear(): void { this.storage.clear(); - sessionStorage.clear(); + if (typeof window !== 'undefined') { + sessionStorage.clear(); + } this.clearActiveLogin(); } @@ -3310,7 +3312,7 @@ export class MedplumClient extends EventTarget { if (this.refresh()) { return this.request(method, url, options); } - this.clearActiveLogin(); + this.clear(); if (this.onUnauthenticated) { this.onUnauthenticated(); } diff --git a/packages/core/src/fhirmapper/transform.ts b/packages/core/src/fhirmapper/transform.ts index 86b29db4cb..06927e7bbd 100644 --- a/packages/core/src/fhirmapper/transform.ts +++ b/packages/core/src/fhirmapper/transform.ts @@ -13,7 +13,7 @@ import { generateId } from '../crypto'; import { evalFhirPathTyped } from '../fhirpath/parse'; import { getTypedPropertyValue, toJsBoolean, toTypedValue } from '../fhirpath/utils'; import { TypedValue } from '../types'; -import { tryGetDataType } from '../typeschema/types'; +import { InternalSchemaElement, tryGetDataType } from '../typeschema/types'; import { conceptMapTranslate } from './conceptmaptranslate'; interface TransformContext { @@ -345,10 +345,12 @@ function evalTarget(ctx: TransformContext, target: StructureMapGroupRuleTarget): const isArray = isArrayProperty(targetContext, target.element as string) || Array.isArray(originalValue); if (!target.transform) { + const elementTypes = tryGetPropertySchema(targetContext, target.element as string)?.type; + const elementType = elementTypes?.length === 1 ? elementTypes[0].code : undefined; if (isArray || originalValue === undefined) { - targetValue = [toTypedValue({})]; + targetValue = [elementType ? { type: elementType, value: {} } : toTypedValue({})]; } else { - targetValue = [toTypedValue(originalValue)]; + targetValue = [elementType ? { type: elementType, value: originalValue } : toTypedValue(originalValue)]; } } else { switch (target.transform) { @@ -408,9 +410,18 @@ function evalTarget(ctx: TransformContext, target: StructureMapGroupRuleTarget): * @internal */ function isArrayProperty(targetContext: TypedValue, element: string): boolean | undefined { - const targetContextTypeDefinition = tryGetDataType(targetContext.type); - const targetPropertyTypeDefinition = targetContextTypeDefinition?.elements?.[element]; - return targetPropertyTypeDefinition?.isArray; + return tryGetPropertySchema(targetContext, element)?.isArray; +} + +/** + * Returns the type schema + * @param targetContext - The target context. + * @param element - The element to check (i.e., the property name). + * @returns the type schema for the target element, if it is loeaded + * @internal + */ +function tryGetPropertySchema(targetContext: TypedValue, element: string): InternalSchemaElement | undefined { + return tryGetDataType(targetContext.type)?.elements?.[element]; } /** diff --git a/packages/core/src/fhirmapper/transform.types.test.ts b/packages/core/src/fhirmapper/transform.types.test.ts new file mode 100644 index 0000000000..ae5791292e --- /dev/null +++ b/packages/core/src/fhirmapper/transform.types.test.ts @@ -0,0 +1,42 @@ +import { readJson } from '@medplum/definitions'; +import { Bundle } from '@medplum/fhirtypes'; +import { toTypedValue } from '../fhirpath/utils'; +import { indexStructureDefinitionBundle } from '../typeschema/types'; +import { parseMappingLanguage } from './parse'; +import { structureMapTransform } from './transform'; +import { TypedValue } from '../types'; + +describe('FHIR Mapper transform - dependent', () => { + beforeAll(() => { + indexStructureDefinitionBundle(readJson('fhir/r4/profiles-types.json') as Bundle); + indexStructureDefinitionBundle(readJson('fhir/r4/profiles-resources.json') as Bundle); + }); + + test('Patient name', () => { + const map = ` + group PIDToPatient(source src: PID, target tgt: Patient) { + src -> tgt.resourceType = 'Patient'; + src.PID_5 as s_name -> tgt.name as t_name then xpnToName(s_name, t_name); + } + + group xpnToName(source srcName: XPN, target tgtName: HumanName) { + srcName._0 as s_family_name -> tgtName.family = s_family_name; + srcName._1 as s_given0 -> tgtName.given = s_given0; + srcName._2 as s_given1 -> tgtName.given = s_given1; + } + `; + + const input: TypedValue[] = [ + toTypedValue({ + PID_5: { _0: 'DOE', _1: 'JANE', _2: 'Q' }, + }), + { type: 'Patient', value: {} } as TypedValue, + ]; + + const structureMap = parseMappingLanguage(map); + const actual = structureMapTransform(structureMap, input); + const expected = [{ value: { resourceType: 'Patient', name: [{ family: 'DOE', given: ['JANE', 'Q'] }] } }]; + + expect(actual).toMatchObject(expected); + }); +}); diff --git a/packages/core/src/format.test.ts b/packages/core/src/format.test.ts index 99759c2df2..215c3f45cb 100644 --- a/packages/core/src/format.test.ts +++ b/packages/core/src/format.test.ts @@ -1,5 +1,5 @@ import { Observation } from '@medplum/fhirtypes'; -import { UCUM } from './constants'; +import { LOINC, UCUM } from './constants'; import { formatAddress, formatCodeableConcept, @@ -423,4 +423,30 @@ test('Format Observation value', () => { ], } as Observation) ).toBe('110 mmHg / 75 mmHg'); + expect( + formatObservationValue({ + resourceType: 'Observation', + code: { text: 'Body temperature' }, + valueQuantity: { + value: 36.7, + unit: 'C', + code: 'Cel', + system: UCUM, + }, + component: [ + { + code: { text: 'Body temperature measurement site' }, + valueCodeableConcept: { + coding: [ + { + display: 'Oral', + code: 'LA9367-9', + system: LOINC, + }, + ], + }, + }, + ], + } as Observation) + ).toBe('36.7 C / Oral'); }); diff --git a/packages/core/src/format.ts b/packages/core/src/format.ts index 70b1b6c42c..5d3a8c5d0c 100644 --- a/packages/core/src/format.ts +++ b/packages/core/src/format.ts @@ -439,24 +439,24 @@ export function formatObservationValue(obs: Observation | ObservationComponent | return ''; } - if ('component' in obs) { - return (obs.component as ObservationComponent[]).map((c) => formatObservationValue(c)).join(' / '); - } + const result = []; if (obs.valueQuantity) { - return formatQuantity(obs.valueQuantity); - } - - if (obs.valueCodeableConcept) { - return formatCodeableConcept(obs.valueCodeableConcept); + result.push(formatQuantity(obs.valueQuantity)); + } else if (obs.valueCodeableConcept) { + result.push(formatCodeableConcept(obs.valueCodeableConcept)); + } else { + const valueString = ensureString(obs.valueString); + if (valueString) { + result.push(valueString); + } } - const valueString = ensureString(obs.valueString); - if (valueString) { - return valueString; + if ('component' in obs) { + result.push((obs.component as ObservationComponent[]).map((c) => formatObservationValue(c)).join(' / ')); } - return ''; + return result.join(' / ').trim(); } /** diff --git a/packages/core/src/typeschema/validation.test.ts b/packages/core/src/typeschema/validation.test.ts index 8ecc02a254..6f78938b37 100644 --- a/packages/core/src/typeschema/validation.test.ts +++ b/packages/core/src/typeschema/validation.test.ts @@ -1259,6 +1259,19 @@ describe('FHIR resource validation', () => { expect(() => validateResource(e3, { profile })).toThrow(); }); + // TODO: Change this check from warning to error + // Duplicate entries for choice-of-type property is currently a warning + // We need to first log and track this, and notify customers of breaking changes + function expectOneWarning(resource: Resource, textContains: string): void { + const issues = validateResource(resource); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe('warning'); + expect(issues[0].details?.text).toContain(textContains); + } + + const DUPLICATE_CHOICE_OF_TYPE_PROPERTY = 'Duplicate choice of type property'; + const PRIMITIVE_EXTENSION_TYPE_MISMATCH = 'Type of primitive extension does not match the type of property'; + test('Multiple values for choice of type property', () => { const carePlan: CarePlan = { resourceType: 'CarePlan', @@ -1286,13 +1299,91 @@ describe('FHIR resource validation', () => { ], }; - // TODO: Change this check from warning to error - // Duplicate entries for choice-of-type property is currently a warning - // We need to first log and track this, and notify customers of breaking changes - const issues = validateResource(carePlan); - expect(issues).toHaveLength(1); - expect(issues[0].severity).toBe('warning'); - expect(issues[0].details?.text).toContain('Duplicate choice of type property'); + expectOneWarning(carePlan, DUPLICATE_CHOICE_OF_TYPE_PROPERTY); + }); + + test('Valid choice of type properties with primitive extensions', () => { + expect( + validateResource({ + resourceType: 'Patient', + multipleBirthInteger: 2, + } as Patient) + ).toHaveLength(0); + + expect( + validateResource({ + resourceType: 'Patient', + _multipleBirthInteger: { + extension: [], + }, + } as Patient) + ).toHaveLength(0); + + // check both orders of the properties + expect( + validateResource({ + resourceType: 'Patient', + multipleBirthInteger: 2, + _multipleBirthInteger: { + extension: [], + }, + } as Patient) + ).toHaveLength(0); + expect( + validateResource({ + resourceType: 'Patient', + multipleBirthInteger: 2, + _multipleBirthInteger: { + extension: [], + }, + } as Patient) + ).toHaveLength(0); + }); + + test('Invalid choice of type properties with primitive extensions', () => { + expectOneWarning( + { + resourceType: 'Patient', + multipleBirthBoolean: true, + multipleBirthInteger: 2, + } as Patient, + DUPLICATE_CHOICE_OF_TYPE_PROPERTY + ); + + expectOneWarning( + { + resourceType: 'Patient', + _multipleBirthInteger: { + extension: [], + }, + _multipleBirthBoolean: { + extension: [], + }, + } as Patient, + DUPLICATE_CHOICE_OF_TYPE_PROPERTY + ); + + // Primitive extension type mismatch, check both orders of the properties + expectOneWarning( + { + resourceType: 'Patient', + multipleBirthInteger: 2, + _multipleBirthBoolean: { + extension: [], + }, + } as Patient, + PRIMITIVE_EXTENSION_TYPE_MISMATCH + ); + expectOneWarning( + { + resourceType: 'Patient', + _multipleBirthBoolean: { + extension: [], + }, + multipleBirthInteger: 2, + } as Patient, + PRIMITIVE_EXTENSION_TYPE_MISMATCH + ); }); test('Reference type check', () => { diff --git a/packages/core/src/typeschema/validation.ts b/packages/core/src/typeschema/validation.ts index a1f7807234..f1fd419f34 100644 --- a/packages/core/src/typeschema/validation.ts +++ b/packages/core/src/typeschema/validation.ts @@ -283,13 +283,38 @@ class ResourceValidator implements ResourceVisitor { if (!object) { return; } - const choiceOfTypeElements: Record = {}; + const choiceOfTypeElements: Record = {}; for (const key of Object.keys(object)) { if (key === 'resourceType') { continue; // Skip special resource type discriminator property in JSON } const choiceOfTypeElementName = isChoiceOfType(parent, key, properties); if (choiceOfTypeElementName) { + // check that the type of the primitive extension matches the type of the property + let relatedElementName: string; + let requiredRelatedElementName: string; + if (choiceOfTypeElementName.startsWith('_')) { + relatedElementName = choiceOfTypeElementName.slice(1); + requiredRelatedElementName = key.slice(1); + } else { + relatedElementName = '_' + choiceOfTypeElementName; + requiredRelatedElementName = '_' + key; + } + + if ( + relatedElementName in choiceOfTypeElements && + choiceOfTypeElements[relatedElementName] !== requiredRelatedElementName + ) { + this.issues.push( + createOperationOutcomeIssue( + 'warning', + 'structure', + `Type of primitive extension does not match the type of property "${choiceOfTypeElementName.startsWith('_') ? choiceOfTypeElementName.slice(1) : choiceOfTypeElementName}"`, + choiceOfTypeElementName + ) + ); + } + if (choiceOfTypeElements[choiceOfTypeElementName]) { // Found a duplicate choice of type property // TODO: This should be an error, but it's currently a warning to avoid breaking existing code @@ -303,7 +328,7 @@ class ResourceValidator implements ResourceVisitor { ) ); } - choiceOfTypeElements[choiceOfTypeElementName] = true; + choiceOfTypeElements[choiceOfTypeElementName] = key; continue; } if (!(key in properties) && !(key.startsWith('_') && key.slice(1) in properties)) { @@ -486,8 +511,10 @@ function isChoiceOfType( key: string, propertyDefinitions: Record ): string | undefined { + let prefix = ''; if (key.startsWith('_')) { key = key.slice(1); + prefix = '_'; } const parts = key.split(/(?=[A-Z])/g); // Split before capital letters let testProperty = ''; @@ -496,7 +523,7 @@ function isChoiceOfType( const elementName = testProperty + '[x]'; if (propertyDefinitions[elementName]) { const typedPropertyValue = getTypedPropertyValue(typedValue, testProperty); - return typedPropertyValue ? elementName : undefined; + return typedPropertyValue ? prefix + elementName : undefined; } } return undefined; diff --git a/packages/core/src/utils.test.ts b/packages/core/src/utils.test.ts index 27ca03bc63..08c211fdc7 100644 --- a/packages/core/src/utils.test.ts +++ b/packages/core/src/utils.test.ts @@ -45,6 +45,7 @@ import { isPopulated, isProfileResource, isUUID, + isValidHostname, lazy, parseReference, preciseEquals, @@ -1342,4 +1343,22 @@ describe('Core Utils', () => { ); expect(getQueryString(undefined)).toEqual(''); }); + + test('isValidHostname', () => { + expect(isValidHostname('foo')).toEqual(true); + expect(isValidHostname('foo.com')).toEqual(true); + expect(isValidHostname('foo.bar.com')).toEqual(true); + expect(isValidHostname('foo.org')).toEqual(true); + expect(isValidHostname('foo.bar.co.uk')).toEqual(true); + expect(isValidHostname('localhost')).toEqual(true); + expect(isValidHostname('LOCALHOST')).toEqual(true); + expect(isValidHostname('foo-bar-baz')).toEqual(true); + expect(isValidHostname('foo_bar')).toEqual(true); + expect(isValidHostname('foobar123')).toEqual(true); + + expect(isValidHostname('foo.com/bar')).toEqual(false); + expect(isValidHostname('https://foo.com')).toEqual(false); + expect(isValidHostname('foo_-bar_-')).toEqual(false); + expect(isValidHostname('foo | rm -rf /')).toEqual(false); + }); }); diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 02a8685d01..0b71db545d 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1139,3 +1139,36 @@ export function getQueryString(query: QueryTypes): string { // Source: https://url.spec.whatwg.org/#dom-urlsearchparams-urlsearchparams:~:text=6.2.%20URLSearchParams,)%20init%20%3D%20%22%22)%3B return new URLSearchParams(query).toString(); } + +export const VALID_HOSTNAME_REGEX = + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-_]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-_]*[A-Za-z0-9])$/; + +/** + * Tests whether a given input is a valid hostname. + * + * __NOTE: Does not validate that the input is a valid domain name, only a valid hostname.__ + * + * @param input - The input to test. + * @returns True if `input` is a valid hostname, otherwise returns false. + * + * ### Valid matches: + * - foo + * - foo.com + * - foo.bar.com + * - foo.org + * - foo.bar.co.uk + * - localhost + * - LOCALHOST + * - foo-bar-baz + * - foo_bar + * - foobar123 + * + * ### Invalid matches: + * - foo.com/bar + * - https://foo.com + * - foo_-bar_- + * - foo | rm -rf / + */ +export function isValidHostname(input: string): boolean { + return VALID_HOSTNAME_REGEX.test(input); +} diff --git a/packages/definitions/dist/fhir/r4/profiles-medplum.json b/packages/definitions/dist/fhir/r4/profiles-medplum.json index 6c91b75a53..c75186e8ad 100644 --- a/packages/definitions/dist/fhir/r4/profiles-medplum.json +++ b/packages/definitions/dist/fhir/r4/profiles-medplum.json @@ -240,23 +240,23 @@ } }, { - "id" : "Project.secret", - "path" : "Project.secret", - "definition" : "Secure environment variable that can be used to store secrets for bots.", + "id" : "Project.setting", + "path" : "Project.setting", + "definition" : "Option or parameter that can be adjusted within the Medplum Project to customize its behavior.", "min" : 0, "max" : "*", "type" : [{ "code" : "BackboneElement" }], "base" : { - "path" : "Project.secret", + "path" : "Project.setting", "min" : 0, "max" : "*" } }, { - "id" : "Project.secret.name", - "path" : "Project.secret.name", + "id" : "Project.setting.name", + "path" : "Project.setting.name", "definition" : "The secret name.", "min" : 1, "max" : "1", @@ -264,14 +264,14 @@ "code" : "string" }], "base" : { - "path" : "Project.secret.name", + "path" : "Project.setting.name", "min" : 1, "max" : "1" } }, { - "id" : "Project.secret.value[x]", - "path" : "Project.secret.value[x]", + "id" : "Project.setting.value[x]", + "path" : "Project.setting.value[x]", "definition" : "The secret value.", "min" : 1, "max" : "1", @@ -288,11 +288,50 @@ "code" : "integer" }], "base" : { - "path" : "Project.secret.value[x]", + "path" : "Project.setting.value[x]", "min" : 1, "max" : "1" } }, + { + "id" : "Project.secret", + "path" : "Project.secret", + "definition" : "Option or parameter that can be adjusted within the Medplum Project to customize its behavior, only visible to project administrators.", + "min" : 0, + "max" : "*", + "base" : { + "path" : "Project.secret", + "min" : 0, + "max" : "*" + }, + "contentReference" : "#Project.setting" + }, + { + "id" : "Project.systemSetting", + "path" : "Project.systemSetting", + "definition" : "Option or parameter that can be adjusted within the Medplum Project to customize its behavior, only modifiable by system administrators.", + "min" : 0, + "max" : "*", + "base" : { + "path" : "Project.systemSetting", + "min" : 0, + "max" : "*" + }, + "contentReference" : "#Project.setting" + }, + { + "id" : "Project.systemSecret", + "path" : "Project.systemSecret", + "definition" : "Option or parameter that can be adjusted within the Medplum Project to customize its behavior, only visible to system administrators.", + "min" : 0, + "max" : "*", + "base" : { + "path" : "Project.systemSecret", + "min" : 0, + "max" : "*" + }, + "contentReference" : "#Project.setting" + }, { "id" : "Project.site", "path" : "Project.site", diff --git a/packages/definitions/package.json b/packages/definitions/package.json index 61304f6376..6bb8645f4a 100644 --- a/packages/definitions/package.json +++ b/packages/definitions/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/definitions", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Data Definitions", "keywords": [ "medplum", diff --git a/packages/docs/docs/access/binary-security-context.md b/packages/docs/docs/access/binary-security-context.md new file mode 100644 index 0000000000..6c1c786763 --- /dev/null +++ b/packages/docs/docs/access/binary-security-context.md @@ -0,0 +1,16 @@ +# Binary Security Context + +When managing access, the FHIR [`Binary`](/docs/api/fhir/resources/binary) resource is unique case. Access controls cannot be applied to [`Binary`](/docs/api/fhir/resources/binary) resources in the same way as other resources, so you must use the `Binary.securityContext` element to add access policies. + +The `securityContext` element is a reference to another resource that acts as a proxy for the access controls of that [`Binary`](/docs/api/fhir/resources/binary). For example, if the `securityContext` references a [`Patient`](/docs/api/fhir/resources/patient), then the [`Binary`](/docs/api/fhir/resources/binary) will only be viewable by users and resources that have read access to that [`Patient`](/docs/api/fhir/resources/patient). + +Below is an example of a simiple [`Binary`](/docs/api/fhir/resources/binary) resource with a `securityContext` that references a [`Patient`](/docs/api/fhir/resources/patient). + +```json +{ + "resourceType": "Binary", + "securityContext": { "reference": "Patient/homer-simpson" } +} +``` + +For more details on how [`Binary`](/docs/api/fhir/resources/binary) resources are used in FHIR, see the [Binary Data docs](/docs/fhir-datastore/binary-data). diff --git a/packages/docs/docs/administration/provider-directory/index.md b/packages/docs/docs/administration/provider-directory/index.md index ee8da13c8f..5d14afe491 100644 --- a/packages/docs/docs/administration/provider-directory/index.md +++ b/packages/docs/docs/administration/provider-directory/index.md @@ -4,7 +4,7 @@ sidebar_position: 7 # Modeling your Provider Directory -Provider Directories are critical databases housing essential details about healthcare providers, from individual practitioners to entire organizations. Accurate and standardized modeling of these directories, ensures better a better patient experience via improved coordination coordination of care and operational efficiency. +Provider Directories are critical databases housing essential details about healthcare providers, from individual practitioners to entire organizations. Accurate and standardized modeling of these directories, ensures a better patient experience via improved coordination coordination of care and operational efficiency. This section provide a series of guides on how to properly model your organization's provider directory. It focuses on a few key challenges: diff --git a/packages/docs/docs/agent/bulk-status.md b/packages/docs/docs/agent/bulk-status.md new file mode 100644 index 0000000000..d80fee911b --- /dev/null +++ b/packages/docs/docs/agent/bulk-status.md @@ -0,0 +1,199 @@ +--- +sidebar_position: 11 +--- + +# Agent Bulk Status + +Gets the status of an agent or agents based on given search criteria. Useful for seeing whether agents are connected and listing their current software version. + +## Invoke the `$bulk-status` operation + +``` +[base]/Agent/$bulk-status +``` + +For example: + +```bash +medplum get 'Agent/$bulk-status' +``` + +### Valid Response + +The response to this operation is a `Bundle` of `Parameters`. Each `Parameters` within the `Bundle` contains an `agent` and a `result`, +which is the result of calling the `$status` operation on this `Agent`, either a `Parameters` or `OperationOutcome` resource. + +Example response: + +```json +{ + "resourceType": "Bundle", + "type": "collection", + "entry": [ + { + "resource": { + "resourceType": "Parameters", + "parameter": [ + { + "name": "agent", + "resource": { + "resourceType": "Agent", + "name": "Test Agent 1", + "status": "active", + "id": "93f8b2fb-65a3-4977-a175-71b73b26fde7", + "meta": { + "versionId": "e182201a-6925-467f-a92b-496193fb4c39", + "lastUpdated": "2024-04-19T20:29:25.087Z" + } + } + }, + { + "name": "result", + "resource": { + "resourceType": "Parameters", + "parameter": [ + { + "name": "status", + "valueCode": "connected" + }, + { + "name": "version", + "valueString": "3.1.4" + }, + { + "name": "lastUpdated", + "valueInstant": "2024-04-19T00:00:00Z" + } + ] + } + } + ] + } + }, + { + "resource": { + "resourceType": "Parameters", + "parameter": [ + { + "name": "agent", + "resource": { + "resourceType": "Agent", + "name": "Test Agent 2", + "status": "active", + "id": "93f8b2fb-65a3-4977-a175-71b73b26fde7", + "meta": { + "versionId": "e182201a-6925-467f-a92b-496193fb4c39", + "lastUpdated": "2024-04-19T20:29:25.087Z" + } + } + }, + { + "name": "result", + "resource": { + "resourceType": "Parameters", + "parameter": [ + { + "name": "status", + "valueCode": "disconnected" + }, + { + "name": "version", + "valueString": "3.1.2" + }, + { + "name": "lastUpdated", + "valueInstant": "2024-04-19T00:00:00Z" + } + ] + } + } + ] + } + }, + { + "resource": { + "resourceType": "Parameters", + "parameter": [ + { + "name": "agent", + "resource": { + "resourceType": "Agent", + "name": "Test Agent 3", + "status": "off", + "id": "93f8b2fb-65a3-4977-a175-71b73b26fde7", + "meta": { + "versionId": "e182201a-6925-467f-a92b-496193fb4c39", + "lastUpdated": "2024-04-19T20:29:25.087Z" + } + } + }, + { + "name": "result", + "resource": { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "details": { + "text": "Something weird happened when getting the status" + } + } + ], + } + } + ] + } + } + ] +} +``` + +### Invalid Response + +Example outcome when exceeding max `_count` limit: + +```json +{ + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "details": { + "text": "'_count' of 101 is greater than max of 100" + } + } + ] +} +``` + +## Using search parameters + +All of the `Agent` search parameters can be used to select which agents to query the status of. + +Some useful search parameters are: +- `name` +- `status` +- `_count` and `_offset` + +### Recipes + +Getting the status for one agent by name: + +```bash +medplum get 'Agent/$bulk-status?name=Test+Agent+1' +``` + +Getting the status of all active agents: + +```bash +medplum get 'Agent/$bulk-status?status=active' +``` + +Paging through all agent statuses, 50 at a time: + +```bash +medplum get 'Agent/$bulk-status?_count=50&_offset=0' +medplum get 'Agent/$bulk-status?_count=50&_offset=50' +``` diff --git a/packages/docs/docs/agent/index.md b/packages/docs/docs/agent/index.md index b8bbabaf80..eac981f39a 100644 --- a/packages/docs/docs/agent/index.md +++ b/packages/docs/docs/agent/index.md @@ -163,7 +163,7 @@ HL7 Feeds can be extremely high volume, and before you go live with a high-volum ## Running from source -Testing the setup end-to-end on localhost can be done by doing the following steps. This assumes you are [running MØedplum on localhost](/docs/contributing/run-the-stack) as a prerequisite. +Testing the setup end-to-end on localhost can be done by doing the following steps. This assumes you are [running Medplum on localhost](/docs/contributing/run-the-stack) as a prerequisite. Navigate to the `medplum/packages/agent`` folder on your drive and run the following command in your terminal diff --git a/packages/docs/docs/agent/requirements.md b/packages/docs/docs/agent/requirements.md index fd88b6a8a6..1e392ca79e 100644 --- a/packages/docs/docs/agent/requirements.md +++ b/packages/docs/docs/agent/requirements.md @@ -1,5 +1,5 @@ --- -sidebar_position: 100 +sidebar_position: 3 --- # System Requirements diff --git a/packages/docs/docs/agent/status.md b/packages/docs/docs/agent/status.md new file mode 100644 index 0000000000..4a9e3dc2b5 --- /dev/null +++ b/packages/docs/docs/agent/status.md @@ -0,0 +1,87 @@ +--- +sidebar_position: 10 +--- + +# Agent Status + +Gets the status of a given agent. Useful for seeing whether an agent is connected and listing its current software version. + +> For querying multiple agent statuses at once, or using `SearchParameters` to select agents to query, see [Bulk Status](./bulk-status.md). + +## Invoke the `$status` operation + +``` +[base]/Agent/[id]/$status +``` + +For example: + +```bash +medplum get 'Agent/[id]/$status' +``` + +### Valid Response + +Valid status codes include: +- `connected` +- `disconnected` +- `unknown` + +Example response when the `Agent` is known and connected: + +```json +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "status", + "valueCode": "connected" + }, + { + "name": "version", + "valueString": "3.1.4" + }, + { + "name": "lastUpdated", + "valueInstant": "2024-04-19T00:00:00Z" + } + ] +} +``` + +In cases where status has not been reported yet, `status` and `version` may be `unknown`, and `lastUpdated` may not be present. + +```json +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "status", + "valueCode": "unknown" + }, + { + "name": "version", + "valueString": "unknown" + } + ] +} +``` + +### Invalid Response + +Example outcome when an ID was not supplied to the operation: + +```json +{ + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "details": { + "text": "Must specify agent ID or identifier" + } + } + ] +} +``` diff --git a/packages/docs/docs/auth/methods/index.md b/packages/docs/docs/auth/methods/index.md index b34f45ab3d..b9ce149957 100644 --- a/packages/docs/docs/auth/methods/index.md +++ b/packages/docs/docs/auth/methods/index.md @@ -114,6 +114,7 @@ All three implementations types will have tokens or client credentials with syst - Consider disabling local storage on device for shared workstations or in accordance with institution policy. - Organizations with mobile devices or laptops should enable a Mobile Device Management (MDM) solution for workstations - [IP restrictions](/docs/access/ip-access-rules) can be enabled when especially sensitive data, such as personal health information (PHI), is being accessed. +- Reusing the same `MedplumClient` instance for different users is discouraged. Consider creating new instances of `MedplumClient` instead. ### Server Authentication @@ -122,6 +123,7 @@ All three implementations types will have tokens or client credentials with syst - Restrict access to host via VPC or other mechanism - do not allow access from general internet. - Use a secrets management to store access keys - do not store credentials on disk. - Ensure host is patched and has security updates applied regularly. +- Consider creating a new instance of `MedplumClient`, particularly when switching to another user. ### Host authentication diff --git a/packages/docs/docs/auth/user-management-guide/user-management-guide.md b/packages/docs/docs/auth/user-management-guide/user-management-guide.md index 8bfa908f29..ade0860701 100644 --- a/packages/docs/docs/auth/user-management-guide/user-management-guide.md +++ b/packages/docs/docs/auth/user-management-guide/user-management-guide.md @@ -304,7 +304,7 @@ It is important to spread the original `ProjectMembership` to ensure that you ar ## Invite via API -Inviting users can be done programmatically using the [`/invite` endpoint](/docs/api/project-admin/invite). +Inviting users can be done programmatically using the [`/invite` endpoint](/docs/api/project-admin/invite). Like inviting via the [Medplum App](https://app.medplum.com), this can only be done by [project admins](/docs/access/admin#project-admin). Prepare JSON payload: diff --git a/packages/docs/docs/fhir-datastore/deleting-data.md b/packages/docs/docs/fhir-datastore/deleting-data.md index 42b128f6ea..c2cce4922a 100644 --- a/packages/docs/docs/fhir-datastore/deleting-data.md +++ b/packages/docs/docs/fhir-datastore/deleting-data.md @@ -46,3 +46,9 @@ The Medplum `$expunge` operation supports an optional `everything` flag to syste ``` POST [base]/[resourceType]/[id]/$expunge?everything=true ``` + +:::warning Expunging a Project + +If you expunge a [`Project`](/docs/api/fhir/medplum/project), it will be _permanently_ deleted and you will no longer be able to sign in or access it in any way. + +::: diff --git a/packages/docs/docs/fhir-datastore/resource-history.md b/packages/docs/docs/fhir-datastore/resource-history.md index 86595f84e0..bd564dd59f 100644 --- a/packages/docs/docs/fhir-datastore/resource-history.md +++ b/packages/docs/docs/fhir-datastore/resource-history.md @@ -65,3 +65,18 @@ These requests return a `Bundle` resource with the different versions stored as :::note Resource Creation Time There is currently no support for directly accessing the time and date that a resource was initially created. To do this use the `/_history` endpoint to retrieve all versions and view the `lastUpdated` field of the original version. Note that the GraphQL endpoint does not currently have a spec for the history API. ::: + +## Reverting Changes to a Resource + +While there is no direct method to revert changes made to a resource, it can be easily done using the `readHistory` and `readVersion` helper functions provided by Medplum. + +The `readHistory` function is used to get the entire history of the resource. You can then choose the version and use `readVersion` to return the complete details of that version of the resource. The current resource can then be updated to the historic details. + +
+ Example: Revert resource to a previous version + + {ExampleCode} + +
+ +This method does not actually revert the resources to the previous version, but it creates a new entry in the resource's history with all of the same details as the historic version. diff --git a/packages/docs/docs/graphql/basic-queries.mdx b/packages/docs/docs/graphql/basic-queries.mdx index b4ca75cb17..88d87592ec 100644 --- a/packages/docs/docs/graphql/basic-queries.mdx +++ b/packages/docs/docs/graphql/basic-queries.mdx @@ -175,6 +175,10 @@ In the example below, we first search for a `Patient` by id, and then find all t See the "[Reverse References](https://hl7.org/fhir/r4/graphql.html#searching)" section of the FHIR GraphQL specification for more information. +:::note Chained Search in GraphQL +When searching on references in GraphQL, you _cannot_ filter on the parameters of the referenced resources. This is called chained search and it is not supported by the FHIR GraphQL spec. However, it is supported in the FHIR Rest API. For more details see the [Chained Search docs](/docs/search/chained-search). +::: + ## Filtering lists with field arguments FHIR GraphQL supports filtering array properties using field arguments. For example, you can filter the `Patient.name` array by the `use` field: diff --git a/packages/docs/docs/rate-limits/index.md b/packages/docs/docs/rate-limits/index.md new file mode 100644 index 0000000000..1ffa07caf3 --- /dev/null +++ b/packages/docs/docs/rate-limits/index.md @@ -0,0 +1,28 @@ +# Rate Limits + +The Medplum API uses a number of safeguards against bursts of incoming traffic to help maximize its stability. Users who send many requests in quick succession might see error responses that show up as status code `429`. + +## Default Rate Limits + +| Category | Free tier | Paid tier | +| ----------------------------- | ------------------------------ | ------------------------------- | +| Auth (`/auth/*`, `/oauth2/*`) | 1 request per IP per second | 1 request per IP per second | +| Others | 100 requests per IP per second | 1000 requests per IP per second | + +All rate limits are calculated per IP address on a 15 minute window. + +Rate limits can be increased for paid plans. Please [contact us](mailto:info+rate-limits@medplum.com?subject=Increase%20rate%20limits) for more information. + +## HTTP Headers + +All API calls affected by rate limits will include the following headers: + +- `X-Ratelimit-Limit`: The maximum number of requests that the consumer is permitted to make in a 15 minute window. +- `X-Ratelimit-Remaining`: The number of requests remaining in the current rate limit window. +- `X-Ratelimit-Reset`: The time at which the current rate limit window resets in UTC epoch seconds. + +``` +X-Ratelimit-Limit: 600 +X-Ratelimit-Remaining: 599 +X-Ratelimit-Reset: 1713810464 +``` diff --git a/packages/docs/docs/search/chained-search.md b/packages/docs/docs/search/chained-search.md index 80812d1217..664379a3bf 100644 --- a/packages/docs/docs/search/chained-search.md +++ b/packages/docs/docs/search/chained-search.md @@ -9,6 +9,12 @@ Chaining search parameters allows you to filter your searches based on the param Chained searches are similar to using [`_include` or `_revinclude` parameters](/docs/search/includes), but it will not return the referenced resources, only filter based on their parameters. The primary benefit of this is it allows for easy pagination since you know you will only receive results of one resource type. See the [paginated search docs](/docs/search/paginated-search) for more details. +:::note Chained Search Availability + +Chained search is only available when using the FHIR Rest API as described here. If you are using GraphQL, chained search functionality is not supported. + +::: + ## Forward Chained Search [Search parameters](/docs/search/basic-search) with the `reference` type can be chained together to search on the elements of the referenced resource. @@ -88,6 +94,12 @@ You can include more than one link in your chained search. In the below example,
+:::note Filtering Chained Searches + +The [`_filter` search parameter](/docs/search/filter-search-parameter) is not currently supported when using chained search. This is on the Medplum road map, but there is no firm date when it is expected to be implemented. You can follow [this issue](https://github.com/medplum/medplum/issues/3224) for updates. + +::: + ## Reverse Chained Search Chained references can also be constructed in reverse, filtering on other resources that reference your target search resource. This is done using the `_has` parameter, which has a special syntax: `_has:::`. diff --git a/packages/docs/docs/self-hosting/config-settings.md b/packages/docs/docs/self-hosting/config-settings.md index 85fde2ddce..a369f669dc 100644 --- a/packages/docs/docs/self-hosting/config-settings.md +++ b/packages/docs/docs/self-hosting/config-settings.md @@ -156,11 +156,14 @@ Optionally override the trusted CA certificates. Default is to trust the well-kn | `otlpTraceEndpoint` | Optional OTLP trace endpoint for OpenTelemetry. For example, `http://localhost:4318/v1/traces`. See [OpenTelemetry](/docs/self-hosting/opentelemetry) for more details. | | | | | `accurateCountThreshold` | Optional threshold for accurate count queries. The server will always perform an estimate count first (to protect database performance), and an accurate count if the estimate is below this threshold. | | | `1000000` | | `defaultBotRuntimeVersion` | Optional default bot runtime version. See [Bot runtime version](/docs/api/fhir/medplum/bot) for more details. | | | `awslambda` | +| `defaultProjectFeatures` | Optional default project features. See [Project Settings](/docs/access/projects#settings) | | `init` | | :::tip Local Config To make changes to the server config after your first deploy, you must the edit parameter values _directly in AWS parameter store_ -To make changes to settings that affect your deployed Medplum App, you must _also_ make this change to your local configuration json file. +To make changes to settings that affect your deployed Medplum App, you must _also_ make these changes to your local configuration json file. + +Once you have made these changes, you will need to restart your server for them to take effect. The easiest way to do this in a zero-downtime manner is by using the `medplum aws update-server` command. For more details on this command see the [Upgrade the Server docs](/docs/self-hosting/install-on-aws#upgrade-the-server). ::: ### AWS Secrets diff --git a/packages/docs/package.json b/packages/docs/package.json index 5d9cbb0187..77c775ee1c 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/docs", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Docs", "homepage": "https://www.medplum.com/", "bugs": { @@ -47,9 +47,9 @@ "@docusaurus/tsconfig": "3.2.1", "@docusaurus/types": "3.2.1", "@mdx-js/react": "3.0.1", - "@medplum/core": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", "@svgr/webpack": "8.1.0", "clsx": "2.1.0", "file-loader": "6.2.0", @@ -57,9 +57,9 @@ "raw-loader": "4.0.2", "react": "18.2.0", "react-dom": "18.2.0", - "react-intersection-observer": "9.8.1", + "react-intersection-observer": "9.8.2", "react-router-dom": "6.22.3", - "typescript": "5.4.4", + "typescript": "5.4.5", "url-loader": "4.1.1" }, "engines": { diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index a2e88a8e13..0ecc9ad338 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -234,6 +234,12 @@ const sidebars: SidebarsConfig = { link: { type: 'doc', id: 'analytics/index' }, items: [{ type: 'autogenerated', dirName: 'analytics' }], }, + { + type: 'category', + label: 'Rate Limits', + link: { type: 'doc', id: 'rate-limits/index' }, + items: [{ type: 'autogenerated', dirName: 'rate-limits' }], + }, { type: 'category', label: 'Self-Hosting', diff --git a/packages/docs/src/pages/enterprise.tsx b/packages/docs/src/pages/enterprise.tsx index b5ada72e37..fc3aed5ce0 100644 --- a/packages/docs/src/pages/enterprise.tsx +++ b/packages/docs/src/pages/enterprise.tsx @@ -91,10 +91,10 @@ export default function EnterprisePage(): JSX.Element { Medplum robot coding
-

Enterprise Integrations

+

Enterprise Identity Management

- Reliable integrations drive efficiency and safety. Medplum provides certificed enterprise integrations for - diagnostics, billing, medications, legacy EHR platforms and more. + Connect multiplie identity prociders and provision identities programmatically across your health record + system. Use SCM administration for robust and compliant identity administration.

@@ -119,6 +119,20 @@ export default function EnterprisePage(): JSX.Element { webUrl="/docs/auth/methods/google-auth" /> + + +
+ Medplum robot coding +
+
+

Enterprise Observability

+

+ Gain deep insights into systems performance and health. Enables proactive issue detection, efficient + troubleshooting, and improved system reliability. +

+
+
+
+ +
+ Medplum robot coding +
+
+

Enterprise Integrations

+

+ Enable reliable, compliant and auditable connectivity to service providers and partners. +

+
+
- Developer + Production 2 - Production + Premium 3 - Enterprise - - 4 - + Enterprise Community @@ -61,10 +58,7 @@ export default function PricingPage(): JSX.Element { - Enterprise - - 6 - + Enterprise @@ -73,10 +67,10 @@ export default function PricingPage(): JSX.Element { Pricing Free - $300/mo + $2,000/mo - $2,000/mo + $6,000/mo Contact us @@ -89,7 +83,7 @@ export default function PricingPage(): JSX.Element { Standard BAA - + ✔️ ✔️ ✔️ @@ -112,8 +106,8 @@ export default function PricingPage(): JSX.Element { 500 - 1,000 50,000 + 250,000 Contact us Contact us @@ -126,8 +120,8 @@ export default function PricingPage(): JSX.Element { None - 1,000 - 5,000 + 5,000/mo + 25,000/mo Contact us Contact us @@ -135,8 +129,8 @@ export default function PricingPage(): JSX.Element { Emails Sent None - 50/mo 500/mo + 2500/mo Contact us Contact us @@ -144,7 +138,7 @@ export default function PricingPage(): JSX.Element { Open Onboarding Testing only - Testing only + ✔️ ✔️ ✔️ @@ -153,14 +147,14 @@ export default function PricingPage(): JSX.Element { Custom Domains None - None 1 + 5 Contact us Contact us - Pre-built Integrations + UMLS Terminology @@ -169,7 +163,7 @@ export default function PricingPage(): JSX.Element { ✔️ - Single Tenant + Dedicated Infrastructure @@ -178,12 +172,90 @@ export default function PricingPage(): JSX.Element { - Support - Discord -
- GitHub + Communications + + + + + + + + + + + Websocket Subscriptions + + 11 + + + + ✔️ + ✔️ + + ✔️ + + + Concurrent Connections + + + 2000 + Contact Us + + Contact Us + + + + Integrations + + + + + + + + + + Lab/Diagnostics + + + ✔️ + ✔️ + + ✔️ + + + Medications/eRx + + + ✔️ + ✔️ + + ✔️ + + + HL7 Integration Engine + + + + ✔️ + + ✔️ + + + + Support + + + + + + + + + + Channels Discord
@@ -192,7 +264,12 @@ export default function PricingPage(): JSX.Element { Discord (SLA)
- Github (SLA) + GitHub (SLA) + + + Private Slack +
+ Email Contact us @@ -202,6 +279,15 @@ export default function PricingPage(): JSX.Element { Contact us + + Shared Roadmap + + + + ✔️ + + ✔️ + Security @@ -236,6 +322,20 @@ export default function PricingPage(): JSX.Element { DIY ✔️ + + + External Identity Providers + + 13 + + + + 1 + 2 + Contact Us + DIY + Contact Us + WAF Blocking ✔️ @@ -249,13 +349,13 @@ export default function PricingPage(): JSX.Element { IP Address Restrictions - + ✔️ ✔️ DIY ✔️ - Observability Suite + SCIM Administration @@ -264,7 +364,27 @@ export default function PricingPage(): JSX.Element { ✔️ - SAML + Access Policies + Testing only + 3 + 10 + Contact us + DIY + Contact us + + + + Observability + + + + + + + + + + Log Streaming @@ -273,13 +393,13 @@ export default function PricingPage(): JSX.Element { ✔️ - Access Policies - Testing only - 5 - 20 - Contact us - DIY - Contact us + CISO Dashboard + + + + ✔️ + + ✔️ @@ -301,7 +421,7 @@ export default function PricingPage(): JSX.Element { ✔️ ✔️ ✔️ - DIY + Contact us @@ -310,7 +430,7 @@ export default function PricingPage(): JSX.Element { Contact us - DIY + Contact us @@ -319,20 +439,34 @@ export default function PricingPage(): JSX.Element { ✔️ Contact us - DIY + Contact us - Sign Up + Audit Support + + 12 + + + + ✔️ + + ✔️ + + - Start Now + Sign Up + Start Now + + Start Now + Contact Us @@ -352,12 +486,12 @@ export default function PricingPage(): JSX.Element { Free: recommended for prototyping or learning.
  • - Developer: recommended for developer environments or test environments. -
  • -
  • Production: recommended for production use, e.g. treatment of patients or conducting research.
  • +
  • + Premium: recommended messaging heavy and integration heavy use cases. +
  • Enterprise: recommended for institutions with complex workflow, integration or data requirements. Read more details on our Enterprise offering page. @@ -367,24 +501,41 @@ export default function PricingPage(): JSX.Element { Medplum application.
  • - Enterprise Managed: recommended for those who must host the application on their own - cloud infrastructure. + Enterprise Self-Hosted: recommended for those who must host the application on their + own cloud infrastructure.
  • - Data usage refers to the creation of{' '} + FHIR Resources Stored: Data usage refers to the creation of{' '} FHIR Resources. This figure - is cumulative. + is cumulative. For Premium, Communication resources that are generated as part of messaging are not + included in the resource cap shown.
  • - Bots and automation refer to custom logic written by customers to execute their workflow.{' '} + Bot Invocations: refers to custom logic written by customers to execute their workflow.{' '} Automation documentation and{' '} integration are a good place to learn more.
  • -
  • Organizations can require that all logins go through Google Authentication.
  • +
  • + Required authentication methods: Organizations can require that all logins at their + domain go through their identity provider of choice. +
  • - Many complex compliance scenarios can be supported with this infrastructure. You can read more on the{' '} + Compliance: Many complex compliance scenarios can be supported with this + infrastructure. You can read more on the{' '} compliance page.
  • +
  • + Websocket Subscriptions: maximal number of concurrent websocket{' '} + subscriptions available. +
  • +
  • + Audit Support: receive support during common audits common in health system and payor + partnerships. +
  • +
  • + External Identity Providers: connect your Okta, Azure SSO, Auth0 or other oAuth based + identity provider. +
  • diff --git a/packages/docs/src/pages/solutions/index.md b/packages/docs/src/pages/solutions/index.md index 93054fca3d..241c08a4c9 100644 --- a/packages/docs/src/pages/solutions/index.md +++ b/packages/docs/src/pages/solutions/index.md @@ -40,7 +40,7 @@ Run and maintain an EMPI including patient identification, data accuracy/risk sc ## Interoperability Service -Highly customizable internal service that supports integrations that are common in healthcare such as FHIR and Smart-on-FHIR integrations, HL7 connections, SFTP, Lab data, home health integrations, logistics providers and more. Available hosted or self-hosted. [Learn More](/products/integration) +Highly customizable internal service that supports integrations that are common in healthcare such as FHIR and Smart-on-FHIR integrations, HL7 connections, SFTP, Lab data, home health integrations, logistics providers and more. Can also be used as a system of record between multiple integrations. Available hosted or self-hosted. [Learn More](/products/integration) ## Remote Patient Monitoring diff --git a/packages/eslint-config/index.cjs b/packages/eslint-config/index.cjs index da18959d45..a786453c32 100644 --- a/packages/eslint-config/index.cjs +++ b/packages/eslint-config/index.cjs @@ -158,6 +158,7 @@ module.exports = { 'babel.config.cjs', 'jest.sequencer.js', 'package-lock.json', + 'postcss.config.cjs', 'rollup.config.mjs', 'webpack.config.js', ], diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index fd19c3a9c9..99418515bb 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/eslint-config", - "version": "3.1.2", + "version": "3.1.3", "description": "Shared ESLint configuration for Medplum projects", "keywords": [ "eslint", @@ -19,8 +19,8 @@ "author": "Medplum ", "main": "index.cjs", "devDependencies": { - "@typescript-eslint/eslint-plugin": "7.5.0", - "@typescript-eslint/parser": "7.5.0", + "@typescript-eslint/eslint-plugin": "7.6.0", + "@typescript-eslint/parser": "7.6.0", "eslint": "8.57.0", "eslint-plugin-jsdoc": "48.2.3", "eslint-plugin-json-files": "4.1.0", diff --git a/packages/examples/package.json b/packages/examples/package.json index f7ca166466..a4f556dab6 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/examples", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Code Examples", "homepage": "https://www.medplum.com/", "bugs": { @@ -19,9 +19,9 @@ }, "devDependencies": { "@jest/globals": "29.7.0", - "@medplum/core": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", "jest": "29.7.0" }, "engines": { diff --git a/packages/examples/src/fhir-datastore/fhir-batch-requests.ts b/packages/examples/src/fhir-datastore/fhir-batch-requests.ts index b1dbc6da3b..94dc756da5 100644 --- a/packages/examples/src/fhir-datastore/fhir-batch-requests.ts +++ b/packages/examples/src/fhir-datastore/fhir-batch-requests.ts @@ -158,7 +158,7 @@ const internalReference: Bundle = // start-block internalReference { resourceType: 'Bundle', - type: 'batch', + type: 'transaction', entry: [ { // highlight-next-line @@ -216,7 +216,7 @@ const conditional: Bundle = // start-block conditionalCreate { resourceType: 'Bundle', - type: 'batch', + type: 'transaction', entry: [ { fullUrl: 'urn:uuid:4aac5fb6-c2ff-4851-b3cf-d66d63a82a17', @@ -234,7 +234,7 @@ const conditional: Bundle = method: 'POST', url: 'Organization', // highlight-next-line - ifNoneExist: 'identifer=https://example-org.com/organizations|example-organization', + ifNoneExist: 'identifier=https://example-org.com/organizations|example-organization', }, }, { diff --git a/packages/examples/src/fhir-datastore/resource-history.ts b/packages/examples/src/fhir-datastore/resource-history.ts index 1f25b34f9e..a483047006 100644 --- a/packages/examples/src/fhir-datastore/resource-history.ts +++ b/packages/examples/src/fhir-datastore/resource-history.ts @@ -1,5 +1,6 @@ // start-block imports import { MedplumClient } from '@medplum/core'; +import { Bundle } from '@medplum/fhirtypes'; // end-block imports const medplum = new MedplumClient(); @@ -19,3 +20,23 @@ curl 'https://api.medplum.com/fhir/R4/Patient/homer-simpson/_history' \ -H 'content-type: application/fhir+json' \ // end-block accessHistoryCurl */ + +// start-block revertChanges +// Read the history, returning a bundle of history entries +const history = await medplum.readHistory('Patient', 'homer-simpson'); + +// Implement your own logic to get the historic version of the resource you want. +// You will need the versionId to use the readVersion function. +const versionId = getVersionId(history); + +// readVersion will return the historic Patient resource +const version = await medplum.readVersion('Patient', 'homer-simpson', versionId); + +// Pass the historic version to updateResource to revert to that version +await medplum.updateResource(version); +// end-block revertChanges + +function getVersionId(history: Bundle): string { + console.log(history); + return 'versionId'; +} diff --git a/packages/expo-polyfills/package.json b/packages/expo-polyfills/package.json index f0d58c40c6..eb51ffcfaf 100644 --- a/packages/expo-polyfills/package.json +++ b/packages/expo-polyfills/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/expo-polyfills", - "version": "3.1.2", + "version": "3.1.3", "description": "A module for polyfilling the minimum necessary web APIs for using the Medplum client on React Native", "keywords": [ "react-native", @@ -47,9 +47,9 @@ "text-encoding": "0.7.0" }, "devDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "@types/base-64": "1.0.2", - "@types/react": "18.2.74", + "@types/react": "18.2.78", "@types/text-encoding": "0.0.39", "esbuild": "0.20.2", "esbuild-node-externals": "1.13.0", @@ -59,7 +59,7 @@ "ts-jest": "29.1.2" }, "peerDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "expo": "*", "expo-crypto": "^12.6.0", "expo-secure-store": "^12.3.1", diff --git a/packages/expo-polyfills/src/index.test.ts b/packages/expo-polyfills/src/index.test.ts index 5b2d137813..651ec99c56 100644 --- a/packages/expo-polyfills/src/index.test.ts +++ b/packages/expo-polyfills/src/index.test.ts @@ -5,20 +5,6 @@ import { Platform } from 'react-native'; import { TextDecoder, TextEncoder } from 'text-encoding'; import { ExpoClientStorage, cleanupMedplumWebAPIs, polyfillMedplumWebAPIs } from '.'; -const originalWindow = window; - -beforeAll(() => { - Object.defineProperty(globalThis, 'window', { - value: { ...originalWindow }, - }); -}); - -afterAll(() => { - Object.defineProperty(globalThis, 'window', { - value: originalWindow, - }); -}); - jest.mock('expo-secure-store', () => { const store = new Map(); let getKeysShouldThrow = false; @@ -58,11 +44,19 @@ if (Platform.OS === 'web') { } describe('polyfillMedplumWebAPIs', () => { + const originalWindow = globalThis.window; + beforeAll(() => { + Object.defineProperty(globalThis, 'window', { + value: { ...originalWindow }, + }); polyfillMedplumWebAPIs(); }); afterAll(() => { + Object.defineProperty(globalThis, 'window', { + value: originalWindow, + }); cleanupMedplumWebAPIs(); }); @@ -110,8 +104,6 @@ describe('polyfillMedplumWebAPIs', () => { expect(window.crypto.subtle).toBeDefined(); expect(window.crypto.subtle.digest).toBeDefined(); }); - - // TODO: Add a test for `digest` }); describe('Location', () => { diff --git a/packages/expo-polyfills/src/index.ts b/packages/expo-polyfills/src/index.ts index 3b7cd4366a..562cfe13e8 100644 --- a/packages/expo-polyfills/src/index.ts +++ b/packages/expo-polyfills/src/index.ts @@ -6,6 +6,7 @@ import expoWebCrypto from 'expo-standard-web-crypto'; import { Platform } from 'react-native'; import { setupURLPolyfill } from 'react-native-url-polyfill'; import { TextDecoder, TextEncoder } from 'text-encoding'; +import { polyfillEvent } from './polyfills/event'; let polyfilled = false; let originalCryptoIsSet = false; @@ -23,6 +24,7 @@ export type PolyfillEnabledConfig = { sessionStorage?: boolean; textEncoder?: boolean; btoa?: boolean; + event?: boolean; }; export function cleanupMedplumWebAPIs(): void { @@ -76,6 +78,10 @@ export function cleanupMedplumWebAPIs(): void { Object.defineProperty(window, 'atob', { configurable: true, enumerable: true, value: undefined }); } + if (window.Event) { + Object.defineProperty(window, 'Event', { configurable: true, enumerable: true, value: undefined }); + } + polyfilled = false; } @@ -166,6 +172,10 @@ export function polyfillMedplumWebAPIs(config?: PolyfillEnabledConfig): void { }); } + if (config?.event !== false && typeof window.Event === 'undefined') { + polyfillEvent(); + } + polyfilled = true; } diff --git a/packages/expo-polyfills/src/polyfills.test.ts b/packages/expo-polyfills/src/polyfills.test.ts index 6e9e3ee04e..f6f2b894dc 100644 --- a/packages/expo-polyfills/src/polyfills.test.ts +++ b/packages/expo-polyfills/src/polyfills.test.ts @@ -1,23 +1,23 @@ import { Platform } from 'react-native'; import { cleanupMedplumWebAPIs, polyfillMedplumWebAPIs } from '.'; -const originalWindow = window; +describe('Medplum polyfills', () => { + const originalWindow = window; -beforeEach(() => { - Object.defineProperty(globalThis, 'window', { - value: { ...originalWindow }, + beforeEach(() => { + cleanupMedplumWebAPIs(); }); -}); -afterAll(() => { - Object.defineProperty(globalThis, 'window', { - value: originalWindow, + beforeEach(() => { + Object.defineProperty(globalThis, 'window', { + value: { ...originalWindow }, + }); }); -}); -describe('Medplum polyfills', () => { - beforeEach(() => { - cleanupMedplumWebAPIs(); + afterAll(() => { + Object.defineProperty(globalThis, 'window', { + value: originalWindow, + }); }); if (Platform.OS !== 'web') { @@ -46,6 +46,21 @@ describe('Medplum polyfills', () => { // There was specifically trouble with this object when calling polyfill multiple times before expect(window.crypto.subtle).toBeDefined(); }); + + test('Event should be constructable after polyfills', () => { + // @ts-expect-error Testing polyfill + globalThis.Event = undefined; + expect(() => new Event('foo')).toThrow(); + polyfillMedplumWebAPIs(); + + const event1 = new Event('foo'); + expect(event1).toBeInstanceOf(Event); + expect(event1.type).toEqual('foo'); + + const event2 = new Event('foo', { bubbles: true, cancelable: true, composed: true }); + expect(event2).toBeInstanceOf(Event); + expect(event2.type).toEqual('foo'); + }); }); describe('cleanupMedplumWebAPIs()', () => { diff --git a/packages/expo-polyfills/src/polyfills/event.ts b/packages/expo-polyfills/src/polyfills/event.ts new file mode 100644 index 0000000000..5628a357e6 --- /dev/null +++ b/packages/expo-polyfills/src/polyfills/event.ts @@ -0,0 +1,40 @@ +// Original source: https://github.com/benlesh/event-target-polyfill/blob/master/index.js +// The package is no longer maintained, so I figured we can vendor it + +export function polyfillEvent(): void { + const root = ((typeof globalThis !== 'undefined' && globalThis) || + (typeof self !== 'undefined' && self) || + (typeof global !== 'undefined' && global)) as typeof globalThis; + + const shouldPolyfillEvent = (() => { + try { + // eslint-disable-next-line no-new + new root.Event(''); + } catch (_error) { + return true; + } + return false; + })(); + + if (shouldPolyfillEvent) { + // @ts-expect-error Types don't quite match up but it should be mostly good enough + root.Event = (() => { + class Event { + readonly type: string; + readonly bubbles: boolean; + readonly cancelable: boolean; + readonly composed: boolean; + defaultPrevented = false; + + constructor(type: string, options: EventInit) { + this.bubbles = !!options && !!options.bubbles; + this.cancelable = !!options && !!options.cancelable; + this.composed = !!options && !!options.composed; + this.type = type; + } + } + + return Event; + })(); + } +} diff --git a/packages/fhir-router/package.json b/packages/fhir-router/package.json index 20bc458fde..559214e229 100644 --- a/packages/fhir-router/package.json +++ b/packages/fhir-router/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/fhir-router", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum FHIR Router", "keywords": [ "medplum", @@ -53,9 +53,9 @@ "test": "jest" }, "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", "dataloader": "2.2.2", "graphql": "16.8.1", "rfc6902": "5.1.1" diff --git a/packages/fhirtypes/dist/Project.d.ts b/packages/fhirtypes/dist/Project.d.ts index 6b3e2fd52e..6b9d7ea392 100644 --- a/packages/fhirtypes/dist/Project.d.ts +++ b/packages/fhirtypes/dist/Project.d.ts @@ -94,10 +94,28 @@ export interface Project { defaultPatientAccessPolicy?: Reference; /** - * Secure environment variable that can be used to store secrets for - * bots. + * Option or parameter that can be adjusted within the Medplum Project to + * customize its behavior. */ - secret?: ProjectSecret[]; + setting?: ProjectSetting[]; + + /** + * Option or parameter that can be adjusted within the Medplum Project to + * customize its behavior, only visible to project administrators. + */ + secret?: ProjectSetting[]; + + /** + * Option or parameter that can be adjusted within the Medplum Project to + * customize its behavior, only modifiable by system administrators. + */ + systemSetting?: ProjectSetting[]; + + /** + * Option or parameter that can be adjusted within the Medplum Project to + * customize its behavior, only visible to system administrators. + */ + systemSecret?: ProjectSetting[]; /** * Web application or web site that is associated with the project. @@ -122,10 +140,10 @@ export interface ProjectLink { } /** - * Secure environment variable that can be used to store secrets for - * bots. + * Option or parameter that can be adjusted within the Medplum Project to + * customize its behavior. */ -export interface ProjectSecret { +export interface ProjectSetting { /** * The secret name. @@ -200,3 +218,8 @@ export interface ProjectSite { */ recaptchaSecretKey?: string; } + +/** + * @deprecated Use ProjectSetting instead + */ +export type ProjectSecret = ProjectSetting; diff --git a/packages/fhirtypes/package.json b/packages/fhirtypes/package.json index e765b47b5c..1ccdff719f 100644 --- a/packages/fhirtypes/package.json +++ b/packages/fhirtypes/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/fhirtypes", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum FHIR Type Definitions", "keywords": [ "medplum", diff --git a/packages/generator/package.json b/packages/generator/package.json index b40257b79e..19d8a69459 100644 --- a/packages/generator/package.json +++ b/packages/generator/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/generator", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Code Generator", "homepage": "https://www.medplum.com/", "repository": { @@ -24,19 +24,19 @@ "test": "jest" }, "devDependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", "@types/json-schema": "7.0.15", - "@types/pg": "8.11.4", + "@types/pg": "8.11.5", "@types/unzipper": "0.10.9", "fast-xml-parser": "4.3.6", "fhirpath": "3.11.0", "mkdirp": "3.0.1", "node-stream-zip": "1.15.0", "pg": "8.11.5", - "tinybench": "2.6.0", - "unzipper": "0.10.14" + "tinybench": "2.7.0", + "unzipper": "0.11.2" }, "engines": { "node": ">=18.0.0" diff --git a/packages/generator/src/index.ts b/packages/generator/src/index.ts index 41917b0224..2ca1223fcf 100644 --- a/packages/generator/src/index.ts +++ b/packages/generator/src/index.ts @@ -144,6 +144,13 @@ function writeInterface(b: FileBuilder, fhirType: InternalTypeSchema): void { writeInterface(b, subType); } } + + if (typeName === 'Project') { + // TODO: Remove this in Medplum v4 + b.newLine(); + generateJavadoc(b, '@deprecated Use ProjectSetting instead'); + b.append('export type ProjectSecret = ProjectSetting;'); + } } function writeInterfaceProperty( diff --git a/packages/graphiql/package.json b/packages/graphiql/package.json index c08acaa69b..a574cc63ce 100644 --- a/packages/graphiql/package.json +++ b/packages/graphiql/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/graphiql", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum GraphiQL", "homepage": "https://www.medplum.com/", "bugs": { @@ -25,18 +25,18 @@ "devDependencies": { "@graphiql/react": "0.21.0", "@graphiql/toolkit": "0.9.1", - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/react": "3.1.2", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/react": "3.1.3", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "graphiql": "3.2.0", "graphql": "16.8.1", "graphql-ws": "5.16.0", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "vite": "5.2.8" diff --git a/packages/health-gorilla/package.json b/packages/health-gorilla/package.json index 5fe12d8fa4..4f8953c488 100644 --- a/packages/health-gorilla/package.json +++ b/packages/health-gorilla/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/health-gorilla", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Health Gorilla SDK", "homepage": "https://www.medplum.com/", "bugs": { @@ -39,11 +39,11 @@ "test": "jest" }, "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/fhirtypes": "3.1.2" + "@medplum/core": "3.1.3", + "@medplum/fhirtypes": "3.1.3" }, "devDependencies": { - "@medplum/mock": "3.1.2" + "@medplum/mock": "3.1.3" }, "engines": { "node": ">=18.0.0" diff --git a/packages/hl7/package.json b/packages/hl7/package.json index 151410d4c6..7e3ff747cf 100644 --- a/packages/hl7/package.json +++ b/packages/hl7/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/hl7", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum HL7 Utilities", "keywords": [ "medplum", @@ -53,10 +53,10 @@ "test": "jest" }, "dependencies": { - "@medplum/core": "3.1.2" + "@medplum/core": "3.1.3" }, "devDependencies": { - "@medplum/fhirtypes": "3.1.2" + "@medplum/fhirtypes": "3.1.3" }, "engines": { "node": ">=18.0.0" diff --git a/packages/mock/package.json b/packages/mock/package.json index 579f182644..98f3fb6bbc 100644 --- a/packages/mock/package.json +++ b/packages/mock/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/mock", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Mock Client", "keywords": [ "medplum", @@ -53,10 +53,10 @@ "test": "jest" }, "dependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhir-router": "3.1.2", - "@medplum/fhirtypes": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhir-router": "3.1.3", + "@medplum/fhirtypes": "3.1.3", "dataloader": "2.2.2", "jest-websocket-mock": "2.5.0", "rfc6902": "5.1.1" diff --git a/packages/mock/src/client.ts b/packages/mock/src/client.ts index 9976b41b6e..4348d15d90 100644 --- a/packages/mock/src/client.ts +++ b/packages/mock/src/client.ts @@ -222,20 +222,23 @@ export class MockClient extends MedplumClient { if (!this.agentAvailable) { throw new OperationOutcomeError(badRequest('Timeout')); } - if (typeof destination === 'string' && destination !== '8.8.8.8') { + if (typeof destination !== 'string' || (destination !== '8.8.8.8' && destination !== 'localhost')) { // Exception for test case if (destination !== 'abc123') { - console.warn('IPs other than 8.8.8.8 will always throw an error in MockClient'); + console.warn( + 'IPs other than 8.8.8.8 and hostnames other than `localhost` will always throw an error in MockClient' + ); } throw new OperationOutcomeError(badRequest('Destination device not found')); } - return `PING 8.8.8.8 (8.8.8.8): 56 data bytes -64 bytes from 8.8.8.8: icmp_seq=0 ttl=115 time=10.977 ms -64 bytes from 8.8.8.8: icmp_seq=1 ttl=115 time=13.037 ms -64 bytes from 8.8.8.8: icmp_seq=2 ttl=115 time=23.159 ms -64 bytes from 8.8.8.8: icmp_seq=3 ttl=115 time=12.725 ms - ---- 8.8.8.8 ping statistics --- + const ip = destination === 'localhost' ? '127.0.0.1' : destination; + return `PING ${destination} (${ip}): 56 data bytes +64 bytes from ${ip}: icmp_seq=0 ttl=115 time=10.977 ms +64 bytes from ${ip}: icmp_seq=1 ttl=115 time=13.037 ms +64 bytes from ${ip}: icmp_seq=2 ttl=115 time=23.159 ms +64 bytes from ${ip}: icmp_seq=3 ttl=115 time=12.725 ms + +--- ${destination} ping statistics --- 4 packets transmitted, 4 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 10.977/14.975/23.159/4.790 ms `; diff --git a/packages/mock/src/mocks/structuredefinitions.json b/packages/mock/src/mocks/structuredefinitions.json index 9aec46297f..b1aa9e099a 100644 --- a/packages/mock/src/mocks/structuredefinitions.json +++ b/packages/mock/src/mocks/structuredefinitions.json @@ -12065,8 +12065,8 @@ ] }, { - "id": "Project.secret", - "path": "Project.secret", + "id": "Project.setting", + "path": "Project.setting", "min": 0, "max": "*", "type": [ @@ -12076,8 +12076,8 @@ ] }, { - "id": "Project.secret.name", - "path": "Project.secret.name", + "id": "Project.setting.name", + "path": "Project.setting.name", "min": 1, "max": "1", "type": [ @@ -12087,8 +12087,8 @@ ] }, { - "id": "Project.secret.value[x]", - "path": "Project.secret.value[x]", + "id": "Project.setting.value[x]", + "path": "Project.setting.value[x]", "min": 1, "max": "1", "type": [ @@ -12106,6 +12106,27 @@ } ] }, + { + "id": "Project.secret", + "path": "Project.secret", + "min": 0, + "max": "*", + "contentReference": "#Project.setting" + }, + { + "id": "Project.systemSetting", + "path": "Project.systemSetting", + "min": 0, + "max": "*", + "contentReference": "#Project.setting" + }, + { + "id": "Project.systemSecret", + "path": "Project.systemSecret", + "min": 0, + "max": "*", + "contentReference": "#Project.setting" + }, { "id": "Project.site", "path": "Project.site", diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 00af3c31ae..f2e11f00df 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/react-hooks", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum React Hooks Library", "keywords": [ "medplum", @@ -57,27 +57,27 @@ "test": "jest" }, "devDependencies": { - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", "@testing-library/dom": "10.0.0", "@testing-library/jest-dom": "6.4.2", - "@testing-library/react": "14.3.0", + "@testing-library/react": "15.0.2", "@types/jest": "29.5.12", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "jest": "29.7.0", "jest-each": "29.7.0", "jest-websocket-mock": "2.5.0", "react": "18.2.0", "react-dom": "18.2.0", "rimraf": "5.0.5", - "typescript": "5.4.4" + "typescript": "5.4.5" }, "peerDependencies": { - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "react": "^17.0.2 || ^18.0.0", "react-dom": "^17.0.2 || ^18.0.0" }, diff --git a/packages/react/.storybook/main.ts b/packages/react/.storybook/main.ts index 32dc913395..0e88894b41 100644 --- a/packages/react/.storybook/main.ts +++ b/packages/react/.storybook/main.ts @@ -16,6 +16,7 @@ const config: StorybookConfig = { }, }, }, + 'storybook-addon-mantine', ], staticDirs: ['../public'], framework: { diff --git a/packages/react/.storybook/preview-head.html b/packages/react/.storybook/preview-head.html new file mode 100644 index 0000000000..314bf801a1 --- /dev/null +++ b/packages/react/.storybook/preview-head.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/react/.storybook/preview.tsx b/packages/react/.storybook/preview.tsx index 44092fa60e..03bc56098a 100644 --- a/packages/react/.storybook/preview.tsx +++ b/packages/react/.storybook/preview.tsx @@ -1,9 +1,9 @@ -import { MantineProvider, MantineThemeOverride } from '@mantine/core'; import '@mantine/core/styles.css'; import { MockClient } from '@medplum/mock'; import { MedplumProvider } from '@medplum/react-hooks'; import { BrowserRouter } from 'react-router-dom'; import { createGlobalTimer } from '../src/stories/MockDateWrapper.utils'; +import { themes } from './themes'; export const parameters = { layout: 'fullscreen', @@ -26,32 +26,12 @@ medplum.get('/').then(() => { clock.restore(); }); -const theme: MantineThemeOverride = { - headings: { - sizes: { - h1: { - fontSize: '1.125rem', - fontWeight: '500', - lineHeight: '2.0', - }, - }, - }, - fontSizes: { - xs: '0.6875rem', - sm: '0.875rem', - md: '0.875rem', - lg: '1.0rem', - xl: '1.125rem', - }, -}; - export const decorators = [ + themes, (Story) => ( - - - + ), diff --git a/packages/react/.storybook/themes.ts b/packages/react/.storybook/themes.ts new file mode 100644 index 0000000000..c6dca1c83a --- /dev/null +++ b/packages/react/.storybook/themes.ts @@ -0,0 +1,481 @@ +import { createTheme } from '@mantine/core'; +import { withMantineThemes } from 'storybook-addon-mantine'; + +const medplumDefault = createTheme({ + headings: { + sizes: { + h1: { + fontSize: '1.125rem', + fontWeight: '500', + lineHeight: '2.0', + }, + }, + }, + fontSizes: { + xs: '0.6875rem', + sm: '0.875rem', + md: '0.875rem', + lg: '1.0rem', + xl: '1.125rem', + }, +}); + +const fooMedical = createTheme({ + colors: { + // Replace or adjust with the exact colors used for your design + primary: [ + '#f7f7f7', // primary[0] + '#eef6f4', // primary[1] + '#e3eff2', // primary[2] + '#d5ebec', // primary[3] + '#cfe7e9', // primary[4] + '#b0d7db', // primary[5] + '#39acbc', // primary[6] + '#005450', // primary[7] + '#004d49', // primary[8] + '#00353a', // primary[9] (adjusted for a darker shade) + ], + secondary: [ + '#fff7eb', // secondary[0] + '#ffedce', // secondary[1] + '#fae3c3', // secondary[2] + '#e9d1b9', // secondary[3] + '#e8c9a6', // secondary[4] + '#f1dfca', // secondary[5] + '#ffc776', // secondary[6] + '#fa645a', // secondary[7] + '#b57931', // secondary[8] + '#935923', // secondary[9] (adjusted for a darker shade) + ], + }, + primaryColor: 'primary', + fontFamily: 'Ginto, helvetica', + radius: { + xs: '.5rem', + sm: '.75rem', + md: '1rem', + lg: '1.5rem', + xl: '2.5rem', + }, + spacing: { + xs: '.25rem', + sm: '.33rem', + md: '.5rem', + lg: '.66rem', + xl: '1rem', + }, + defaultRadius: 'xl', + shadows: { + xs: '0px 0px 0px rgba(0, 0, 0, 0)', + md: '2px 2px 1.5px rgba(0, 0, 0, .25)', + xl: '5px 5px 3px rgba(0, 0, 0, .25)', + }, + headings: { + fontFamily: 'GT Super Display, serif', + sizes: { + h1: { fontSize: '30px', lineHeight: '1.4' }, + h2: { fontSize: '24px', lineHeight: '1.35' }, + h3: { fontSize: '20px', lineHeight: '1.3' }, + h4: { fontSize: '18px', lineHeight: '1.25' }, + h5: { fontSize: '16px', lineHeight: '1.2' }, + h6: { fontSize: '14px', lineHeight: '1.15' }, + }, + }, +}); + +const bonFoo = createTheme({ + components: { + Paper: { + defaultProps: { + p: 'sm', + shadow: 'xs', + }, + }, + Table: { + defaultProps: { + striped: false, + // m: '16px', + }, + }, + }, + fontFamily: + '-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji', + shadows: { + xs: 'none', + sm: 'none', + md: 'none', + lg: 'none', + xl: 'none', + }, + spacing: { + xs: '8px', + sm: '10px', + md: '12px', + lg: '14px', + xl: '16px', + }, + colors: { + destructive: [ + '#FFF5F5', + '#FFE3E3', + '#FFC9C9', + '#FFA8A8', + '#FF8787', + '#FF6B6B', + '#FA5252', + '#F03E3E', + '#E03131', + '#C92A2A', + ], + dark: [ + '#C1C2C5', + '#A6A7AB', + '#909296', + '#5C5F66', + '#373A40', + '#2C2E33', + '#25262B', + '#1A1B1E', + '#141517', + '#101113', + ], + primary: [ + '#E7F5FF', + '#D0EBFF', + '#A5D8FF', + '#74C0FC', + '#4DABF7', + '#339AF0', + '#228BE6', + '#1C7ED6', + '#1971C2', + '#1864AB', + ], + neutral: [ + '#F8F9FA', + '#F1F3F5', + '#E9ECEF', + '#DEE2E6', + '#CED4DA', + '#ADB5BD', + '#868E96', + '#495057', + '#343A40', + '#212529', + ], + }, +}); + +const plumMedical = createTheme({ + components: { + Divider: { + defaultProps: { + my: '0', + }, + }, + }, + colors: { + primary: [ + '#eef6f4', + '#00D7AB', + '#00B395', + '#00907E', + '#008062', + '#005450', + '#004D49', + '#003F3B', + '#003231', + '#002824', + ], + destructive: [ + '#e8d3cf', + '#ddb3b0', + '#d59091', + '#cf6e77', + '#ca4956', + '#bc2f3d', + '#a0222f', + '#821722', + '#620e16', + '#400707', + ], + }, + primaryColor: 'primary', + primaryShade: 6, + shadows: { + xs: '0px 0px 0px rgba(0, 0, 0, 0)', + sm: '0px 0px 0px rgba(0, 0, 0, 0)', + md: '0px 0px 0px rgba(0, 0, 0, 0)', + lg: '0px 0px 0px rgba(0, 0, 0, 0)', + xl: '0px 0px 0px rgba(0, 0, 0, 0)', + }, + fontFamily: '"Ginto", helvetica, "sans-serif"', + headings: { fontFamily: 'GT Super Display, Times New Roman, "serif"' }, + fontSizes: { + xs: '12px', + sm: '14px', + md: '18px', + lg: '22px', + xl: '30px', + }, + radius: { + xs: '5px', + sm: '7px', + md: '9px', + lg: '11px', + xl: '13px', + }, + defaultRadius: 'lg', + spacing: { + xs: '.5rem', + sm: '1rem', + md: '1.5rem', + lg: '2rem', + xl: '2.5rem', + }, +}); + +const materialUi = createTheme({ + fontFamily: 'Roboto, Helvetica, Arial, "sans-serif"', + fontSizes: { + xs: '.7rem', + sm: '.85rem', + md: '1rem', + lg: '1.2rem', + xl: '1.4rem', + }, + colors: { + primary: [ + '#cce5ff', + '#99ccff', + '#66b2ff', + '#3399ff', + '#0073e6', + '#0288d1', + '#006bd6', + '#0061c2', + '#004c99', + '#0037a5', + ], + }, + primaryColor: 'primary', + primaryShade: 4, + shadows: { + xs: '0px 2px 1px -1px rgba(0, 0, 0, 0.2)', + sm: '0px 2px 1px -1px rgba(0, 0, 0, 0.2)', + md: '0px 1px 1px 0px rgba(0, 0, 0, 0.14)', + lg: '0px 1px 1px 0px rgba(0, 0, 0, 0.14)', + xl: '0px 1px 3px 0px rgba(0, 0, 0, 0.12)', + }, + radius: { + xs: '0px', + sm: '2px', + md: '4px', + lg: '6px', + xl: '8px', + }, + spacing: { + xs: '4px 8px', + sm: '6px 12px', + md: '8px 16px', + lg: '10px 20px', + xl: '12px 24px', + }, +}); + +const sciFi = createTheme({ + fontFamily: '"Gill Sans", arial, "sans-serif"', + colors: { + primary: [ + '#FFFBB7', + '#FFF891', + '#FFF56A', + '#FFF244', + '#FFE81F', + '#FFD900', + '#ffb300', + '#f59b00', + '#eb8500', + '#e07000', + ], + }, + primaryColor: 'primary', + primaryShade: 6, + black: '#412538', + radius: { + xs: '20px 10px', + sm: '30px 15px', + md: '40px 20px', + lg: '50px 25px', + xl: '60px 30px', + }, + shadows: { + xs: '2px 1px 1px -1px #939393', + md: '3px 2px 1px -1px #939393', + xl: '4px 3px 1px -1px #939393', + }, + spacing: { + xs: '1px', + sm: '3px', + md: '5px', + lg: '7px', + xl: '9px', + }, + lineHeights: { + xs: '12px', + sm: '16px', + md: '20px', + lg: '24px', + xl: '30px', + }, +}); + +const cursive = createTheme({ + fontFamily: '"Brush Script MT", serif', + colors: { + primary: [ + '#fce8e8', + '#f7cfd5', + '#f1bcc9', + '#e7a6c0', + '#d987b0', + '#c770a4', + '#b65d9c', + '#a85d9a', + '#845282', + '#604965', + ], + }, + primaryColor: 'primary', + primaryShade: 5, + radius: { + xs: '0', + sm: '0', + md: '0', + lg: '0', + xl: '0', + }, + shadows: { + xs: '4px 4px 3px grey', + sm: '8px 8px 3px grey', + md: '12px 12px 3px grey', + lg: '16px 16px 3px grey', + xl: '20px 20px 3px grey', + }, +}); + +const caesar = createTheme({ + fontFamily: '"Caesar Dressing", serif', + fontSizes: { + xs: '.8rem', + sm: '.9rem', + md: '1rem', + lg: '1.1rem', + xl: '1.2rem', + }, + colors: { + primary: [ + '#fd5d6b', + '#fb3737', + '#f81b1b', + '#d70909', + '#a00808', + '#810e0e', + '#601410', + '#4b1711', + '#34150f', + '#25120e', + ], + }, + primaryColor: 'primary', + primaryShade: 4, + shadows: { + xs: '3px 3px 2px grey', + xl: '5px 5px 2px grey', + }, +}); + +const wordArt = createTheme({ + fontFamily: '"Bungee Spice", "sans-serif"', + defaultRadius: '0px', + shadows: { + xs: '0px 0px 0px', + sm: '0px 0px 0px', + md: '0px 0px 0px', + lg: '0px 0px 0px', + xl: '0px 0px 0px', + }, + colors: { + primary: [ + '#bcfeae', + '#90fa85', + '#64f55c', + '#34ed31', + '#1acf17', + '#1da21a', + '#1c7e1b', + '#1d5e20', + '#183f1c', + '#122b17', + ], + }, + primaryColor: 'primary', + primaryShade: 4, + spacing: { + xs: '12px', + sm: '16px', + md: '20px', + lg: '24px', + xl: '30px', + }, +}); + +export const themes = withMantineThemes({ + themes: [ + { + id: 'medplumDefault', + name: 'Medplum Default', + ...medplumDefault, + }, + { + id: 'foomedical', + name: 'Foo Medical', + ...fooMedical, + }, + { + id: 'bonfoo', + name: 'Bon Foo', + ...bonFoo, + }, + { + id: 'plumMedical', + name: 'PlumMedical', + ...plumMedical, + }, + { + id: 'materialUi', + name: 'Material UI', + ...materialUi, + }, + { + id: 'sci-fi', + name: 'SciFi', + ...sciFi, + }, + { + id: 'cursive', + name: 'Cursive', + ...cursive, + }, + { + id: 'caesar', + name: 'Caesar', + ...caesar, + }, + { + id: 'word-art', + name: 'Word Art', + ...wordArt, + }, + ], +}); diff --git a/packages/react/package.json b/packages/react/package.json index 89d71cdab7..3e8089231f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/react", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum React Component Library", "keywords": [ "medplum", @@ -67,51 +67,52 @@ "test": "jest" }, "devDependencies": { - "@mantine/core": "7.7.1", - "@mantine/hooks": "7.7.1", - "@mantine/notifications": "7.7.1", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhirtypes": "3.1.2", - "@medplum/mock": "3.1.2", - "@medplum/react-hooks": "3.1.2", - "@storybook/addon-actions": "8.0.6", - "@storybook/addon-essentials": "8.0.6", - "@storybook/addon-links": "8.0.6", - "@storybook/addon-storysource": "8.0.6", - "@storybook/blocks": "^8.0.6", - "@storybook/builder-vite": "8.0.6", - "@storybook/react": "8.0.6", - "@storybook/react-vite": "8.0.6", - "@tabler/icons-react": "3.1.0", + "@mantine/core": "7.8.0", + "@mantine/hooks": "7.8.0", + "@mantine/notifications": "7.8.0", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhirtypes": "3.1.3", + "@medplum/mock": "3.1.3", + "@medplum/react-hooks": "3.1.3", + "@storybook/addon-actions": "8.0.8", + "@storybook/addon-essentials": "8.0.8", + "@storybook/addon-links": "8.0.8", + "@storybook/addon-storysource": "8.0.8", + "@storybook/blocks": "^8.0.8", + "@storybook/builder-vite": "8.0.8", + "@storybook/react": "8.0.8", + "@storybook/react-vite": "8.0.8", + "@tabler/icons-react": "3.2.0", "@testing-library/dom": "10.0.0", "@testing-library/jest-dom": "6.4.2", - "@testing-library/react": "14.3.0", + "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.12", - "@types/node": "20.12.5", - "@types/react": "18.2.74", - "@types/react-dom": "18.2.24", + "@types/node": "20.12.7", + "@types/react": "18.2.78", + "@types/react-dom": "18.2.25", "@vitejs/plugin-react": "4.2.1", "chromatic": "11.0.0", "jest": "29.7.0", "jest-each": "29.7.0", "postcss": "8.4.38", - "postcss-preset-mantine": "1.13.0", + "postcss-preset-mantine": "1.14.4", "react": "18.2.0", "react-dom": "18.2.0", "rfc6902": "5.1.1", "rimraf": "5.0.5", "sinon": "17.0.1", - "storybook": "8.0.6", - "typescript": "5.4.4", + "storybook": "8.0.8", + "typescript": "5.4.5", + "storybook-addon-mantine": "4.0.2", "vite-plugin-turbosnap": "^1.0.3" }, "peerDependencies": { "@mantine/core": "^7.0.0", "@mantine/hooks": "^7.0.0", "@mantine/notifications": "^7.0.0", - "@medplum/core": "3.1.2", + "@medplum/core": "3.1.3", "react": "^17.0.2 || ^18.0.0", "react-dom": "^17.0.2 || ^18.0.0", "rfc6902": "^5.0.1" diff --git a/packages/react/src/AsyncAutocomplete/AsyncAutocomplete.tsx b/packages/react/src/AsyncAutocomplete/AsyncAutocomplete.tsx index 277ae18f37..fac146d6dd 100644 --- a/packages/react/src/AsyncAutocomplete/AsyncAutocomplete.tsx +++ b/packages/react/src/AsyncAutocomplete/AsyncAutocomplete.tsx @@ -11,7 +11,7 @@ import { } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { normalizeErrorString } from '@medplum/core'; -import { KeyboardEvent, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { KeyboardEvent, ReactNode, SyntheticEvent, useCallback, useEffect, useRef, useState } from 'react'; import { killEvent } from '../utils/dom'; export interface AsyncAutocompleteOption extends ComboboxItem { @@ -129,7 +129,7 @@ export function AsyncAutocomplete(props: AsyncAutocompleteProps): JSX.Elem }, [combobox, loadOptions, onChange, toOption]); const handleSearchChange = useCallback( - (e: React.SyntheticEvent): void => { + (e: SyntheticEvent): void => { if ((options && options.length > 0) || creatable) { combobox.openDropdown(); } diff --git a/packages/react/src/CheckboxFormSection/CheckboxFormSection.tsx b/packages/react/src/CheckboxFormSection/CheckboxFormSection.tsx index 6d985e7eee..0629914702 100644 --- a/packages/react/src/CheckboxFormSection/CheckboxFormSection.tsx +++ b/packages/react/src/CheckboxFormSection/CheckboxFormSection.tsx @@ -15,7 +15,7 @@ export interface CheckboxFormSectionProps { export function CheckboxFormSection(props: CheckboxFormSectionProps): JSX.Element { const { debugMode } = useContext(ElementsContext); - let label: React.ReactNode; + let label: ReactNode; if (debugMode && props.fhirPath) { label = `${props.title} - ${props.fhirPath}`; } else { diff --git a/packages/react/src/ElementsInput/ElementsInput.utils.ts b/packages/react/src/ElementsInput/ElementsInput.utils.ts index 67ff6a3e11..70e7da1a78 100644 --- a/packages/react/src/ElementsInput/ElementsInput.utils.ts +++ b/packages/react/src/ElementsInput/ElementsInput.utils.ts @@ -1,8 +1,8 @@ import { ElementsContextType, InternalSchemaElement, isPopulated } from '@medplum/core'; -import React from 'react'; +import { createContext } from 'react'; import { DEFAULT_IGNORED_NON_NESTED_PROPERTIES, DEFAULT_IGNORED_PROPERTIES } from '../constants'; -export const ElementsContext = React.createContext({ +export const ElementsContext = createContext({ path: '', profileUrl: undefined, elements: Object.create(null), diff --git a/packages/react/src/FormSection/FormSection.tsx b/packages/react/src/FormSection/FormSection.tsx index ef1e1c8707..06219fe1aa 100644 --- a/packages/react/src/FormSection/FormSection.tsx +++ b/packages/react/src/FormSection/FormSection.tsx @@ -1,8 +1,8 @@ import { Input } from '@mantine/core'; import { OperationOutcome } from '@medplum/fhirtypes'; import { ReactNode, useContext } from 'react'; -import { getErrorsForInput } from '../utils/outcomes'; import { ElementsContext } from '../ElementsInput/ElementsInput.utils'; +import { getErrorsForInput } from '../utils/outcomes'; export interface FormSectionProps { readonly title?: string; @@ -19,7 +19,7 @@ export interface FormSectionProps { export function FormSection(props: FormSectionProps): JSX.Element { const { debugMode } = useContext(ElementsContext); - let label: React.ReactNode; + let label: ReactNode; if (debugMode && props.fhirPath) { label = `${props.title} - ${props.fhirPath}`; } else { diff --git a/packages/react/src/Panel/Panel.tsx b/packages/react/src/Panel/Panel.tsx index 87ff3ec529..2d6aae14ac 100644 --- a/packages/react/src/Panel/Panel.tsx +++ b/packages/react/src/Panel/Panel.tsx @@ -1,11 +1,12 @@ import { Paper, PaperProps } from '@mantine/core'; import cx from 'clsx'; +import { ReactNode } from 'react'; import classes from './Panel.module.css'; export interface PanelProps extends PaperProps { readonly width?: number; readonly fill?: boolean; - readonly children?: React.ReactNode; + readonly children?: ReactNode; } export function Panel(props: PanelProps): JSX.Element { diff --git a/packages/react/src/ReferenceRangeEditor/ReferenceRangeEditor.tsx b/packages/react/src/ReferenceRangeEditor/ReferenceRangeEditor.tsx index 78d5b8a124..941c052234 100644 --- a/packages/react/src/ReferenceRangeEditor/ReferenceRangeEditor.tsx +++ b/packages/react/src/ReferenceRangeEditor/ReferenceRangeEditor.tsx @@ -179,7 +179,7 @@ export function ReferenceRangeGroupEditor(props: ReferenceRangeGroupEditorProps) data-testid={`remove-group-button-${intervalGroup.id}`} key={`remove-group-button-${intervalGroup.id}`} size="sm" - onClick={(e: React.MouseEvent) => { + onClick={(e: MouseEvent) => { killEvent(e); props.onRemoveGroup(intervalGroup); }} @@ -209,7 +209,7 @@ export function ReferenceRangeGroupEditor(props: ReferenceRangeGroupEditorProps) size="sm" key={`remove-interval-${interval.id}`} data-testid={`remove-interval-${interval.id}`} - onClick={(e: React.MouseEvent) => { + onClick={(e: MouseEvent) => { killEvent(e); props.onRemove(intervalGroup.id, interval); }} @@ -232,7 +232,7 @@ export function ReferenceRangeGroupEditor(props: ReferenceRangeGroupEditorProps) title="Add Interval" variant="subtle" size="sm" - onClick={(e: React.MouseEvent) => { + onClick={(e: MouseEvent) => { killEvent(e); props.onAdd(intervalGroup.id, { range: { diff --git a/packages/react/src/SliceInput/SliceInput.tsx b/packages/react/src/SliceInput/SliceInput.tsx index 1603a6a84f..6c953c6883 100644 --- a/packages/react/src/SliceInput/SliceInput.tsx +++ b/packages/react/src/SliceInput/SliceInput.tsx @@ -8,16 +8,16 @@ import { isEmpty, isPopulated, } from '@medplum/core'; -import { useContext, useMemo, useState } from 'react'; +import { MouseEvent, useContext, useMemo, useState } from 'react'; import { ElementsContext } from '../ElementsInput/ElementsInput.utils'; import { FormSection } from '../FormSection/FormSection'; +import classes from '../ResourceArrayInput/ResourceArrayInput.module.css'; import { ElementDefinitionTypeInput } from '../ResourcePropertyInput/ResourcePropertyInput'; +import { BaseInputProps } from '../ResourcePropertyInput/ResourcePropertyInput.utils'; import { ArrayAddButton } from '../buttons/ArrayAddButton'; import { ArrayRemoveButton } from '../buttons/ArrayRemoveButton'; import { killEvent } from '../utils/dom'; -import classes from '../ResourceArrayInput/ResourceArrayInput.module.css'; import { maybeWrapWithContext } from '../utils/maybeWrapWithContext'; -import { BaseInputProps } from '../ResourcePropertyInput/ResourcePropertyInput.utils'; export interface SliceInputProps extends BaseInputProps { readonly slice: SliceDefinitionWithTypes; @@ -96,7 +96,7 @@ export function SliceInput(props: SliceInputProps): JSX.Element | null { { + onClick={(e: MouseEvent) => { killEvent(e); const newValues = [...values]; newValues.splice(valueIndex, 1); @@ -111,7 +111,7 @@ export function SliceInput(props: SliceInputProps): JSX.Element | null { { + onClick={(e: MouseEvent) => { killEvent(e); const newValues = [...values, undefined]; setValuesWrapper(newValues); diff --git a/packages/react/src/buttons/ArrayAddButton.tsx b/packages/react/src/buttons/ArrayAddButton.tsx index 254818d9b6..874429ce1d 100644 --- a/packages/react/src/buttons/ArrayAddButton.tsx +++ b/packages/react/src/buttons/ArrayAddButton.tsx @@ -1,9 +1,10 @@ -import { Button, ActionIcon } from '@mantine/core'; +import { ActionIcon, Button } from '@mantine/core'; import { IconCirclePlus } from '@tabler/icons-react'; +import { MouseEventHandler } from 'react'; export interface ArrayAddButtonProps { readonly propertyDisplayName?: string; - readonly onClick: React.MouseEventHandler; + readonly onClick: MouseEventHandler; readonly testId?: string; } diff --git a/packages/react/src/buttons/ArrayRemoveButton.tsx b/packages/react/src/buttons/ArrayRemoveButton.tsx index bc78478711..40970a2b18 100644 --- a/packages/react/src/buttons/ArrayRemoveButton.tsx +++ b/packages/react/src/buttons/ArrayRemoveButton.tsx @@ -1,9 +1,10 @@ import { ActionIcon } from '@mantine/core'; import { IconCircleMinus } from '@tabler/icons-react'; +import { MouseEventHandler } from 'react'; export interface ArrayRemoveButtonProps { readonly propertyDisplayName?: string; - readonly onClick: React.MouseEventHandler; + readonly onClick: MouseEventHandler; readonly testId?: string; } diff --git a/packages/react/src/chat/ChatModal/ChatModal.tsx b/packages/react/src/chat/ChatModal/ChatModal.tsx index 4654078519..50dab73bdd 100644 --- a/packages/react/src/chat/ChatModal/ChatModal.tsx +++ b/packages/react/src/chat/ChatModal/ChatModal.tsx @@ -1,12 +1,12 @@ import { ActionIcon } from '@mantine/core'; import { useMedplumProfile } from '@medplum/react-hooks'; import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'; -import { useEffect, useState } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; import classes from './ChatModal.module.css'; export interface ChatModalProps { readonly open?: boolean; - readonly children: React.ReactNode; + readonly children: ReactNode; } export function ChatModal(props: ChatModalProps): JSX.Element | null { diff --git a/packages/react/src/test-utils/render.tsx b/packages/react/src/test-utils/render.tsx index 9a7e8639e7..8fcf582122 100644 --- a/packages/react/src/test-utils/render.tsx +++ b/packages/react/src/test-utils/render.tsx @@ -12,17 +12,15 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; export { act, fireEvent, screen, userEvent, waitFor, within }; const theme = {}; -export function render( - ui: React.ReactNode, - wrapper?: ({ children }: { children: React.ReactNode }) => JSX.Element -): RenderResult { +export function render(ui: ReactNode, wrapper?: ({ children }: { children: ReactNode }) => JSX.Element): RenderResult { return testingLibraryRender(ui, { - wrapper: ({ children }: { children: React.ReactNode }) => ( + wrapper: ({ children }: { children: ReactNode }) => ( {wrapper ? wrapper({ children }) : children} ), }); diff --git a/packages/react/src/utils/dom.ts b/packages/react/src/utils/dom.ts index 551b089fef..7b94e7cef4 100644 --- a/packages/react/src/utils/dom.ts +++ b/packages/react/src/utils/dom.ts @@ -1,10 +1,12 @@ +import { SyntheticEvent } from 'react'; + /** * Kills a browser event. * Prevents default behavior. * Stops event propagation. * @param e - The event. */ -export function killEvent(e: Event | React.SyntheticEvent): void { +export function killEvent(e: Event | SyntheticEvent): void { e.preventDefault(); e.stopPropagation(); } diff --git a/packages/react/src/utils/maybeWrapWithContext.tsx b/packages/react/src/utils/maybeWrapWithContext.tsx index c21d70acbb..08bfd8c3a1 100644 --- a/packages/react/src/utils/maybeWrapWithContext.tsx +++ b/packages/react/src/utils/maybeWrapWithContext.tsx @@ -1,5 +1,7 @@ +import { Context } from 'react'; + export function maybeWrapWithContext( - ContextProvider: React.Context['Provider'], + ContextProvider: Context['Provider'], contextValue: T | undefined, contents: JSX.Element ): JSX.Element { diff --git a/packages/server/jest.config.json b/packages/server/jest.config.json deleted file mode 100644 index 8e2a0d7497..0000000000 --- a/packages/server/jest.config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "testEnvironment": "node", - "testTimeout": 600000, - "testSequencer": "/jest.sequencer.js", - "transform": { - "^.+\\.(js|jsx|ts|tsx)$": "babel-jest" - }, - "moduleFileExtensions": ["ts", "js", "json", "node"], - "testMatch": ["**/src/**/*.test.ts"], - "coverageDirectory": "coverage", - "coverageReporters": ["json", "text"], - "collectCoverageFrom": ["**/src/**/*", "!**/src/__mocks__/**/*.ts", "!**/src/migrations/**/*.ts"] -} diff --git a/packages/server/jest.config.ts b/packages/server/jest.config.ts new file mode 100644 index 0000000000..9c24d5533e --- /dev/null +++ b/packages/server/jest.config.ts @@ -0,0 +1,15 @@ +import type { Config } from 'jest'; + +export default { + testEnvironment: 'node', + testTimeout: 600000, + testSequencer: '/jest.sequencer.js', + transform: { + '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', + }, + testMatch: ['/src/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json', 'node'], + coverageDirectory: 'coverage', + coverageReporters: ['json', 'text'], + collectCoverageFrom: ['**/src/**/*', '!**/src/__mocks__/**/*.ts', '!**/src/migrations/**/*.ts'], +} satisfies Config; diff --git a/packages/server/jest.seed.config.ts b/packages/server/jest.seed.config.ts new file mode 100644 index 0000000000..b428e8b3d5 --- /dev/null +++ b/packages/server/jest.seed.config.ts @@ -0,0 +1,8 @@ +import type { Config } from 'jest'; +import defaultConfig from './jest.config'; + +export default { + ...defaultConfig, + testMatch: ['/seed-tests/**/*.test.ts'], + collectCoverageFrom: ['/seed-tests/**/*'], +} satisfies Config; diff --git a/packages/server/package.json b/packages/server/package.json index a9993bf9a6..4abc16f616 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@medplum/server", - "version": "3.1.2", + "version": "3.1.3", "description": "Medplum Server", "homepage": "https://www.medplum.com/", "bugs": { @@ -18,21 +18,23 @@ "clean": "rimraf dist", "dev": "ts-node-dev --poll --respawn --transpile-only --require ./src/otel/instrumentation.ts src/index.ts", "start": "node --require ./dist/otel/instrumentation.js dist/index.js", - "test": "jest --runInBand" + "test:seed:serial": "jest seed-serial.test.ts --config jest.seed.config.ts --coverageDirectory \"/coverage-seed/serial\"", + "test:seed:parallel": "jest seed.test.ts --config jest.seed.config.ts --coverageDirectory \"/coverage-seed/parallel\"", + "test": "docker-compose -f ../../docker-compose.seed.yml up -d && npm run test:seed:parallel && jest" }, "dependencies": { - "@aws-sdk/client-cloudwatch-logs": "3.549.0", - "@aws-sdk/client-lambda": "3.549.0", - "@aws-sdk/client-s3": "3.550.0", - "@aws-sdk/client-secrets-manager": "3.549.0", - "@aws-sdk/client-sesv2": "3.549.0", - "@aws-sdk/client-ssm": "3.549.0", + "@aws-sdk/client-cloudwatch-logs": "3.554.0", + "@aws-sdk/client-lambda": "3.554.0", + "@aws-sdk/client-s3": "3.554.0", + "@aws-sdk/client-secrets-manager": "3.554.0", + "@aws-sdk/client-sesv2": "3.554.0", + "@aws-sdk/client-ssm": "3.554.0", "@aws-sdk/cloudfront-signer": "3.541.0", - "@aws-sdk/lib-storage": "3.550.0", + "@aws-sdk/lib-storage": "3.554.0", "@aws-sdk/types": "3.535.0", - "@medplum/core": "3.1.2", - "@medplum/definitions": "3.1.2", - "@medplum/fhir-router": "3.1.2", + "@medplum/core": "3.1.3", + "@medplum/definitions": "3.1.3", + "@medplum/fhir-router": "3.1.3", "@opentelemetry/auto-instrumentations-node": "0.44.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.50.0", "@opentelemetry/exporter-trace-otlp-proto": "0.50.0", @@ -42,7 +44,7 @@ "@smithy/util-stream": "2.2.0", "bcryptjs": "2.4.3", "body-parser": "1.20.2", - "bullmq": "5.5.4", + "bullmq": "5.7.1", "bytes": "3.1.2", "compression": "1.7.4", "cookie-parser": "1.4.6", @@ -71,7 +73,7 @@ }, "devDependencies": { "@jest/test-sequencer": "29.7.0", - "@medplum/fhirtypes": "3.1.2", + "@medplum/fhirtypes": "3.1.3", "@types/bcryptjs": "2.4.6", "@types/body-parser": "1.19.5", "@types/bytes": "3.1.4", @@ -82,10 +84,10 @@ "@types/express-rate-limit": "5.1.3", "@types/json-schema": "7.0.15", "@types/mailparser": "3.4.4", - "@types/node": "20.12.5", + "@types/node": "20.12.7", "@types/node-fetch": "2.6.11", "@types/nodemailer": "6.4.14", - "@types/pg": "8.11.2", + "@types/pg": "8.11.5", "@types/set-cookie-parser": "2.4.7", "@types/supertest": "6.0.2", "@types/ua-parser-js": "0.7.39", @@ -94,7 +96,7 @@ "@types/ws": "8.5.10", "aws-sdk-client-mock": "4.0.0", "aws-sdk-client-mock-jest": "4.0.0", - "mailparser": "3.6.9", + "mailparser": "3.7.0", "openapi3-ts": "4.3.1", "set-cookie-parser": "2.6.0", "supertest": "6.3.4", diff --git a/packages/server/src/seed.test.ts b/packages/server/seed-tests/seed-serial.test.ts similarity index 56% rename from packages/server/src/seed.test.ts rename to packages/server/seed-tests/seed-serial.test.ts index 2d72346245..1e8500b6c2 100644 --- a/packages/server/src/seed.test.ts +++ b/packages/server/seed-tests/seed-serial.test.ts @@ -1,16 +1,19 @@ import { Project } from '@medplum/fhirtypes'; -import { initAppServices, shutdownApp } from './app'; -import { loadTestConfig } from './config'; -import { getDatabasePool } from './database'; -import { SelectQuery } from './fhir/sql'; -import { seedDatabase } from './seed'; -import { withTestContext } from './test.setup'; +import { initAppServices, shutdownApp } from '../src/app'; +import { loadTestConfig } from '../src/config'; +import { getDatabasePool } from '../src/database'; +import { SelectQuery } from '../src/fhir/sql'; +import { seedDatabase } from '../src/seed'; +import { withTestContext } from '../src/test.setup'; describe('Seed', () => { beforeAll(async () => { console.log = jest.fn(); const config = await loadTestConfig(); + config.database.port = process.env['POSTGRES_SEED_PORT'] + ? Number.parseInt(process.env['POSTGRES_SEED_PORT'], 10) + : 5433; return withTestContext(() => initAppServices(config)); }); @@ -18,9 +21,9 @@ describe('Seed', () => { await shutdownApp(); }); - test('Seeder completes successfully', async () => { + test('Seeder completes successfully -- serial version', async () => { // First time, seeder should run - await seedDatabase(); + await seedDatabase({ parallel: false }); // Make sure the first project is a super admin const rows = await new SelectQuery('Project') @@ -34,6 +37,6 @@ describe('Seed', () => { expect(project.strictMode).toBe(true); // Second time, seeder should silently ignore - await seedDatabase(); + await seedDatabase({ parallel: false }); }, 240000); }); diff --git a/packages/server/seed-tests/seed.test.ts b/packages/server/seed-tests/seed.test.ts new file mode 100644 index 0000000000..129bd383d2 --- /dev/null +++ b/packages/server/seed-tests/seed.test.ts @@ -0,0 +1,42 @@ +import { Project } from '@medplum/fhirtypes'; +import { initAppServices, shutdownApp } from '../src/app'; +import { loadTestConfig } from '../src/config'; +import { getDatabasePool } from '../src/database'; +import { SelectQuery } from '../src/fhir/sql'; +import { seedDatabase } from '../src/seed'; +import { withTestContext } from '../src/test.setup'; + +describe('Seed', () => { + beforeAll(async () => { + console.log = jest.fn(); + + const config = await loadTestConfig(); + return withTestContext(() => initAppServices(config)); + }); + + afterAll(async () => { + await shutdownApp(); + }); + + test('Seeder completes successfully', async () => { + // First time, seeder should run + await seedDatabase(); + + // Make sure all database migrations have run + const pool = getDatabasePool(); + const result = await pool.query('SELECT "version" FROM "DatabaseMigration"'); + const version = result.rows[0]?.version ?? -1; + expect(version).toBeGreaterThanOrEqual(67); + + // Make sure the first project is a super admin + const rows = await new SelectQuery('Project').column('content').where('name', '=', 'Super Admin').execute(pool); + expect(rows.length).toBe(1); + + const project = JSON.parse(rows[0].content) as Project; + expect(project.superAdmin).toBe(true); + expect(project.strictMode).toBe(true); + + // Second time, seeder should silently ignore + await seedDatabase(); + }, 240000); +}); diff --git a/packages/server/src/admin/invite.test.ts b/packages/server/src/admin/invite.test.ts index 061f2bdae0..0487054490 100644 --- a/packages/server/src/admin/invite.test.ts +++ b/packages/server/src/admin/invite.test.ts @@ -10,7 +10,6 @@ import { simpleParser } from 'mailparser'; import fetch from 'node-fetch'; import { Readable } from 'stream'; import request from 'supertest'; - import { initApp, shutdownApp } from '../app'; import { registerNew } from '../auth/register'; import { loadTestConfig } from '../config'; @@ -26,6 +25,7 @@ describe('Admin Invite', () => { beforeAll(async () => { const config = await loadTestConfig(); + config.emailProvider = 'awsses'; await withTestContext(() => initApp(app, config)); }); diff --git a/packages/server/src/admin/project.test.ts b/packages/server/src/admin/project.test.ts index b44cf7d887..5f10e047c9 100644 --- a/packages/server/src/admin/project.test.ts +++ b/packages/server/src/admin/project.test.ts @@ -1,4 +1,3 @@ -import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; import { createReference } from '@medplum/core'; import { ProjectMembership } from '@medplum/fhirtypes'; import { randomUUID } from 'crypto'; @@ -7,12 +6,11 @@ import { pwnedPassword } from 'hibp'; import fetch from 'node-fetch'; import request from 'supertest'; import { initApp, shutdownApp } from '../app'; -import { registerNew, RegisterResponse } from '../auth/register'; +import { RegisterResponse, registerNew } from '../auth/register'; import { loadTestConfig } from '../config'; import { addTestUser, setupPwnedPasswordMock, setupRecaptchaMock, withTestContext } from '../test.setup'; import { inviteUser } from './invite'; -jest.mock('@aws-sdk/client-sesv2'); jest.mock('hibp'); jest.mock('node-fetch'); @@ -43,8 +41,6 @@ describe('Project Admin routes', () => { }); beforeEach(() => { - (SESv2Client as unknown as jest.Mock).mockClear(); - (SendEmailCommand as unknown as jest.Mock).mockClear(); (fetch as unknown as jest.Mock).mockClear(); (pwnedPassword as unknown as jest.Mock).mockClear(); setupPwnedPasswordMock(pwnedPassword as unknown as jest.Mock, 0); diff --git a/packages/server/src/agent/utils.ts b/packages/server/src/agent/utils.ts new file mode 100644 index 0000000000..a5b4263552 --- /dev/null +++ b/packages/server/src/agent/utils.ts @@ -0,0 +1,11 @@ +export enum AgentConnectionState { + UNKNOWN = 'unknown', + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', +} + +export type AgentInfo = { + status: AgentConnectionState; + version: string; + lastUpdated?: string; +}; diff --git a/packages/server/src/agent/websockets.test.ts b/packages/server/src/agent/websockets.test.ts index eec0385fc0..d0fa8abb0f 100644 --- a/packages/server/src/agent/websockets.test.ts +++ b/packages/server/src/agent/websockets.test.ts @@ -1,11 +1,13 @@ -import { allOk, ContentType, getReferenceString, Hl7Message } from '@medplum/core'; +import { allOk, ContentType, getReferenceString, Hl7Message, MEDPLUM_VERSION, sleep } from '@medplum/core'; import { Agent, Bot, Device } from '@medplum/fhirtypes'; import express from 'express'; import { Server } from 'http'; import request from 'superwstest'; import { initApp, shutdownApp } from '../app'; import { loadTestConfig, MedplumServerConfig } from '../config'; +import { getRedis } from '../redis'; import { initTestAuth } from '../test.setup'; +import { AgentConnectionState, AgentInfo } from './utils'; const app = express(); let config: MedplumServerConfig; @@ -19,6 +21,7 @@ describe('Agent WebSockets', () => { beforeAll(async () => { config = await loadTestConfig(); config.vmContextBotsEnabled = true; + config.heartbeatMilliseconds = 5000; server = await initApp(app, config); accessToken = await initTestAuth({ membership: { admin: true } }); @@ -325,6 +328,7 @@ describe('Agent WebSockets', () => { .set('Authorization', 'Bearer ' + accessToken) .send({ waitForResponse: true, + waitTimeout: 500, destination: getReferenceString(device), contentType: ContentType.HL7_V2, body: @@ -407,14 +411,30 @@ describe('Agent WebSockets', () => { agentId: agent.id, }) ) - .expectText('{"type":"agent:connect:response"}') + .expectJson({ type: 'agent:connect:response' }) + .expectJson({ type: 'agent:heartbeat:request' }) // Send a ping - .sendText(JSON.stringify({ type: 'agent:heartbeat:request' })) - .expectText('{"type":"agent:heartbeat:response"}') + .sendJson({ type: 'agent:heartbeat:request' }) + .expectJson({ type: 'agent:heartbeat:response', version: MEDPLUM_VERSION }) // Simulate a ping response - .sendText(JSON.stringify({ type: 'agent:heartbeat:response' })) + .sendJson({ type: 'agent:heartbeat:response', version: MEDPLUM_VERSION }) .close() .expectClosed(); + + let info: AgentInfo = { status: AgentConnectionState.UNKNOWN, version: 'unknown' }; + for (let i = 0; i < 5; i++) { + await sleep(50); + const infoStr = (await getRedis().get(`medplum:agent:${agent.id as string}:info`)) as string; + info = JSON.parse(infoStr) as AgentInfo; + if (info.status === AgentConnectionState.DISCONNECTED) { + break; + } + } + expect(info).toMatchObject({ + status: AgentConnectionState.DISCONNECTED, + version: MEDPLUM_VERSION, + lastUpdated: expect.any(String), + }); }); test('Ping IP', async () => { diff --git a/packages/server/src/agent/websockets.ts b/packages/server/src/agent/websockets.ts index d6cfba398e..feb5885c94 100644 --- a/packages/server/src/agent/websockets.ts +++ b/packages/server/src/agent/websockets.ts @@ -4,21 +4,24 @@ import { AgentTransmitRequest, ContentType, Hl7Message, + MEDPLUM_VERSION, getReferenceString, normalizeErrorString, } from '@medplum/core'; import { Agent, Bot, Reference } from '@medplum/fhirtypes'; -import { AsyncLocalStorage } from 'async_hooks'; -import { IncomingMessage } from 'http'; import { Redis } from 'ioredis'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { IncomingMessage } from 'node:http'; import ws from 'ws'; import { getRepoForLogin } from '../fhir/accesspolicy'; import { executeBot } from '../fhir/operations/execute'; import { heartbeat } from '../heartbeat'; +import { globalLogger } from '../logger'; import { getLoginForAccessToken } from '../oauth/utils'; -import { getRedis } from '../redis'; +import { getRedis, getRedisSubscriber } from '../redis'; +import { AgentConnectionState, AgentInfo } from './utils'; -const STATUS_EX_SECONDS = 24 * 60 * 60; // 24 hours in seconds +const INFO_EX_SECONDS = 24 * 60 * 60; // 24 hours in seconds /** * Handles a new WebSocket connection to the agent service. @@ -51,11 +54,11 @@ export async function handleAgentConnection(socket: ws.WebSocket, request: Incom break; case 'agent:heartbeat:request': - sendMessage({ type: 'agent:heartbeat:response' }); + sendMessage({ type: 'agent:heartbeat:response', version: MEDPLUM_VERSION }); break; case 'agent:heartbeat:response': - await updateStatus('connected'); + await updateAgentInfo({ status: AgentConnectionState.CONNECTED, version: command.version }); break; // @ts-expect-error - Deprecated message type @@ -83,7 +86,7 @@ export async function handleAgentConnection(socket: ws.WebSocket, request: Incom socket.on( 'close', AsyncLocalStorage.bind(async () => { - await updateStatus('disconnected'); + await updateAgentStatus(AgentConnectionState.DISCONNECTED); heartbeat.removeEventListener('heartbeat', heartbeatHandler); redisSubscriber?.disconnect(); redisSubscriber = undefined; @@ -110,12 +113,18 @@ export async function handleAgentConnection(socket: ws.WebSocket, request: Incom agentId = command.agentId; - const { login, project, membership } = await getLoginForAccessToken(command.accessToken); + const authState = await getLoginForAccessToken(command.accessToken); + if (!authState) { + sendError('Invalid access token'); + return; + } + + const { login, project, membership } = authState; const repo = await getRepoForLogin(login, membership, project, true); const agent = await repo.readResource('Agent', agentId); // Connect to Redis - redisSubscriber = getRedis().duplicate(); + redisSubscriber = getRedisSubscriber(); await redisSubscriber.subscribe(getReferenceString(agent)); redisSubscriber.on('message', (_channel: string, message: string) => { // When a message is received, send it to the agent @@ -129,7 +138,7 @@ export async function handleAgentConnection(socket: ws.WebSocket, request: Incom sendMessage({ type: 'agent:connect:response' }); // Update the agent status in Redis - await updateStatus('connected'); + await updateAgentStatus(AgentConnectionState.CONNECTED); } /** @@ -158,7 +167,13 @@ export async function handleAgentConnection(socket: ws.WebSocket, request: Incom return; } - const { login, project, membership } = await getLoginForAccessToken(command.accessToken); + const authState = await getLoginForAccessToken(command.accessToken); + if (!authState) { + sendError('Invalid access token'); + return; + } + + const { login, project, membership } = authState; const repo = await getRepoForLogin(login, membership, project, true); const agent = await repo.readResource('Agent', agentId); const channel = agent?.channel?.find((c) => c.name === command.channel); @@ -204,23 +219,53 @@ export async function handleAgentConnection(socket: ws.WebSocket, request: Incom } /** - * Updates the agent status in Redis. - * This is used by the Agent "$status" operation to monitor agent status. + * Updates the agent info in Redis. + * This is used by the Agent "$status" operation to monitor agent status and other info. * See packages/server/src/fhir/operations/agentstatus.ts for more details. - * @param status - The new status. + * @param info - The latest info received from the Agent. */ - async function updateStatus(status: string): Promise { + async function updateAgentInfo(info: AgentInfo): Promise { if (!agentId) { // Not connected } - await getRedis().set( - `medplum:agent:${agentId}:status`, + + let redis: Redis; + try { + redis = getRedis(); + } catch (err) { + globalLogger.warn(`[Agent]: Attempted to update agent info after server closed. ${normalizeErrorString(err)}`); + return; + } + + await redis.set( + `medplum:agent:${agentId}:info`, JSON.stringify({ - status, + ...info, lastUpdated: new Date().toISOString(), - }), + } satisfies AgentInfo), 'EX', - STATUS_EX_SECONDS + INFO_EX_SECONDS ); } + + async function updateAgentStatus(status: AgentConnectionState): Promise { + if (!agentId) { + // Not connected + } + + let redis: Redis; + try { + redis = getRedis(); + } catch (err) { + globalLogger.warn(`[Agent]: Attempted to update agent status after server closed. ${normalizeErrorString(err)}`); + return; + } + + const lastInfo = await redis.get(`medplum:agent:${agentId}:info`); + if (!lastInfo) { + await updateAgentInfo({ status, version: 'unknown', lastUpdated: new Date().toISOString() }); + return; + } + await updateAgentInfo({ ...(JSON.parse(lastInfo) as AgentInfo), status }); + } } diff --git a/packages/server/src/app.test.ts b/packages/server/src/app.test.ts index 2251926ba6..f134b5ab3e 100644 --- a/packages/server/src/app.test.ts +++ b/packages/server/src/app.test.ts @@ -63,6 +63,28 @@ describe('App', () => { expect(await shutdownApp()).toBeUndefined(); }); + test('X-Forwarded-For spoofing', async () => { + const app = express(); + const config = await loadTestConfig(); + config.logLevel = 'info'; + config.logRequests = true; + + const originalWrite = process.stdout.write; + process.stdout.write = jest.fn(); + + await initApp(app, config); + const res = await request(app).get('/').set('X-Forwarded-For', '1.1.1.1, 2.2.2.2'); + expect(res.status).toBe(200); + expect(process.stdout.write).toHaveBeenCalledTimes(1); + + const logLine = (process.stdout.write as jest.Mock).mock.calls[0][0]; + const logObj = JSON.parse(logLine); + expect(logObj.ip).toBe('2.2.2.2'); + + expect(await shutdownApp()).toBeUndefined(); + process.stdout.write = originalWrite; + }); + test('Internal Server Error', async () => { const app = express(); app.get('/throw', () => { diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 510113a0b5..ee51a467b7 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -36,7 +36,7 @@ import { keyValueRouter } from './keyvalue/routes'; import { initKeys } from './oauth/keys'; import { oauthRouter } from './oauth/routes'; import { openApiHandler } from './openapi'; -import { closeRateLimiter } from './ratelimit'; +import { closeRateLimiter, getRateLimiter } from './ratelimit'; import { closeRedis, initRedis } from './redis'; import { scimRouter } from './scim/routes'; import { seedDatabase } from './seed'; @@ -135,12 +135,13 @@ export async function initApp(app: Express, config: MedplumServerConfig): Promis initWebSockets(server); app.set('etag', false); - app.set('trust proxy', true); + app.set('trust proxy', 1); app.set('x-powered-by', false); app.use(standardHeaders); app.use(cors(corsOptions)); app.use(compression()); app.use(attachRequestContext); + app.use(getRateLimiter()); app.use('/fhir/R4/Binary', binaryRouter); app.use( urlencoded({ diff --git a/packages/server/src/auth/changepassword.test.ts b/packages/server/src/auth/changepassword.test.ts index b3fda1c1b7..61d3f8f71b 100644 --- a/packages/server/src/auth/changepassword.test.ts +++ b/packages/server/src/auth/changepassword.test.ts @@ -1,4 +1,3 @@ -import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; import { badRequest } from '@medplum/core'; import { randomUUID } from 'crypto'; import express from 'express'; @@ -10,7 +9,6 @@ import { loadTestConfig } from '../config'; import { setupPwnedPasswordMock, setupRecaptchaMock, withTestContext } from '../test.setup'; import { registerNew } from './register'; -jest.mock('@aws-sdk/client-sesv2'); jest.mock('hibp'); jest.mock('node-fetch'); @@ -27,8 +25,6 @@ describe('Change Password', () => { }); beforeEach(() => { - (SESv2Client as unknown as jest.Mock).mockClear(); - (SendEmailCommand as unknown as jest.Mock).mockClear(); (fetch as unknown as jest.Mock).mockClear(); (pwnedPassword as unknown as jest.Mock).mockClear(); setupPwnedPasswordMock(pwnedPassword as unknown as jest.Mock, 0); diff --git a/packages/server/src/auth/external.test.ts b/packages/server/src/auth/external.test.ts index 3b7d193604..f12ce7e175 100644 --- a/packages/server/src/auth/external.test.ts +++ b/packages/server/src/auth/external.test.ts @@ -106,6 +106,12 @@ describe('External', () => { expect(res.body.issue[0].details.text).toBe('Missing state'); }); + test('Invalid JSON state', async () => { + const res = await request(app).get('/auth/external?code=xyz&state=xyz'); + expect(res.status).toBe(400); + expect(res.body.issue[0].details.text).toBe('Invalid state'); + }); + test('Unknown domain', async () => { // Build the external callback URL with an unrecognized domain const url = appendQueryParams('/auth/external', { diff --git a/packages/server/src/auth/external.ts b/packages/server/src/auth/external.ts index 82ad68d16c..2174ca2bd4 100644 --- a/packages/server/src/auth/external.ts +++ b/packages/server/src/auth/external.ts @@ -47,7 +47,13 @@ export const externalCallbackHandler = async (req: Request, res: Response): Prom return; } - const body = JSON.parse(state) as ExternalAuthState; + let body: ExternalAuthState; + try { + body = JSON.parse(state); + } catch (err) { + sendOutcome(res, badRequest('Invalid state')); + return; + } const { idp, client } = await getIdentityProvider(body); if (!idp) { diff --git a/packages/server/src/auth/login.test.ts b/packages/server/src/auth/login.test.ts index c3a5f01b88..75719c9bfa 100644 --- a/packages/server/src/auth/login.test.ts +++ b/packages/server/src/auth/login.test.ts @@ -29,6 +29,7 @@ describe('Login', () => { beforeAll(() => withTestContext(async () => { const config = await loadTestConfig(); + config.emailProvider = 'awsses'; await initApp(app, config); // Create a test project diff --git a/packages/server/src/auth/profile.test.ts b/packages/server/src/auth/profile.test.ts index f85275bbcd..6787de46af 100644 --- a/packages/server/src/auth/profile.test.ts +++ b/packages/server/src/auth/profile.test.ts @@ -9,8 +9,6 @@ import { getSystemRepo } from '../fhir/repo'; import { withTestContext } from '../test.setup'; import { registerNew } from './register'; -jest.mock('@aws-sdk/client-sesv2'); - const app = express(); const email = `multi${randomUUID()}@example.com`; const password = randomUUID(); diff --git a/packages/server/src/auth/resetpassword.test.ts b/packages/server/src/auth/resetpassword.test.ts index 42490c3748..72fbd8d5b6 100644 --- a/packages/server/src/auth/resetpassword.test.ts +++ b/packages/server/src/auth/resetpassword.test.ts @@ -24,6 +24,7 @@ describe('Reset Password', () => { beforeAll(async () => { const config = await loadTestConfig(); + config.emailProvider = 'awsses'; await initApp(app, config); }); diff --git a/packages/server/src/auth/revoke.test.ts b/packages/server/src/auth/revoke.test.ts index 0686ed1317..aa91a9cc2d 100644 --- a/packages/server/src/auth/revoke.test.ts +++ b/packages/server/src/auth/revoke.test.ts @@ -9,8 +9,6 @@ import { withTestContext } from '../test.setup'; import { registerNew } from './register'; import { setPassword } from './setpassword'; -jest.mock('@aws-sdk/client-sesv2'); - const app = express(); describe('Revoke', () => { diff --git a/packages/server/src/auth/routes.ts b/packages/server/src/auth/routes.ts index ed21829ef7..ee02e35db3 100644 --- a/packages/server/src/auth/routes.ts +++ b/packages/server/src/auth/routes.ts @@ -1,7 +1,8 @@ +import { badRequest } from '@medplum/core'; +import { OperationOutcome, Project } from '@medplum/fhirtypes'; import { Router } from 'express'; import { asyncWrap } from '../async'; import { authenticateRequest } from '../oauth/middleware'; -import { getRateLimiter } from '../ratelimit'; import { changePasswordHandler, changePasswordValidator } from './changepassword'; import { exchangeHandler, exchangeValidator } from './exchange'; import { externalCallbackHandler } from './external'; @@ -18,14 +19,11 @@ import { resetPasswordHandler, resetPasswordValidator } from './resetpassword'; import { revokeHandler, revokeValidator } from './revoke'; import { scopeHandler, scopeValidator } from './scope'; import { setPasswordHandler, setPasswordValidator } from './setpassword'; -import { verifyEmailHandler, verifyEmailValidator } from './verifyemail'; import { statusHandler, statusValidator } from './status'; -import { badRequest } from '@medplum/core'; -import { OperationOutcome, Project } from '@medplum/fhirtypes'; import { validateRecaptcha } from './utils'; +import { verifyEmailHandler, verifyEmailValidator } from './verifyemail'; export const authRouter = Router(); -authRouter.use(getRateLimiter()); authRouter.use('/mfa', mfaRouter); authRouter.post('/method', methodValidator, asyncWrap(methodHandler)); authRouter.get('/external', asyncWrap(externalCallbackHandler)); diff --git a/packages/server/src/auth/setpassword.test.ts b/packages/server/src/auth/setpassword.test.ts index d0a230bad5..ffa3ee9b2a 100644 --- a/packages/server/src/auth/setpassword.test.ts +++ b/packages/server/src/auth/setpassword.test.ts @@ -23,6 +23,7 @@ const app = express(); describe('Set Password', () => { beforeAll(async () => { const config = await loadTestConfig(); + config.emailProvider = 'awsses'; await initApp(app, config); }); diff --git a/packages/server/src/cloud/aws/config.test.ts b/packages/server/src/cloud/aws/config.test.ts new file mode 100644 index 0000000000..dd77a4a498 --- /dev/null +++ b/packages/server/src/cloud/aws/config.test.ts @@ -0,0 +1,71 @@ +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { GetParametersByPathCommand, SSMClient } from '@aws-sdk/client-ssm'; +import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { getConfig, loadConfig } from '../../config'; + +describe('Config', () => { + let mockSSMClient: AwsClientStub; + let mockSecretsManagerClient: AwsClientStub; + + beforeEach(() => { + mockSSMClient = mockClient(SSMClient); + mockSecretsManagerClient = mockClient(SecretsManagerClient); + + mockSecretsManagerClient.on(GetSecretValueCommand).resolves({ + SecretString: JSON.stringify({ host: 'host', port: 123 }), + }); + + mockSSMClient.on(GetParametersByPathCommand).resolves({ + Parameters: [ + { Name: 'baseUrl', Value: 'https://www.example.com/' }, + { Name: 'database.ssl.require', Value: 'true' }, + { Name: 'database.ssl.rejectUnauthorized', Value: 'true' }, + { Name: 'database.ssl.ca', Value: 'DatabaseSslCa' }, + { Name: 'DatabaseSecrets', Value: 'DatabaseSecretsArn' }, + { Name: 'RedisSecrets', Value: 'RedisSecretsArn' }, + { Name: 'port', Value: '8080' }, + { Name: 'botCustomFunctionsEnabled', Value: 'true' }, + { Name: 'logAuditEvents', Value: 'true' }, + { Name: 'registerEnabled', Value: 'false' }, + ], + }); + }); + + afterEach(() => { + mockSSMClient.restore(); + mockSecretsManagerClient.restore(); + }); + + test('Load AWS config', async () => { + const config = await loadConfig('aws:test'); + expect(config).toBeDefined(); + expect(config.baseUrl).toBeDefined(); + expect(config.port).toEqual(8080); + expect(config.botCustomFunctionsEnabled).toEqual(true); + expect(config.logAuditEvents).toEqual(true); + expect(config.registerEnabled).toEqual(false); + expect(config.database).toBeDefined(); + expect(config.database.ssl).toBeDefined(); + expect(config.database.ssl?.require).toEqual(true); + expect(config.database.ssl?.rejectUnauthorized).toEqual(true); + expect(config.database.ssl?.ca).toEqual('DatabaseSslCa'); + expect(getConfig()).toBe(config); + expect(mockSSMClient).toReceiveCommand(GetParametersByPathCommand); + }); + + test('Load region AWS config', async () => { + const config = await loadConfig('aws:ap-southeast-2:test'); + expect(config).toBeDefined(); + expect(config.baseUrl).toBeDefined(); + expect(config.port).toEqual(8080); + expect(getConfig()).toBe(config); + expect(mockSecretsManagerClient).toReceiveCommand(GetSecretValueCommand); + expect(mockSecretsManagerClient).toReceiveCommandWith(GetSecretValueCommand, { + SecretId: 'DatabaseSecretsArn', + }); + expect(mockSecretsManagerClient).toReceiveCommandWith(GetSecretValueCommand, { + SecretId: 'RedisSecretsArn', + }); + }); +}); diff --git a/packages/server/src/cloud/aws/config.ts b/packages/server/src/cloud/aws/config.ts new file mode 100644 index 0000000000..9ca28b8550 --- /dev/null +++ b/packages/server/src/cloud/aws/config.ts @@ -0,0 +1,118 @@ +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { GetParametersByPathCommand, Parameter, SSMClient } from '@aws-sdk/client-ssm'; +import { splitN } from '@medplum/core'; +import { MedplumServerConfig } from '../../config'; + +const DEFAULT_AWS_REGION = 'us-east-1'; + +/** + * Loads configuration settings from AWS SSM Parameter Store. + * @param path - The AWS SSM Parameter Store path prefix. + * @returns The loaded configuration. + */ +export async function loadAwsConfig(path: string): Promise { + let region = DEFAULT_AWS_REGION; + if (path.includes(':')) { + [region, path] = splitN(path, ':', 2); + } + + const client = new SSMClient({ region }); + const config: Record = {}; + const parameters = [] as Parameter[]; + let nextToken: string | undefined; + do { + const response = await client.send( + new GetParametersByPathCommand({ + Path: path, + NextToken: nextToken, + WithDecryption: true, + }) + ); + if (response.Parameters) { + parameters.push(...response.Parameters); + } + nextToken = response.NextToken; + } while (nextToken); + + // Load special AWS Secrets Manager secrets first + for (const param of parameters) { + const key = (param.Name as string).replace(path, ''); + const value = param.Value as string; + if (key === 'DatabaseSecrets') { + config['database'] = await loadAwsSecrets(region, value); + } else if (key === 'RedisSecrets') { + config['redis'] = await loadAwsSecrets(region, value); + } + } + + // Then load other parameters, which may override the secrets + for (const param of parameters) { + const key = (param.Name as string).replace(path, ''); + const value = param.Value as string; + setValue(config, key, value); + } + + return config as MedplumServerConfig; +} + +/** + * Returns the AWS Database Secret data as a JSON map. + * @param region - The AWS region. + * @param secretId - Secret ARN + * @returns The secret data as a JSON map. + */ +async function loadAwsSecrets(region: string, secretId: string): Promise | undefined> { + const client = new SecretsManagerClient({ region }); + const result = await client.send(new GetSecretValueCommand({ SecretId: secretId })); + + if (!result.SecretString) { + return undefined; + } + + return JSON.parse(result.SecretString); +} + +function setValue(config: Record, key: string, value: string): void { + const keySegments = key.split('.'); + let obj = config; + + while (keySegments.length > 1) { + const segment = keySegments.shift() as string; + if (!obj[segment]) { + obj[segment] = {}; + } + obj = obj[segment] as Record; + } + + let parsedValue: any = value; + if (isIntegerConfig(key)) { + parsedValue = parseInt(value, 10); + } else if (isBooleanConfig(key)) { + parsedValue = value === 'true'; + } else if (isObjectConfig(key)) { + parsedValue = JSON.parse(value); + } + + obj[keySegments[0]] = parsedValue; +} + +function isIntegerConfig(key: string): boolean { + return key === 'port' || key === 'accurateCountThreshold'; +} + +function isBooleanConfig(key: string): boolean { + return ( + key === 'botCustomFunctionsEnabled' || + key === 'database.ssl.rejectUnauthorized' || + key === 'database.ssl.require' || + key === 'logRequests' || + key === 'logAuditEvents' || + key === 'registerEnabled' || + key === 'require' || + key === 'rejectUnauthorized' + ); +} + +function isObjectConfig(key: string): boolean { + return key === 'tls'; +} diff --git a/packages/server/src/cloud/aws/deploy.test.ts b/packages/server/src/cloud/aws/deploy.test.ts new file mode 100644 index 0000000000..2482d5f102 --- /dev/null +++ b/packages/server/src/cloud/aws/deploy.test.ts @@ -0,0 +1,255 @@ +import { + CreateFunctionCommand, + GetFunctionCommand, + GetFunctionConfigurationCommand, + LambdaClient, + ListLayerVersionsCommand, + UpdateFunctionCodeCommand, + UpdateFunctionConfigurationCommand, +} from '@aws-sdk/client-lambda'; +import { ContentType } from '@medplum/core'; +import { Bot } from '@medplum/fhirtypes'; +import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import express from 'express'; +import request from 'supertest'; +import { initApp, shutdownApp } from '../../app'; +import { loadTestConfig } from '../../config'; +import { initTestAuth } from '../../test.setup'; + +const app = express(); +let accessToken: string; +let mockLambdaClient: AwsClientStub; + +describe('Deploy', () => { + beforeAll(async () => { + const config = await loadTestConfig(); + await initApp(app, config); + accessToken = await initTestAuth(); + }); + + afterAll(async () => { + await shutdownApp(); + }); + + beforeEach(() => { + let created = false; + + mockLambdaClient = mockClient(LambdaClient); + + mockLambdaClient.on(CreateFunctionCommand).callsFake(({ FunctionName }) => { + created = true; + + return { + Configuration: { + FunctionName, + }, + }; + }); + + mockLambdaClient.on(GetFunctionCommand).callsFake(({ FunctionName }) => { + if (created) { + return { + Configuration: { + FunctionName, + }, + }; + } + + return { + Configuration: {}, + }; + }); + + mockLambdaClient.on(GetFunctionConfigurationCommand).callsFake(({ FunctionName }) => { + return { + FunctionName, + Runtime: 'nodejs18.x', + Handler: 'index.handler', + State: 'Active', + Layers: [ + { + Arn: 'arn:aws:lambda:us-east-1:123456789012:layer:test-layer:1', + }, + ], + }; + }); + + mockLambdaClient.on(ListLayerVersionsCommand).resolves({ + LayerVersions: [ + { + LayerVersionArn: 'arn:aws:lambda:us-east-1:123456789012:layer:test-layer:1', + }, + ], + }); + + mockLambdaClient.on(UpdateFunctionCodeCommand).callsFake(({ FunctionName }) => ({ + Configuration: { + FunctionName, + }, + })); + }); + + afterEach(() => { + mockLambdaClient.restore(); + }); + + test('Happy path', async () => { + // Step 1: Create a bot + const res1 = await request(app) + .post(`/fhir/R4/Bot`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + resourceType: 'Bot', + name: 'Test Bot', + runtimeVersion: 'awslambda', + code: ` + export async function handler() { + console.log('input', input); + return input; + } + `, + }); + expect(res1.status).toBe(201); + + const bot = res1.body as Bot; + const name = `medplum-bot-lambda-${bot.id}`; + + // Step 2: Deploy the bot + const res2 = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$deploy`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + code: ` + export async function handler() { + console.log('input', input); + return input; + } + `, + }); + expect(res2.status).toBe(200); + + expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(ListLayerVersionsCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(CreateFunctionCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandWith(GetFunctionCommand, { + FunctionName: name, + }); + expect(mockLambdaClient).toHaveReceivedCommandWith(CreateFunctionCommand, { + FunctionName: name, + }); + mockLambdaClient.resetHistory(); + + // Step 3: Deploy again to trigger the update path + const res3 = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$deploy`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + code: ` + export async function handler() { + console.log('input', input); + return input; + } + `, + filename: 'updated.js', + }); + expect(res3.status).toBe(200); + + expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(ListLayerVersionsCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionConfigurationCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(UpdateFunctionConfigurationCommand, 0); + expect(mockLambdaClient).toHaveReceivedCommandTimes(UpdateFunctionCodeCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandWith(GetFunctionCommand, { + FunctionName: name, + }); + }); + + test('Deploy bot with lambda layer update', async () => { + // When deploying a bot, we check if we need to update the bot configuration. + // This test verifies that we correctly update the bot configuration when the lambda layer changes. + // Step 1: Create a bot + const res1 = await request(app) + .post(`/fhir/R4/Bot`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + resourceType: 'Bot', + name: 'Test Bot', + runtimeVersion: 'awslambda', + code: ` + export async function handler() { + console.log('input', input); + return input; + } + `, + }); + expect(res1.status).toBe(201); + + const bot = res1.body as Bot; + const name = `medplum-bot-lambda-${bot.id}`; + + // Step 2: Deploy the bot + const res2 = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$deploy`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + code: ` + export async function handler() { + console.log('input', input); + return input; + } + `, + }); + expect(res2.status).toBe(200); + + expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(ListLayerVersionsCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(CreateFunctionCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandWith(GetFunctionCommand, { + FunctionName: name, + }); + expect(mockLambdaClient).toHaveReceivedCommandWith(CreateFunctionCommand, { + FunctionName: name, + }); + mockLambdaClient.resetHistory(); + + // Step 3: Simulate releasing a new version of the lambda layer + mockLambdaClient.on(ListLayerVersionsCommand).resolves({ + LayerVersions: [ + { + LayerVersionArn: 'new-layer-version-arn', + }, + ], + }); + + // Step 4: Deploy again to trigger the update path + const res3 = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$deploy`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + code: ` + export async function handler() { + console.log('input', input); + return input; + } + `, + filename: 'updated.js', + }); + expect(res3.status).toBe(200); + + expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(ListLayerVersionsCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionConfigurationCommand, 2); + expect(mockLambdaClient).toHaveReceivedCommandTimes(UpdateFunctionConfigurationCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandTimes(UpdateFunctionCodeCommand, 1); + expect(mockLambdaClient).toHaveReceivedCommandWith(GetFunctionCommand, { + FunctionName: name, + }); + }); +}); diff --git a/packages/server/src/cloud/aws/deploy.ts b/packages/server/src/cloud/aws/deploy.ts new file mode 100644 index 0000000000..50dc4477dd --- /dev/null +++ b/packages/server/src/cloud/aws/deploy.ts @@ -0,0 +1,260 @@ +import { + CreateFunctionCommand, + GetFunctionCommand, + GetFunctionConfigurationCommand, + GetFunctionConfigurationCommandOutput, + LambdaClient, + ListLayerVersionsCommand, + PackageType, + UpdateFunctionCodeCommand, + UpdateFunctionConfigurationCommand, +} from '@aws-sdk/client-lambda'; +import { sleep } from '@medplum/core'; +import { Bot } from '@medplum/fhirtypes'; +import { ConfiguredRetryStrategy } from '@smithy/util-retry'; +import JSZip from 'jszip'; +import { getConfig } from '../../config'; +import { getRequestContext } from '../../context'; + +const LAMBDA_RUNTIME = 'nodejs18.x'; + +const LAMBDA_HANDLER = 'index.handler'; + +const LAMBDA_MEMORY = 1024; + +const WRAPPER_CODE = `const { ContentType, Hl7Message, MedplumClient } = require("@medplum/core"); +const fetch = require("node-fetch"); +const PdfPrinter = require("pdfmake"); +const userCode = require("./user.js"); + +exports.handler = async (event, context) => { + const { bot, baseUrl, accessToken, contentType, secrets, traceId } = event; + const medplum = new MedplumClient({ + baseUrl, + fetch: function(url, options = {}) { + options.headers ||= {}; + options.headers['X-Trace-Id'] = traceId; + options.headers['traceparent'] = traceId; + return fetch(url, options); + }, + createPdf, + }); + medplum.setAccessToken(accessToken); + try { + let input = event.input; + if (contentType === ContentType.HL7_V2 && input) { + input = Hl7Message.parse(input); + } + let result = await userCode.handler(medplum, { bot, input, contentType, secrets, traceId }); + if (contentType === ContentType.HL7_V2 && result) { + result = result.toString(); + } + return result; + } catch (err) { + if (err instanceof Error) { + console.log("Unhandled error: " + err.message + "\\n" + err.stack); + } else if (typeof err === "object") { + console.log("Unhandled error: " + JSON.stringify(err, undefined, 2)); + } else { + console.log("Unhandled error: " + err); + } + throw err; + } +}; + +function createPdf(docDefinition, tableLayouts, fonts) { + if (!fonts) { + fonts = { + Helvetica: { + normal: 'Helvetica', + bold: 'Helvetica-Bold', + italics: 'Helvetica-Oblique', + bolditalics: 'Helvetica-BoldOblique', + }, + Roboto: { + normal: '/opt/fonts/Roboto/Roboto-Regular.ttf', + bold: '/opt/fonts/Roboto/Roboto-Medium.ttf', + italics: '/opt/fonts/Roboto/Roboto-Italic.ttf', + bolditalics: '/opt/fonts/Roboto/Roboto-MediumItalic.ttf' + }, + Avenir: { + normal: '/opt/fonts/Avenir/Avenir.ttf' + } + }; + } + return new Promise((resolve, reject) => { + const printer = new PdfPrinter(fonts); + const pdfDoc = printer.createPdfKitDocument(docDefinition, { tableLayouts }); + const chunks = []; + pdfDoc.on('data', (chunk) => chunks.push(chunk)); + pdfDoc.on('end', () => resolve(Buffer.concat(chunks))); + pdfDoc.on('error', reject); + pdfDoc.end(); + }); +} +`; + +export async function deployLambda(bot: Bot, code: string): Promise { + const ctx = getRequestContext(); + + // Create a new AWS Lambda client + // Use a custom retry strategy to avoid throttling errors + // This is especially important when updating lambdas which also + // involve upgrading the layer version. + const client = new LambdaClient({ + region: getConfig().awsRegion, + retryStrategy: new ConfiguredRetryStrategy( + 5, // max attempts + (attempt: number) => 500 * 2 ** attempt // Exponential backoff + ), + }); + + const name = `medplum-bot-lambda-${bot.id}`; + ctx.logger.info('Deploying lambda function for bot', { name }); + const zipFile = await createZipFile(code); + ctx.logger.debug('Lambda function zip size', { bytes: zipFile.byteLength }); + + const exists = await lambdaExists(client, name); + if (!exists) { + await createLambda(client, name, zipFile); + } else { + await updateLambda(client, name, zipFile); + } +} + +async function createZipFile(code: string): Promise { + const zip = new JSZip(); + zip.file('user.js', code); + zip.file('index.js', WRAPPER_CODE); + return zip.generateAsync({ type: 'uint8array' }); +} + +/** + * Returns true if the AWS Lambda exists for the bot name. + * @param client - The AWS Lambda client. + * @param name - The bot name. + * @returns True if the bot exists. + */ +async function lambdaExists(client: LambdaClient, name: string): Promise { + try { + const command = new GetFunctionCommand({ FunctionName: name }); + const response = await client.send(command); + return response.Configuration?.FunctionName === name; + } catch (err) { + return false; + } +} + +/** + * Creates a new AWS Lambda for the bot name. + * @param client - The AWS Lambda client. + * @param name - The bot name. + * @param zipFile - The zip file with the bot code. + */ +async function createLambda(client: LambdaClient, name: string, zipFile: Uint8Array): Promise { + const layerVersion = await getLayerVersion(client); + + await client.send( + new CreateFunctionCommand({ + FunctionName: name, + Role: getConfig().botLambdaRoleArn, + Runtime: LAMBDA_RUNTIME, + Handler: LAMBDA_HANDLER, + MemorySize: LAMBDA_MEMORY, + PackageType: PackageType.Zip, + Layers: [layerVersion], + Code: { + ZipFile: zipFile, + }, + Publish: true, + Timeout: 10, // seconds + }) + ); +} + +/** + * Updates an existing AWS Lambda for the bot name. + * @param client - The AWS Lambda client. + * @param name - The bot name. + * @param zipFile - The zip file with the bot code. + */ +async function updateLambda(client: LambdaClient, name: string, zipFile: Uint8Array): Promise { + // First, make sure the lambda configuration is up to date + await updateLambdaConfig(client, name); + + // Then update the code + await client.send( + new UpdateFunctionCodeCommand({ + FunctionName: name, + ZipFile: zipFile, + Publish: true, + }) + ); +} + +/** + * Updates the lambda configuration. + * @param client - The AWS Lambda client. + * @param name - The lambda name. + */ +async function updateLambdaConfig(client: LambdaClient, name: string): Promise { + const layerVersion = await getLayerVersion(client); + const functionConfig = await getLambdaConfig(client, name); + if ( + functionConfig.Runtime === LAMBDA_RUNTIME && + functionConfig.Handler === LAMBDA_HANDLER && + functionConfig.Layers?.[0].Arn === layerVersion + ) { + // Everything is up-to-date + return; + } + + // Need to update + await client.send( + new UpdateFunctionConfigurationCommand({ + FunctionName: name, + Role: getConfig().botLambdaRoleArn, + Runtime: LAMBDA_RUNTIME, + Handler: LAMBDA_HANDLER, + Layers: [layerVersion], + }) + ); + + // Wait for the update to complete before returning + // Wait up to 5 seconds + // See: https://github.com/aws/aws-toolkit-visual-studio/issues/197 + // See: https://aws.amazon.com/blogs/compute/coming-soon-expansion-of-aws-lambda-states-to-all-functions/ + for (let i = 0; i < 5; i++) { + const config = await getLambdaConfig(client, name); + // Valid Values: Pending | Active | Inactive | Failed + // See: https://docs.aws.amazon.com/lambda/latest/dg/API_GetFunctionConfiguration.html + if (config.State === 'Active') { + return; + } + await sleep(1000); + } +} + +async function getLambdaConfig(client: LambdaClient, name: string): Promise { + return client.send( + new GetFunctionConfigurationCommand({ + FunctionName: name, + }) + ); +} + +/** + * Returns the latest layer version for the Medplum bot layer. + * The first result is the latest version. + * See: https://stackoverflow.com/a/55752188 + * @param client - The AWS Lambda client. + * @returns The most recent layer version ARN. + */ +async function getLayerVersion(client: LambdaClient): Promise { + const command = new ListLayerVersionsCommand({ + LayerName: getConfig().botLambdaLayerName, + MaxItems: 1, + }); + const response = await client.send(command); + return response.LayerVersions?.[0].LayerVersionArn as string; +} diff --git a/packages/server/src/cloud/aws/email.ts b/packages/server/src/cloud/aws/email.ts new file mode 100644 index 0000000000..d495c0a507 --- /dev/null +++ b/packages/server/src/cloud/aws/email.ts @@ -0,0 +1,41 @@ +import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; +import { badRequest, normalizeErrorString, OperationOutcomeError } from '@medplum/core'; +import Mail from 'nodemailer/lib/mailer'; +import { getConfig } from '../../config'; +import { addressToString, buildAddresses, buildRawMessage } from '../../email/utils'; + +/** + * Sends an email via AWS SES. + * @param options - The nodemailer options. + */ +export async function sendEmailViaSes(options: Mail.Options): Promise { + const config = getConfig(); + const fromAddress = addressToString(options.from); + const toAddresses = buildAddresses(options.to); + const ccAddresses = buildAddresses(options.cc); + const bccAddresses = buildAddresses(options.bcc); + + let msg: Uint8Array; + try { + msg = await buildRawMessage(options); + } catch (err) { + throw new OperationOutcomeError(badRequest('Invalid email options: ' + normalizeErrorString(err)), err); + } + + const sesClient = new SESv2Client({ region: config.awsRegion }); + await sesClient.send( + new SendEmailCommand({ + FromEmailAddress: fromAddress, + Destination: { + ToAddresses: toAddresses, + CcAddresses: ccAddresses, + BccAddresses: bccAddresses, + }, + Content: { + Raw: { + Data: msg, + }, + }, + }) + ); +} diff --git a/packages/server/src/cloud/aws/execute.test.ts b/packages/server/src/cloud/aws/execute.test.ts new file mode 100644 index 0000000000..912321a1f3 --- /dev/null +++ b/packages/server/src/cloud/aws/execute.test.ts @@ -0,0 +1,231 @@ +import { InvokeCommand, LambdaClient, ListLayerVersionsCommand } from '@aws-sdk/client-lambda'; +import { ContentType } from '@medplum/core'; +import { Bot } from '@medplum/fhirtypes'; +import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; +import { randomUUID } from 'crypto'; +import express from 'express'; +import request from 'supertest'; +import { initApp, shutdownApp } from '../../app'; +import { getConfig, loadTestConfig } from '../../config'; +import { getBinaryStorage } from '../../fhir/storage'; +import { initTestAuth } from '../../test.setup'; +import { getLambdaFunctionName } from './execute'; + +const app = express(); +let accessToken: string; +let bot: Bot; + +describe('Execute', () => { + let mockLambdaClient: AwsClientStub; + + beforeEach(() => { + mockLambdaClient = mockClient(LambdaClient); + + mockLambdaClient.on(ListLayerVersionsCommand).resolves({ + LayerVersions: [ + { + LayerVersionArn: 'xyz', + }, + ], + }); + + mockLambdaClient.on(InvokeCommand).callsFake(({ Payload }) => { + const decoder = new TextDecoder(); + const event = JSON.parse(decoder.decode(Payload)); + const output = JSON.stringify(event.input); + const encoder = new TextEncoder(); + + return { + LogResult: `U1RBUlQgUmVxdWVzdElkOiAxNDZmY2ZjZi1jMzJiLTQzZjUtODJhNi1lZTBmMzEzMmQ4NzMgVmVyc2lvbjogJExBVEVTVAoyMDIyLTA1LTMwVDE2OjEyOjIyLjY4NVoJMTQ2ZmNmY2YtYzMyYi00M2Y1LTgyYTYtZWUwZjMxMzJkODczCUlORk8gdGVzdApFTkQgUmVxdWVzdElkOiAxNDZmY2ZjZi1jMzJiLTQzZjUtODJhNi1lZTBmMzEzMmQ4NzMKUkVQT1JUIFJlcXVlc3RJZDogMTQ2ZmNmY2YtYzMyYi00M2Y1LTgyYTYtZWUwZjMxMzJkODcz`, + Payload: encoder.encode(output), + }; + }); + }); + + afterEach(() => { + mockLambdaClient.restore(); + }); + + beforeAll(async () => { + const config = await loadTestConfig(); + await initApp(app, config); + accessToken = await initTestAuth(); + + const res = await request(app) + .post(`/fhir/R4/Bot`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + resourceType: 'Bot', + identifier: [{ system: 'https://example.com/bot', value: randomUUID() }], + name: 'Test Bot', + runtimeVersion: 'awslambda', + code: ` + export async function handler(medplum, event) { + console.log('input', event.input); + return event.input; + } + `, + }); + expect(res.status).toBe(201); + bot = res.body as Bot; + }); + + afterAll(async () => { + await shutdownApp(); + }); + + test('Submit plain text', async () => { + const res = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$execute`) + .set('Content-Type', ContentType.TEXT) + .set('Authorization', 'Bearer ' + accessToken) + .send('input'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8'); + expect(res.text).toEqual('input'); + }); + + test('Submit FHIR with content type', async () => { + const res = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$execute`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + resourceType: 'Patient', + name: [{ given: ['John'], family: ['Doe'] }], + }); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('application/fhir+json; charset=utf-8'); + }); + + test('Submit FHIR without content type', async () => { + const res = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$execute`) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + resourceType: 'Patient', + name: [{ given: ['John'], family: ['Doe'] }], + }); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('application/json; charset=utf-8'); + }); + + test('Submit HL7', async () => { + const binaryStorage = getBinaryStorage(); + const writeFileSpy = jest.spyOn(binaryStorage, 'writeFile'); + + const text = + 'MSH|^~\\&|Main_HIS|XYZ_HOSPITAL|iFW|ABC_Lab|20160915003015||ACK|9B38584D|P|2.6.1|\r' + + 'MSA|AA|9B38584D|Everything was okay dokay!|'; + + const res = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$execute`) + .set('Content-Type', ContentType.HL7_V2) + .set('Authorization', 'Bearer ' + accessToken) + .send(text); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('x-application/hl7-v2+er7; charset=utf-8'); + expect(writeFileSpy).toHaveBeenCalledTimes(1); + + const args = writeFileSpy.mock.calls[0]; + expect(args.length).toBe(3); + expect(args[0]).toMatch(/^bot\//); + expect(args[1]).toBe(ContentType.JSON); + + const row = JSON.parse(args[2] as string); + expect(row.botId).toEqual(bot.id); + expect(row.hl7MessageType).toEqual('ACK'); + expect(row.hl7Version).toEqual('2.6.1'); + }); + + test('Execute without code', async () => { + // Create a bot with empty code + const res1 = await request(app) + .post(`/fhir/R4/Bot`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + resourceType: 'Bot', + name: 'Test Bot', + code: '', + }); + expect(res1.status).toBe(201); + const bot = res1.body as Bot; + + // Execute the bot + const res2 = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$execute`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({}); + expect(res2.status).toBe(400); + }); + + test('Unsupported runtime version', async () => { + const res1 = await request(app) + .post(`/fhir/R4/Bot`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + resourceType: 'Bot', + name: 'Test Bot', + runtimeVersion: 'unsupported', + }); + expect(res1.status).toBe(201); + const bot = res1.body as Bot; + + // Step 2: Publish the bot + const res2 = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$deploy`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + code: ` + export async function handler() { + console.log('input', input); + return input; + } + `, + }); + expect(res2.status).toBe(200); + + // Step 3: Execute the bot + const res3 = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$execute`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({}); + expect(res3.status).toBe(400); + }); + + test('Get function name', async () => { + const config = getConfig(); + const normalBot: Bot = { resourceType: 'Bot', id: '123' }; + const customBot: Bot = { + resourceType: 'Bot', + id: '456', + identifier: [{ system: 'https://medplum.com/bot-external-function-id', value: 'custom' }], + }; + + expect(getLambdaFunctionName(normalBot)).toEqual('medplum-bot-lambda-123'); + expect(getLambdaFunctionName(customBot)).toEqual('medplum-bot-lambda-456'); + + // Temporarily enable custom bot support + config.botCustomFunctionsEnabled = true; + expect(getLambdaFunctionName(normalBot)).toEqual('medplum-bot-lambda-123'); + expect(getLambdaFunctionName(customBot)).toEqual('custom'); + config.botCustomFunctionsEnabled = false; + }); + + test('Execute by identifier', async () => { + const res = await request(app) + .post(`/fhir/R4/Bot/$execute?identifier=${bot.identifier?.[0]?.system}|${bot.identifier?.[0]?.value}`) + .set('Content-Type', ContentType.TEXT) + .set('Authorization', 'Bearer ' + accessToken) + .send('input'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8'); + expect(res.text).toEqual('input'); + }); +}); diff --git a/packages/server/src/cloud/aws/execute.ts b/packages/server/src/cloud/aws/execute.ts new file mode 100644 index 0000000000..50e28d0dd7 --- /dev/null +++ b/packages/server/src/cloud/aws/execute.ts @@ -0,0 +1,108 @@ +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { Hl7Message, createReference, getIdentifier, normalizeErrorString } from '@medplum/core'; +import { Bot } from '@medplum/fhirtypes'; +import { TextDecoder, TextEncoder } from 'util'; +import { getConfig } from '../../config'; +import { BotExecutionContext, BotExecutionResult } from '../../fhir/operations/execute'; + +/** + * Executes a Bot in an AWS Lambda. + * @param request - The bot request. + * @returns The bot execution result. + */ +export async function runInLambda(request: BotExecutionContext): Promise { + const { bot, accessToken, secrets, input, contentType, traceId } = request; + const config = getConfig(); + const client = new LambdaClient({ region: config.awsRegion }); + const name = getLambdaFunctionName(bot); + const payload = { + bot: createReference(bot), + baseUrl: config.baseUrl, + accessToken, + input: input instanceof Hl7Message ? input.toString() : input, + contentType, + secrets, + traceId, + }; + + // Build the command + const encoder = new TextEncoder(); + const command = new InvokeCommand({ + FunctionName: name, + InvocationType: 'RequestResponse', + LogType: 'Tail', + Payload: encoder.encode(JSON.stringify(payload)), + }); + + // Execute the command + try { + const response = await client.send(command); + const responseStr = response.Payload ? new TextDecoder().decode(response.Payload) : undefined; + + // The response from AWS Lambda is always JSON, even if the function returns a string + // Therefore we always use JSON.parse to get the return value + // See: https://stackoverflow.com/a/49951946/2051724 + const returnValue = responseStr ? JSON.parse(responseStr) : undefined; + + return { + success: !response.FunctionError, + logResult: parseLambdaLog(response.LogResult as string), + returnValue, + }; + } catch (err) { + return { + success: false, + logResult: normalizeErrorString(err), + }; + } +} + +/** + * Returns the AWS Lambda function name for the given bot. + * By default, the function name is based on the bot ID. + * If the bot has a custom function, and the server allows it, then that is used instead. + * @param bot - The Bot resource. + * @returns The AWS Lambda function name. + */ +export function getLambdaFunctionName(bot: Bot): string { + if (getConfig().botCustomFunctionsEnabled) { + const customFunction = getIdentifier(bot, 'https://medplum.com/bot-external-function-id'); + if (customFunction) { + return customFunction; + } + } + + // By default, use the bot ID as the Lambda function name + return `medplum-bot-lambda-${bot.id}`; +} + +/** + * Parses the AWS Lambda log result. + * + * The raw logs include markup metadata such as timestamps and billing information. + * + * We only want to include the actual log contents in the AuditEvent, + * so we attempt to scrub away all of that extra metadata. + * + * See: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-logging.html + * @param logResult - The raw log result from the AWS lambda event. + * @returns The parsed log result. + */ +function parseLambdaLog(logResult: string): string { + const logBuffer = Buffer.from(logResult, 'base64'); + const log = logBuffer.toString('ascii'); + const lines = log.split('\n'); + const result = []; + for (const line of lines) { + if (line.startsWith('START RequestId: ')) { + // Ignore start line + continue; + } + if (line.startsWith('END RequestId: ') || line.startsWith('REPORT RequestId: ')) { + // Stop at end lines + break; + } + result.push(line); + } + return result.join('\n').trim(); +} diff --git a/packages/server/src/fhir/signer.md b/packages/server/src/cloud/aws/signer.md similarity index 100% rename from packages/server/src/fhir/signer.md rename to packages/server/src/cloud/aws/signer.md diff --git a/packages/server/src/fhir/signer.test.ts b/packages/server/src/cloud/aws/signer.test.ts similarity index 93% rename from packages/server/src/fhir/signer.test.ts rename to packages/server/src/cloud/aws/signer.test.ts index f86ec8ddda..c4b4f698c4 100644 --- a/packages/server/src/fhir/signer.test.ts +++ b/packages/server/src/cloud/aws/signer.test.ts @@ -1,6 +1,6 @@ import { Binary } from '@medplum/fhirtypes'; import { randomUUID } from 'crypto'; -import { loadTestConfig } from '../config'; +import { loadTestConfig } from '../../config'; import { getPresignedUrl } from './signer'; describe('Signer', () => { diff --git a/packages/server/src/fhir/signer.ts b/packages/server/src/cloud/aws/signer.ts similarity index 95% rename from packages/server/src/fhir/signer.ts rename to packages/server/src/cloud/aws/signer.ts index 0bcb896eaf..e33001b78f 100644 --- a/packages/server/src/fhir/signer.ts +++ b/packages/server/src/cloud/aws/signer.ts @@ -1,6 +1,6 @@ import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; import { Binary } from '@medplum/fhirtypes'; -import { getConfig } from '../config'; +import { getConfig } from '../../config'; /** * Returns a presigned URL for the Binary resource content. diff --git a/packages/server/src/cloud/aws/storage.test.ts b/packages/server/src/cloud/aws/storage.test.ts new file mode 100644 index 0000000000..050ebb705f --- /dev/null +++ b/packages/server/src/cloud/aws/storage.test.ts @@ -0,0 +1,188 @@ +import { CopyObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { ContentType } from '@medplum/core'; +import { Binary } from '@medplum/fhirtypes'; +import { sdkStreamMixin } from '@smithy/util-stream'; +import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Request } from 'express'; +import internal, { Readable } from 'stream'; +import { loadTestConfig } from '../../config'; +import { getBinaryStorage, initBinaryStorage } from '../../fhir/storage'; + +describe('Storage', () => { + let mockS3Client: AwsClientStub; + + beforeAll(async () => { + await loadTestConfig(); + }); + + beforeEach(() => { + mockS3Client = mockClient(S3Client); + }); + + afterEach(() => { + mockS3Client.restore(); + }); + + test('Undefined binary storage', () => { + initBinaryStorage('binary'); + expect(() => getBinaryStorage()).toThrow(); + }); + + test('S3 storage', async () => { + initBinaryStorage('s3:foo'); + + const storage = getBinaryStorage(); + expect(storage).toBeDefined(); + + // Write a file + const binary = { + resourceType: 'Binary', + id: '123', + meta: { + versionId: '456', + }, + } as Binary; + const req = new Readable(); + req.push('foo'); + req.push(null); + (req as any).headers = {}; + + const sdkStream = sdkStreamMixin(req); + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStream }); + + await storage.writeBinary(binary, 'test.txt', ContentType.TEXT, req as Request); + + expect(mockS3Client.send.callCount).toBe(1); + expect(mockS3Client).toReceiveCommandWith(PutObjectCommand, { + Bucket: 'foo', + Key: 'binary/123/456', + ContentType: ContentType.TEXT, + }); + + // Read a file + const stream = await storage.readBinary(binary); + expect(stream).toBeDefined(); + expect(mockS3Client).toHaveReceivedCommand(GetObjectCommand); + }); + + test('Missing metadata', async () => { + initBinaryStorage('s3:foo'); + + const storage = getBinaryStorage(); + expect(storage).toBeDefined(); + + // Write a file + const binary = { + resourceType: 'Binary', + id: '123', + meta: { + versionId: '456', + }, + } as Binary; + const req = new Readable(); + req.push('foo'); + req.push(null); + (req as any).headers = {}; + + const sdkStream = sdkStreamMixin(req); + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStream }); + + await storage.writeBinary(binary, undefined, undefined, req as Request); + expect(mockS3Client.send.callCount).toBe(1); + expect(mockS3Client).toReceiveCommandWith(PutObjectCommand, { + Bucket: 'foo', + Key: 'binary/123/456', + ContentType: 'application/octet-stream', + }); + + // Read a file + const stream = await storage.readBinary(binary); + expect(stream).toBeDefined(); + expect(mockS3Client).toHaveReceivedCommand(GetObjectCommand); + }); + + test('Invalid file extension', async () => { + initBinaryStorage('s3:foo'); + + const storage = getBinaryStorage(); + expect(storage).toBeDefined(); + + const binary = null as unknown as Binary; + const stream = null as unknown as internal.Readable; + try { + await storage.writeBinary(binary, 'test.exe', ContentType.TEXT, stream); + fail('Expected error'); + } catch (err) { + expect((err as Error).message).toEqual('Invalid file extension'); + } + expect(mockS3Client).not.toHaveReceivedCommand(PutObjectCommand); + }); + + test('Invalid content type', async () => { + initBinaryStorage('s3:foo'); + + const storage = getBinaryStorage(); + expect(storage).toBeDefined(); + + const binary = null as unknown as Binary; + const stream = null as unknown as internal.Readable; + try { + await storage.writeBinary(binary, 'test.sh', 'application/x-sh', stream); + fail('Expected error'); + } catch (err) { + expect((err as Error).message).toEqual('Invalid content type'); + } + expect(mockS3Client).not.toHaveReceivedCommand(PutObjectCommand); + }); + + test('Copy S3 object', async () => { + initBinaryStorage('s3:foo'); + + const storage = getBinaryStorage(); + expect(storage).toBeDefined(); + + // Write a file + const binary = { + resourceType: 'Binary', + id: '123', + meta: { + versionId: '456', + }, + } as Binary; + const req = new Readable(); + req.push('foo'); + req.push(null); + (req as any).headers = {}; + + const sdkStream = sdkStreamMixin(req); + mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStream }); + + await storage.writeBinary(binary, 'test.txt', ContentType.TEXT, req as Request); + + expect(mockS3Client.send.callCount).toBe(1); + expect(mockS3Client).toReceiveCommandWith(PutObjectCommand, { + Bucket: 'foo', + Key: 'binary/123/456', + ContentType: ContentType.TEXT, + }); + mockS3Client.reset(); + + // Copy the object + const destinationBinary = { + resourceType: 'Binary', + id: '789', + meta: { + versionId: '012', + }, + } as Binary; + await storage.copyBinary(binary, destinationBinary); + + expect(mockS3Client.send.callCount).toBe(1); + expect(mockS3Client).toReceiveCommandWith(CopyObjectCommand, { + CopySource: 'foo/binary/123/456', + Bucket: 'foo', + Key: 'binary/789/012', + }); + }); +}); diff --git a/packages/server/src/cloud/aws/storage.ts b/packages/server/src/cloud/aws/storage.ts new file mode 100644 index 0000000000..a237889ac9 --- /dev/null +++ b/packages/server/src/cloud/aws/storage.ts @@ -0,0 +1,133 @@ +import { CopyObjectCommand, GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; +import { Upload } from '@aws-sdk/lib-storage'; +import { Binary } from '@medplum/fhirtypes'; +import { Readable } from 'stream'; +import { getConfig } from '../../config'; +import { BinarySource, BinaryStorage, checkFileMetadata } from '../../fhir/storage'; + +/** + * The S3Storage class stores binary data in an AWS S3 bucket. + * Files are stored in bucket/binary/binary.id/binary.meta.versionId. + */ +export class S3Storage implements BinaryStorage { + private readonly client: S3Client; + private readonly bucket: string; + + constructor(bucket: string) { + this.client = new S3Client({ region: getConfig().awsRegion }); + this.bucket = bucket; + } + + /** + * Writes a binary blob to S3. + * @param binary - The binary resource destination. + * @param filename - Optional binary filename. + * @param contentType - Optional binary content type. + * @param stream - The Node.js stream of readable content. + * @returns Promise that resolves when the write is complete. + */ + writeBinary( + binary: Binary, + filename: string | undefined, + contentType: string | undefined, + stream: BinarySource + ): Promise { + checkFileMetadata(filename, contentType); + return this.writeFile(this.getKey(binary), contentType, stream); + } + + /** + * Writes a file to S3. + * + * Early implementations used the simple "PutObjectCommand" to write the blob to S3. + * However, PutObjectCommand does not support streaming. + * + * We now use the @aws-sdk/lib-storage package. + * + * Learn more: + * https://github.com/aws/aws-sdk-js-v3/blob/main/UPGRADING.md#s3-multipart-upload + * https://github.com/aws/aws-sdk-js-v3/tree/main/lib/lib-storage + * + * Be mindful of Cache-Control settings. + * + * Because we use signed URLs intended for one hour use, + * we set "max-age" to 1 hour = 3600 seconds. + * + * But we want CloudFront to cache the response for 1 day, + * so we set "s-maxage" to 1 day = 86400 seconds. + * + * Learn more: + * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html + * @param key - The S3 key. + * @param contentType - Optional binary content type. + * @param stream - The Node.js stream of readable content. + */ + async writeFile(key: string, contentType: string | undefined, stream: BinarySource): Promise { + const upload = new Upload({ + params: { + Bucket: this.bucket, + Key: key, + CacheControl: 'max-age=3600, s-maxage=86400', + ContentType: contentType ?? 'application/octet-stream', + Body: stream, + }, + client: this.client, + queueSize: 3, + }); + + await upload.done(); + } + + async readBinary(binary: Binary): Promise { + const output = await this.client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: this.getKey(binary), + }) + ); + return output.Body as Readable; + } + + async copyBinary(sourceBinary: Binary, destinationBinary: Binary): Promise { + await this.copyFile(this.getKey(sourceBinary), this.getKey(destinationBinary)); + } + + async copyFile(sourceKey: string, destinationKey: string): Promise { + await this.client.send( + new CopyObjectCommand({ + CopySource: `${this.bucket}/${sourceKey}`, + Bucket: this.bucket, + Key: destinationKey, + }) + ); + } + + /** + * Returns a presigned URL for the Binary resource content. + * + * Reference: + * https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_cloudfront_signer.html + * + * @param binary - Binary resource. + * @returns Presigned URL to access the binary data. + */ + getPresignedUrl(binary: Binary): string { + const config = getConfig(); + const storageBaseUrl = config.storageBaseUrl; + const unsignedUrl = `${storageBaseUrl}${binary.id}/${binary.meta?.versionId}`; + const dateLessThan = new Date(); + dateLessThan.setHours(dateLessThan.getHours() + 1); + return getSignedUrl({ + url: unsignedUrl, + keyPairId: config.signingKeyId, + dateLessThan: dateLessThan.toISOString(), + privateKey: config.signingKey, + passphrase: config.signingKeyPassphrase, + }); + } + + private getKey(binary: Binary): string { + return 'binary/' + binary.id + '/' + binary.meta?.versionId; + } +} diff --git a/packages/server/src/config.test.ts b/packages/server/src/config.test.ts index 158816b8bc..05c383f522 100644 --- a/packages/server/src/config.test.ts +++ b/packages/server/src/config.test.ts @@ -1,43 +1,7 @@ -import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; -import { GetParametersByPathCommand, SSMClient } from '@aws-sdk/client-ssm'; -import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; -import 'aws-sdk-client-mock-jest'; import fs from 'fs'; import { getConfig, loadConfig } from './config'; describe('Config', () => { - let mockSSMClient: AwsClientStub; - let mockSecretsManagerClient: AwsClientStub; - - beforeEach(() => { - mockSSMClient = mockClient(SSMClient); - mockSecretsManagerClient = mockClient(SecretsManagerClient); - - mockSecretsManagerClient.on(GetSecretValueCommand).resolves({ - SecretString: JSON.stringify({ host: 'host', port: 123 }), - }); - - mockSSMClient.on(GetParametersByPathCommand).resolves({ - Parameters: [ - { Name: 'baseUrl', Value: 'https://www.example.com/' }, - { Name: 'database.ssl.require', Value: 'true' }, - { Name: 'database.ssl.rejectUnauthorized', Value: 'true' }, - { Name: 'database.ssl.ca', Value: 'DatabaseSslCa' }, - { Name: 'DatabaseSecrets', Value: 'DatabaseSecretsArn' }, - { Name: 'RedisSecrets', Value: 'RedisSecretsArn' }, - { Name: 'port', Value: '8080' }, - { Name: 'botCustomFunctionsEnabled', Value: 'true' }, - { Name: 'logAuditEvents', Value: 'true' }, - { Name: 'registerEnabled', Value: 'false' }, - ], - }); - }); - - afterEach(() => { - mockSSMClient.restore(); - mockSecretsManagerClient.restore(); - }); - test('Unrecognized config', async () => { await expect(loadConfig('unrecognized')).rejects.toThrow(); }); @@ -53,38 +17,6 @@ describe('Config', () => { expect(getConfig()).toBe(config); }); - test('Load AWS config', async () => { - const config = await loadConfig('aws:test'); - expect(config).toBeDefined(); - expect(config.baseUrl).toBeDefined(); - expect(config.port).toEqual(8080); - expect(config.botCustomFunctionsEnabled).toEqual(true); - expect(config.logAuditEvents).toEqual(true); - expect(config.registerEnabled).toEqual(false); - expect(config.database).toBeDefined(); - expect(config.database.ssl).toBeDefined(); - expect(config.database.ssl?.require).toEqual(true); - expect(config.database.ssl?.rejectUnauthorized).toEqual(true); - expect(config.database.ssl?.ca).toEqual('DatabaseSslCa'); - expect(getConfig()).toBe(config); - expect(mockSSMClient).toReceiveCommand(GetParametersByPathCommand); - }); - - test('Load region AWS config', async () => { - const config = await loadConfig('aws:ap-southeast-2:test'); - expect(config).toBeDefined(); - expect(config.baseUrl).toBeDefined(); - expect(config.port).toEqual(8080); - expect(getConfig()).toBe(config); - expect(mockSecretsManagerClient).toReceiveCommand(GetSecretValueCommand); - expect(mockSecretsManagerClient).toReceiveCommandWith(GetSecretValueCommand, { - SecretId: 'DatabaseSecretsArn', - }); - expect(mockSecretsManagerClient).toReceiveCommandWith(GetSecretValueCommand, { - SecretId: 'RedisSecretsArn', - }); - }); - test('Load env config', async () => { process.env.MEDPLUM_BASE_URL = 'http://localhost:3000'; process.env.MEDPLUM_PORT = '3000'; diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 0bc5f9d5b9..76d5a43556 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -1,10 +1,9 @@ -import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; -import { GetParametersByPathCommand, Parameter, SSMClient } from '@aws-sdk/client-ssm'; import { splitN } from '@medplum/core'; import { KeepJobs } from 'bullmq'; import { mkdtempSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; +import { loadAwsConfig } from './cloud/aws/config'; const DEFAULT_AWS_REGION = 'us-east-1'; @@ -24,9 +23,11 @@ export interface MedplumServerConfig { signingKeyId: string; signingKeyPassphrase: string; supportEmail: string; + approvedSenderEmails?: string; database: MedplumDatabaseConfig; databaseProxyEndpoint?: string; redis: MedplumRedisConfig; + emailProvider?: 'none' | 'awsses' | 'smtp'; smtp?: MedplumSmtpConfig; bullmq?: MedplumBullmqConfig; googleClientId?: string; @@ -52,6 +53,17 @@ export interface MedplumServerConfig { heartbeatEnabled?: boolean; accurateCountThreshold: number; defaultBotRuntimeVersion: 'awslambda' | 'vmcontext'; + defaultProjectFeatures?: + | ( + | 'email' + | 'bots' + | 'cron' + | 'google-auth-required' + | 'graphql-introspection' + | 'terminology' + | 'websocket-subscriptions' + )[] + | undefined; /** Temporary feature flag, to be removed */ chainedSearchWithReferenceTables?: boolean; @@ -163,10 +175,13 @@ export async function loadTestConfig(): Promise { config.binaryStorage = 'file:' + mkdtempSync(join(tmpdir(), 'medplum-temp-storage')); config.allowedOrigins = undefined; config.database.host = process.env['POSTGRES_HOST'] ?? 'localhost'; - config.database.port = process.env['POSTGRES_PORT'] ? parseInt(process.env['POSTGRES_PORT'], 10) : 5432; + config.database.port = process.env['POSTGRES_PORT'] ? Number.parseInt(process.env['POSTGRES_PORT'], 10) : 5432; config.database.dbname = 'medplum_test'; config.redis.db = 7; // Select logical DB `7` so we don't collide with existing dev Redis cache. config.redis.password = process.env['REDIS_PASSWORD_DISABLED_IN_TESTS'] ? undefined : config.redis.password; + config.approvedSenderEmails = 'no-reply@example.com'; + config.emailProvider = 'none'; + config.logLevel = 'error'; return config; } @@ -222,97 +237,6 @@ async function loadFileConfig(path: string): Promise { return JSON.parse(readFileSync(resolve(__dirname, '../', path), { encoding: 'utf8' })); } -/** - * Loads configuration settings from AWS SSM Parameter Store. - * @param path - The AWS SSM Parameter Store path prefix. - * @returns The loaded configuration. - */ -async function loadAwsConfig(path: string): Promise { - let region = DEFAULT_AWS_REGION; - if (path.includes(':')) { - [region, path] = splitN(path, ':', 2); - } - - const client = new SSMClient({ region }); - const config: Record = {}; - const parameters = [] as Parameter[]; - let nextToken: string | undefined; - do { - const response = await client.send( - new GetParametersByPathCommand({ - Path: path, - NextToken: nextToken, - WithDecryption: true, - }) - ); - if (response.Parameters) { - parameters.push(...response.Parameters); - } - nextToken = response.NextToken; - } while (nextToken); - - // Load special AWS Secrets Manager secrets first - for (const param of parameters) { - const key = (param.Name as string).replace(path, ''); - const value = param.Value as string; - if (key === 'DatabaseSecrets') { - config['database'] = await loadAwsSecrets(region, value); - } else if (key === 'RedisSecrets') { - config['redis'] = await loadAwsSecrets(region, value); - } - } - - // Then load other parameters, which may override the secrets - for (const param of parameters) { - const key = (param.Name as string).replace(path, ''); - const value = param.Value as string; - setValue(config, key, value); - } - - return config as MedplumServerConfig; -} - -/** - * Returns the AWS Database Secret data as a JSON map. - * @param region - The AWS region. - * @param secretId - Secret ARN - * @returns The secret data as a JSON map. - */ -async function loadAwsSecrets(region: string, secretId: string): Promise | undefined> { - const client = new SecretsManagerClient({ region }); - const result = await client.send(new GetSecretValueCommand({ SecretId: secretId })); - - if (!result.SecretString) { - return undefined; - } - - return JSON.parse(result.SecretString); -} - -function setValue(config: MedplumDatabaseConfig, key: string, value: string): void { - const keySegments = key.split('.'); - let obj = config as Record; - - while (keySegments.length > 1) { - const segment = keySegments.shift() as string; - if (!obj[segment]) { - obj[segment] = {}; - } - obj = obj[segment] as Record; - } - - let parsedValue: any = value; - if (isIntegerConfig(key)) { - parsedValue = parseInt(value, 10); - } else if (isBooleanConfig(key)) { - parsedValue = value === 'true'; - } else if (isObjectConfig(key)) { - parsedValue = JSON.parse(value); - } - - obj[keySegments[0]] = parsedValue; -} - /** * Adds default values to the config. * @param config - The input config as loaded from the config file. @@ -334,6 +258,8 @@ function addDefaults(config: MedplumServerConfig): MedplumServerConfig { config.shutdownTimeoutMilliseconds = config.shutdownTimeoutMilliseconds ?? 30000; config.accurateCountThreshold = config.accurateCountThreshold ?? 1000000; config.defaultBotRuntimeVersion = config.defaultBotRuntimeVersion ?? 'awslambda'; + config.defaultProjectFeatures = config.defaultProjectFeatures ?? []; + config.emailProvider = config.emailProvider || (config.smtp ? 'smtp' : 'awsses'); return config; } diff --git a/packages/server/src/context.test.ts b/packages/server/src/context.test.ts index fd139a7da2..35df84789b 100644 --- a/packages/server/src/context.test.ts +++ b/packages/server/src/context.test.ts @@ -1,4 +1,5 @@ import { Request } from 'express'; +import { loadTestConfig } from './config'; import { RequestContext, buildTracingExtension, @@ -12,6 +13,10 @@ import { import { withTestContext } from './test.setup'; describe('RequestContext', () => { + beforeAll(async () => { + await loadTestConfig(); + }); + test('tryGetRequestContext', async () => { expect(tryGetRequestContext()).toBeUndefined(); withTestContext(() => expect(tryGetRequestContext()).toBeDefined()); diff --git a/packages/server/src/context.ts b/packages/server/src/context.ts index 2717e8f577..bfeb604736 100644 --- a/packages/server/src/context.ts +++ b/packages/server/src/context.ts @@ -1,9 +1,12 @@ -import { LogLevel, Logger, ProfileResource, isUUID } from '@medplum/core'; +import { LogLevel, Logger, ProfileResource, isUUID, parseLogLevel } from '@medplum/core'; import { Extension, Login, Project, ProjectMembership, Reference } from '@medplum/fhirtypes'; import { AsyncLocalStorage } from 'async_hooks'; import { randomUUID } from 'crypto'; import { NextFunction, Request, Response } from 'express'; +import { getConfig } from './config'; +import { getRepoForLogin } from './fhir/accesspolicy'; import { Repository, getSystemRepo } from './fhir/repo'; +import { authenticateTokenImpl, isExtendedMode } from './oauth/middleware'; import { parseTraceparent } from './traceparent'; export class RequestContext { @@ -14,9 +17,7 @@ export class RequestContext { constructor(requestId: string, traceId: string, logger?: Logger) { this.requestId = requestId; this.traceId = traceId; - this.logger = - logger ?? - new Logger(write, { requestId, traceId }, process.env.NODE_ENV === 'test' ? LogLevel.ERROR : LogLevel.INFO); + this.logger = logger ?? new Logger(write, { requestId, traceId }, parseLogLevel(getConfig().logLevel ?? 'info')); } close(): void { @@ -44,10 +45,9 @@ export class AuthenticatedRequestContext extends RequestContext { project: Project, membership: ProjectMembership, repo: Repository, - logger?: Logger, accessToken?: string ) { - super(ctx.requestId, ctx.traceId, logger); + super(ctx.requestId, ctx.traceId, ctx.logger); this.repo = repo; this.project = project; @@ -63,12 +63,11 @@ export class AuthenticatedRequestContext extends RequestContext { static system(ctx?: { requestId?: string; traceId?: string }): AuthenticatedRequestContext { return new AuthenticatedRequestContext( - new RequestContext(ctx?.requestId ?? '', ctx?.traceId ?? ''), + new RequestContext(ctx?.requestId ?? '', ctx?.traceId ?? '', systemLogger), {} as unknown as Login, {} as unknown as Project, {} as unknown as ProjectMembership, - getSystemRepo(), - systemLogger + getSystemRepo() ); } } @@ -97,7 +96,17 @@ export function getAuthenticatedContext(): AuthenticatedRequestContext { export async function attachRequestContext(req: Request, res: Response, next: NextFunction): Promise { const { requestId, traceId } = requestIds(req); - requestContextStore.run(new RequestContext(requestId, traceId), () => next()); + + let ctx = new RequestContext(requestId, traceId); + + const authState = await authenticateTokenImpl(req); + if (authState) { + const { login, membership, project, accessToken } = authState; + const repo = await getRepoForLogin(login, membership, project, isExtendedMode(req)); + ctx = new AuthenticatedRequestContext(ctx, login, project, membership, repo, accessToken); + } + + requestContextStore.run(ctx, () => next()); } export function closeRequestContext(): void { diff --git a/packages/server/src/email/email.test.ts b/packages/server/src/email/email.test.ts index 498e39a317..b3ab339d40 100644 --- a/packages/server/src/email/email.test.ts +++ b/packages/server/src/email/email.test.ts @@ -21,6 +21,7 @@ describe('Email', () => { beforeAll(async () => { const config = await loadTestConfig(); + config.emailProvider = 'awsses'; config.storageBaseUrl = 'https://storage.example.com/'; await initAppServices(config); }); @@ -39,8 +40,10 @@ describe('Email', () => { }); test('Send text email', async () => { + const fromAddress = 'gibberish@example.com'; const toAddresses = 'alice@example.com'; await sendEmail(systemRepo, { + from: fromAddress, to: toAddresses, cc: 'bob@example.com', subject: 'Hello', @@ -52,6 +55,32 @@ describe('Email', () => { const inputArgs = mockSESv2Client.commandCalls(SendEmailCommand)[0].args[0].input; + expect(inputArgs?.FromEmailAddress).toBe(getConfig().supportEmail); + expect(inputArgs?.Destination?.ToAddresses?.[0] ?? '').toBe('alice@example.com'); + expect(inputArgs?.Destination?.CcAddresses?.[0] ?? '').toBe('bob@example.com'); + + const parsed = await simpleParser(Readable.from(inputArgs?.Content?.Raw?.Data ?? '')); + expect(parsed.subject).toBe('Hello'); + expect(parsed.text).toBe('Hello Alice\n'); + }); + + test('Send text email from approved sender', async () => { + const fromAddress = 'no-reply@example.com'; + const toAddresses = 'alice@example.com'; + await sendEmail(systemRepo, { + from: fromAddress, + to: toAddresses, + cc: 'bob@example.com', + subject: 'Hello', + text: 'Hello Alice', + }); + + expect(mockSESv2Client.send.callCount).toBe(1); + expect(mockSESv2Client).toHaveReceivedCommandTimes(SendEmailCommand, 1); + + const inputArgs = mockSESv2Client.commandCalls(SendEmailCommand)[0].args[0].input; + + expect(inputArgs?.FromEmailAddress).toBe(fromAddress); expect(inputArgs?.Destination?.ToAddresses?.[0] ?? '').toBe('alice@example.com'); expect(inputArgs?.Destination?.CcAddresses?.[0] ?? '').toBe('bob@example.com'); diff --git a/packages/server/src/email/email.ts b/packages/server/src/email/email.ts index 595cc1d2cd..33b7f40d6d 100644 --- a/packages/server/src/email/email.ts +++ b/packages/server/src/email/email.ts @@ -1,13 +1,12 @@ -import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; -import { badRequest, normalizeErrorString, OperationOutcomeError } from '@medplum/core'; import { Binary } from '@medplum/fhirtypes'; import { createTransport } from 'nodemailer'; -import MailComposer from 'nodemailer/lib/mail-composer'; -import Mail, { Address } from 'nodemailer/lib/mailer'; +import Mail from 'nodemailer/lib/mailer'; +import { sendEmailViaSes } from '../cloud/aws/email'; import { getConfig, MedplumSmtpConfig } from '../config'; import { Repository } from '../fhir/repo'; import { getBinaryStorage } from '../fhir/storage'; import { globalLogger } from '../logger'; +import { getFromAddress } from './utils'; /** * Sends an email using the AWS SES service. @@ -18,10 +17,8 @@ import { globalLogger } from '../logger'; */ export async function sendEmail(repo: Repository, options: Mail.Options): Promise { const config = getConfig(); - const fromAddress = config.supportEmail; - const toAddresses = buildAddresses(options.to); + const fromAddress = getFromAddress(options); - // Always set the from and sender to the support email address options.from = fromAddress; options.sender = fromAddress; @@ -33,65 +30,15 @@ export async function sendEmail(repo: Repository, options: Mail.Options): Promis // "if set to true then fails with an error when a node tries to load content from a file" options.disableFileAccess = true; - globalLogger.info('Sending email', { to: toAddresses?.join(', '), subject: options.subject }); + globalLogger.info('Sending email', { to: options.to, subject: options.subject }); if (config.smtp) { await sendEmailViaSmpt(config.smtp, options); - } else { + } else if (config.emailProvider === 'awsses') { await sendEmailViaSes(options); } } -/** - * Converts nodemailer addresses to an array of strings. - * @param input - nodemailer address input. - * @returns Array of string addresses. - */ -function buildAddresses(input: string | Address | (string | Address)[] | undefined): string[] | undefined { - if (!input) { - return undefined; - } - if (Array.isArray(input)) { - return input.map(addressToString) as string[]; - } - return [addressToString(input) as string]; -} - -/** - * Converts a nodemailer address to a string. - * @param address - nodemailer address input. - * @returns String address. - */ -function addressToString(address: Address | string | undefined): string | undefined { - if (address) { - if (typeof address === 'string') { - return address; - } - if (typeof address === 'object' && 'address' in address) { - return address.address; - } - } - return undefined; -} - -/** - * Builds a raw email message using nodemailer MailComposer. - * @param options - The nodemailer options. - * @returns The raw email message. - */ -function buildRawMessage(options: Mail.Options): Promise { - const msg = new MailComposer(options); - return new Promise((resolve, reject) => { - msg.compile().build((err, message) => { - if (err) { - reject(err); - return; - } - resolve(message); - }); - }); -} - /** * Validates an array of nodemailer attachments. * @param repo - The user repository. @@ -151,39 +98,3 @@ async function sendEmailViaSmpt(smtpConfig: MedplumSmtpConfig, options: Mail.Opt }); await transport.sendMail(options); } - -/** - * Sends an email via AWS SES. - * @param options - The nodemailer options. - */ -async function sendEmailViaSes(options: Mail.Options): Promise { - const config = getConfig(); - const fromAddress = config.supportEmail; - const toAddresses = buildAddresses(options.to); - const ccAddresses = buildAddresses(options.cc); - const bccAddresses = buildAddresses(options.bcc); - - let msg: Uint8Array; - try { - msg = await buildRawMessage(options); - } catch (err) { - throw new OperationOutcomeError(badRequest('Invalid email options: ' + normalizeErrorString(err)), err); - } - - const sesClient = new SESv2Client({ region: config.awsRegion }); - await sesClient.send( - new SendEmailCommand({ - FromEmailAddress: fromAddress, - Destination: { - ToAddresses: toAddresses, - CcAddresses: ccAddresses, - BccAddresses: bccAddresses, - }, - Content: { - Raw: { - Data: msg, - }, - }, - }) - ); -} diff --git a/packages/server/src/email/routes.test.ts b/packages/server/src/email/routes.test.ts index 9615153759..e4192fa3cf 100644 --- a/packages/server/src/email/routes.test.ts +++ b/packages/server/src/email/routes.test.ts @@ -10,13 +10,12 @@ import { initTestAuth } from '../test.setup'; jest.mock('@aws-sdk/client-sesv2'); const app = express(); -let accessToken: string; describe('Email API Routes', () => { beforeAll(async () => { const config = await loadTestConfig(); + config.emailProvider = 'awsses'; await initApp(app, config); - accessToken = await initTestAuth(); }); beforeEach(() => { @@ -39,7 +38,22 @@ describe('Email API Routes', () => { expect(SendEmailCommand).toHaveBeenCalledTimes(0); }); + test('Forbidden for non project admin', async () => { + const accessToken = await initTestAuth({ membership: { admin: false } }); + const res = await request(app) + .post(`/email/v1/send`) + .set('Authorization', 'Bearer ' + accessToken) + .set('Content-Type', ContentType.JSON) + .send({ + to: 'alice@example.com', + subject: 'Subject', + text: 'Body', + }); + expect(res.status).toBe(403); + }); + test('Wrong content type', async () => { + const accessToken = await initTestAuth({ membership: { admin: true } }); const res = await request(app) .post(`/email/v1/send`) .set('Authorization', 'Bearer ' + accessToken) @@ -48,7 +62,8 @@ describe('Email API Routes', () => { expect(res.status).toBe(400); }); - test('Send email', async () => { + test('Send email as project admin', async () => { + const accessToken = await initTestAuth({ membership: { admin: true } }); const res = await request(app) .post(`/email/v1/send`) .set('Authorization', 'Bearer ' + accessToken) diff --git a/packages/server/src/email/routes.ts b/packages/server/src/email/routes.ts index 480ccc3c22..ab02d780d5 100644 --- a/packages/server/src/email/routes.ts +++ b/packages/server/src/email/routes.ts @@ -2,11 +2,11 @@ import { allOk, ContentType, forbidden } from '@medplum/core'; import { Request, Response, Router } from 'express'; import { body, check } from 'express-validator'; import { asyncWrap } from '../async'; +import { getAuthenticatedContext } from '../context'; import { sendOutcome } from '../fhir/outcomes'; import { authenticateRequest } from '../oauth/middleware'; -import { sendEmail } from './email'; -import { getAuthenticatedContext } from '../context'; import { makeValidationMiddleware } from '../util/validator'; +import { sendEmail } from './email'; export const emailRouter = Router(); emailRouter.use(authenticateRequest); @@ -24,7 +24,7 @@ emailRouter.post( const ctx = getAuthenticatedContext(); // Make sure the user project has the email feature enabled - if (!ctx.project.features?.includes('email')) { + if (!ctx.project.features?.includes('email') || !ctx.membership.admin) { sendOutcome(res, forbidden); return; } diff --git a/packages/server/src/email/utils.ts b/packages/server/src/email/utils.ts new file mode 100644 index 0000000000..df5a96b5b2 --- /dev/null +++ b/packages/server/src/email/utils.ts @@ -0,0 +1,73 @@ +import MailComposer from 'nodemailer/lib/mail-composer'; +import Mail, { Address } from 'nodemailer/lib/mailer'; +import { getConfig } from '../config'; + +/** + * Returns the from address to use. + * If the user specified a from address, it must be an approved sender. + * Otherwise uses the support email address. + * @param options - The user specified nodemailer options. + * @returns The from address to use. + */ +export function getFromAddress(options: Mail.Options): string { + const config = getConfig(); + + if (options.from) { + const fromAddress = addressToString(options.from); + if (fromAddress && config.approvedSenderEmails?.split(',')?.includes(fromAddress)) { + return fromAddress; + } + } + + return config.supportEmail; +} + +/** + * Converts nodemailer addresses to an array of strings. + * @param input - nodemailer address input. + * @returns Array of string addresses. + */ +export function buildAddresses(input: string | Address | (string | Address)[] | undefined): string[] | undefined { + if (!input) { + return undefined; + } + if (Array.isArray(input)) { + return input.map(addressToString) as string[]; + } + return [addressToString(input) as string]; +} + +/** + * Converts a nodemailer address to a string. + * @param address - nodemailer address input. + * @returns String address. + */ +export function addressToString(address: Address | string | undefined): string | undefined { + if (address) { + if (typeof address === 'string') { + return address; + } + if (typeof address === 'object' && 'address' in address) { + return address.address; + } + } + return undefined; +} + +/** + * Builds a raw email message using nodemailer MailComposer. + * @param options - The nodemailer options. + * @returns The raw email message. + */ +export function buildRawMessage(options: Mail.Options): Promise { + const msg = new MailComposer(options); + return new Promise((resolve, reject) => { + msg.compile().build((err, message) => { + if (err) { + reject(err); + return; + } + resolve(message); + }); + }); +} diff --git a/packages/server/src/fhir/binary.ts b/packages/server/src/fhir/binary.ts index 203f0b077b..ad3f6ffdc6 100644 --- a/packages/server/src/fhir/binary.ts +++ b/packages/server/src/fhir/binary.ts @@ -8,7 +8,6 @@ import { getAuthenticatedContext, getLogger } from '../context'; import { authenticateRequest } from '../oauth/middleware'; import { sendOutcome } from './outcomes'; import { sendResponse, sendResponseHeaders } from './response'; -import { getPresignedUrl } from './signer'; import { BinarySource, getBinaryStorage } from './storage'; export const binaryRouter = Router().use(authenticateRequest); @@ -119,7 +118,7 @@ async function handleBinaryWriteRequest(req: Request, res: Response): Promise { + const app = express(); + let accessToken: string; + const agents: Agent[] = []; + let connectedAgent: Agent; + let disabledAgent: Agent; + + beforeAll(async () => { + const config = await loadTestConfig(); + await initApp(app, config); + accessToken = await initTestAuth(); + + const promises = Array.from({ length: NUM_DEFAULT_AGENTS }) as Promise[]; + for (let i = 0; i < NUM_DEFAULT_AGENTS; i++) { + promises[i] = request(app) + .post('/fhir/R4/Agent') + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + resourceType: 'Agent', + identifier: [{ system: 'https://example.com/agent', value: randomUUID() }], + name: `Test Agent ${i + 1}`, + status: 'active', + }); + } + + const responses = await Promise.all(promises); + for (let i = 0; i < NUM_DEFAULT_AGENTS; i++) { + expect(responses[i].status).toBe(201); + agents[i] = responses[i].body; + } + + const agent1Res = await request(app) + .post('/fhir/R4/Agent') + .set('Authorization', 'Bearer ' + accessToken) + .type('json') + .send({ + identifier: [{ system: 'https://example.com/agent', value: randomUUID() }], + resourceType: 'Agent', + name: 'Medplum Agent', + status: 'active', + } satisfies Agent); + expect(agent1Res.status).toEqual(201); + + const agent2Res = await request(app) + .post('/fhir/R4/Agent') + .set('Authorization', 'Bearer ' + accessToken) + .type('json') + .send({ + identifier: [{ system: 'https://example.com/agent', value: randomUUID() }], + resourceType: 'Agent', + name: 'Old Medplum Agent', + status: 'off', + } satisfies Agent); + expect(agent2Res.status).toEqual(201); + + connectedAgent = agent1Res.body; + disabledAgent = agent2Res.body; + + // Emulate a connection + await getRedis().set( + `medplum:agent:${connectedAgent.id}:info`, + JSON.stringify({ + status: AgentConnectionState.CONNECTED, + version: '3.1.4', + lastUpdated: new Date().toISOString(), + }), + 'EX', + 60 + ); + + // Emulate a disconnected agent + await getRedis().set( + `medplum:agent:${disabledAgent.id}:info`, + JSON.stringify({ + status: AgentConnectionState.DISCONNECTED, + version: '3.1.2', + lastUpdated: new Date().toISOString(), + }), + 'EX', + 60 + ); + }); + + afterAll(async () => { + await shutdownApp(); + }); + + test('Get all agent statuses', async () => { + const res = await request(app) + .get('/fhir/R4/Agent/$bulk-status') + .set('Authorization', 'Bearer ' + accessToken); + expect(res.status).toBe(200); + + const bundle = res.body as Bundle; + expect(bundle.resourceType).toBe('Bundle'); + expect(bundle.entry).toHaveLength(4); + + const bundleEntries = bundle.entry as BundleEntry[]; + for (const entry of bundleEntries) { + const parameters = entry.resource as Parameters; + expect(parameters).toBeDefined(); + expect(parameters.resourceType).toEqual('Parameters'); + expect(parameters.parameter?.length).toEqual(2); + } + + expectBundleToContainStatusEntry(bundle, connectedAgent, { + status: AgentConnectionState.CONNECTED, + version: '3.1.4', + lastUpdated: expect.any(String), + }); + + expectBundleToContainStatusEntry(bundle, disabledAgent, { + status: AgentConnectionState.DISCONNECTED, + version: '3.1.2', + lastUpdated: expect.any(String), + }); + + expectBundleToContainStatusEntry(bundle, agents[0], { + status: AgentConnectionState.UNKNOWN, + version: 'unknown', + }); + }); + + test('Get agent statuses for agent with name containing Medplum', async () => { + const res = await request(app) + .get('/fhir/R4/Agent/$bulk-status') + .query({ 'name:contains': 'Medplum' }) + .set('Authorization', 'Bearer ' + accessToken); + expect(res.status).toBe(200); + + const bundle = res.body as Bundle; + expect(bundle.resourceType).toBe('Bundle'); + expect(bundle.entry).toHaveLength(2); + + const bundleEntries = bundle.entry as BundleEntry[]; + for (let i = 0; i < 2; i++) { + const parameters = bundleEntries[i].resource as Parameters; + expect(parameters).toBeDefined(); + expect(parameters.resourceType).toEqual('Parameters'); + expect(parameters.parameter?.length).toEqual(2); + } + + expectBundleToContainStatusEntry(bundle, connectedAgent, { + status: AgentConnectionState.CONNECTED, + version: '3.1.4', + lastUpdated: expect.any(String), + }); + + expectBundleToContainStatusEntry(bundle, disabledAgent, { + status: AgentConnectionState.DISCONNECTED, + version: '3.1.2', + lastUpdated: expect.any(String), + }); + }); + + test('Get agent statuses for ACTIVE agents with name containing Medplum', async () => { + const res = await request(app) + .get('/fhir/R4/Agent/$bulk-status') + .query({ 'name:contains': 'Medplum', status: 'active' }) + .set('Authorization', 'Bearer ' + accessToken); + expect(res.status).toBe(200); + + const bundle = res.body as Bundle; + expect(bundle.resourceType).toBe('Bundle'); + expect(bundle.entry).toHaveLength(1); + + const bundleEntries = bundle.entry as BundleEntry[]; + for (let i = 0; i < 1; i++) { + const parameters = bundleEntries[i].resource as Parameters; + expect(parameters).toBeDefined(); + expect(parameters.resourceType).toEqual('Parameters'); + expect(parameters.parameter?.length).toEqual(2); + } + + expectBundleToContainStatusEntry(bundle, connectedAgent, { + status: AgentConnectionState.CONNECTED, + version: '3.1.4', + lastUpdated: expect.any(String), + }); + }); + + test('Get agent statuses -- no matching agents', async () => { + const res = await request(app) + .get('/fhir/R4/Agent/$bulk-status') + .query({ name: 'INVALID_AGENT', status: 'active' }) + .set('Authorization', 'Bearer ' + accessToken); + expect(res.status).toBe(400); + + expect(res.body).toMatchObject({ + resourceType: 'OperationOutcome', + issue: expect.arrayContaining([ + expect.objectContaining({ severity: 'error', code: 'invalid' }), + ]), + }); + }); + + test('Get agent statuses -- invalid AgentInfo from Redis', async () => { + await getRedis().set( + `medplum:agent:${agents[1].id as string}:info`, + JSON.stringify({ + version: '3.1.4', + lastUpdated: new Date().toISOString(), + }), + 'EX', + 60 + ); + + const res = await request(app) + .get('/fhir/R4/Agent/$bulk-status') + .query({ name: 'Test Agent 2' }) + .set('Authorization', 'Bearer ' + accessToken); + expect(res.status).toBe(200); + + const bundle = res.body as Bundle; + expect(bundle.resourceType).toBe('Bundle'); + expect(bundle.entry).toHaveLength(1); + + expectBundleToContainOutcomeError(bundle, agents[1], { + issue: [expect.objectContaining({ severity: 'error', code: 'exception' })], + }); + + await getRedis().set( + `medplum:agent:${agents[1].id as string}:info`, + JSON.stringify({ + status: AgentConnectionState.UNKNOWN, + version: 'unknown', + lastUpdated: new Date().toISOString(), + } satisfies AgentInfo), + 'EX', + 60 + ); + }); + + test('Get agent statuses -- `_count` exceeding max page size', async () => { + const res = await request(app) + .get('/fhir/R4/Agent/$bulk-status') + .query({ 'name:contains': 'Medplum', _count: MAX_AGENTS_PER_PAGE + 1 }) + .set('Authorization', 'Bearer ' + accessToken); + expect(res.status).toBe(400); + + expect(res.body).toMatchObject({ + resourceType: 'OperationOutcome', + issue: expect.arrayContaining([ + expect.objectContaining({ severity: 'error', code: 'invalid' }), + ]), + }); + }); +}); + +function expectBundleToContainStatusEntry(bundle: Bundle, agent: Agent, info: AgentInfo): void { + const entries = bundle.entry as BundleEntry[]; + expect(entries).toContainEqual({ + resource: expect.objectContaining({ + resourceType: 'Parameters', + parameter: expect.arrayContaining([ + expect.objectContaining({ + name: 'agent', + resource: expect.objectContaining(agent), + }), + expect.objectContaining({ + name: 'result', + resource: expect.objectContaining({ + resourceType: 'Parameters', + parameter: expect.arrayContaining([ + expect.objectContaining({ + name: 'status', + valueCode: info.status, + }), + expect.objectContaining({ + name: 'version', + valueString: info.version, + }), + ...(info.lastUpdated !== undefined + ? [ + expect.objectContaining({ + name: 'lastUpdated', + valueInstant: info.lastUpdated, + }), + ] + : []), + ]), + }), + }), + ]), + }), + }); +} + +function expectBundleToContainOutcomeError( + bundle: Bundle, + agent: Agent, + outcome: Partial & { issue: OperationOutcomeIssue[] } +): void { + const entries = bundle.entry as BundleEntry[]; + expect(entries).toContainEqual({ + resource: expect.objectContaining({ + resourceType: 'Parameters', + parameter: expect.arrayContaining([ + expect.objectContaining({ + name: 'agent', + resource: expect.objectContaining(agent), + }), + expect.objectContaining({ + name: 'result', + resource: expect.objectContaining>(outcome), + }), + ]), + }), + }); +} diff --git a/packages/server/src/fhir/operations/agentbulkstatus.ts b/packages/server/src/fhir/operations/agentbulkstatus.ts new file mode 100644 index 0000000000..23ab29a21d --- /dev/null +++ b/packages/server/src/fhir/operations/agentbulkstatus.ts @@ -0,0 +1,82 @@ +import { allOk, badRequest, isOk, serverError } from '@medplum/core'; +import { FhirRequest, FhirResponse } from '@medplum/fhir-router'; +import { Agent, Bundle, BundleEntry, OperationDefinition, OperationOutcome, Parameters } from '@medplum/fhirtypes'; +import { getAuthenticatedContext } from '../../context'; +import { agentStatusHandler } from './agentstatus'; +import { getAgentsForRequest } from './agentutils'; + +export const MAX_AGENTS_PER_PAGE = 100; + +export const operation: OperationDefinition = { + resourceType: 'OperationDefinition', + name: 'agent-bulk-status', + status: 'active', + kind: 'operation', + code: 'bulk-status', + experimental: true, + resource: ['Agent'], + system: false, + type: true, + instance: false, + parameter: [{ use: 'out', name: 'return', type: 'Bundle', min: 1, max: '1' }], +}; + +/** + * Handles HTTP requests for the Agent $status operation. + * First reads the agent and makes sure it is valid and the user has access to it. + * Then tries to get the agent status from Redis. + * Returns the agent status details as a Parameters resource. + * + * @param req - The FHIR request. + * @returns The FHIR response. + */ +export async function agentBulkStatusHandler(req: FhirRequest): Promise { + const { repo } = getAuthenticatedContext(); + + if (req.query._count && Number.parseInt(req.query._count, 10) > MAX_AGENTS_PER_PAGE) { + return [badRequest(`'_count' of ${req.query._count} is greater than max of ${MAX_AGENTS_PER_PAGE}`)]; + } + + const agents = await getAgentsForRequest(req, repo); + if (!agents?.length) { + return [badRequest('No agent(s) for given query')]; + } + + const promises = agents.map((agent) => agentStatusHandler({ ...req, params: { id: agent.id as string } })); + const results = await Promise.allSettled(promises); + const entries = [] as BundleEntry[]; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === 'rejected') { + entries.push(makeResultWrapperEntry(serverError(result.reason as Error), agents[i])); + continue; + } + const [outcome, params] = result.value; + if (!isOk(outcome)) { + entries.push(makeResultWrapperEntry(outcome, agents[i])); + continue; + } + entries.push(makeResultWrapperEntry(params as Parameters, agents[i])); + } + + return [ + allOk, + { + resourceType: 'Bundle', + type: 'collection', + entry: entries, + } satisfies Bundle, + ]; +} + +function makeResultWrapperEntry(result: Parameters | OperationOutcome, agent: Agent): BundleEntry { + return { + resource: { + resourceType: 'Parameters', + parameter: [ + { name: 'agent', resource: agent }, + { name: 'result', resource: result }, + ], + }, + }; +} diff --git a/packages/server/src/fhir/operations/agentpush.test.ts b/packages/server/src/fhir/operations/agentpush.test.ts index 111c8a9b6a..9101abe448 100644 --- a/packages/server/src/fhir/operations/agentpush.test.ts +++ b/packages/server/src/fhir/operations/agentpush.test.ts @@ -1,11 +1,20 @@ -import { allOk, ContentType, getReferenceString } from '@medplum/core'; -import { Agent, Device } from '@medplum/fhirtypes'; -import { randomUUID } from 'crypto'; +import { + AgentTransmitRequest, + AgentTransmitResponse, + allOk, + ContentType, + getReferenceString, + sleep, +} from '@medplum/core'; +import { Agent, Device, OperationOutcome } from '@medplum/fhirtypes'; import express from 'express'; +import { randomUUID } from 'node:crypto'; import request from 'supertest'; import { initApp, shutdownApp } from '../../app'; import { loadTestConfig } from '../../config'; +import { getRedis } from '../../redis'; import { initTestAuth } from '../../test.setup'; +import { AgentPushParameters } from './agentpush'; const app = express(); let accessToken: string; @@ -248,4 +257,201 @@ describe('Agent Push', () => { expect(res.status).toBe(400); expect(res.body.issue[0].details.text).toEqual('Invalid wait timeout'); }); + + test('Ping -- Successful ping to IP', async () => { + const redis = getRedis(); + const publishSpy = jest.spyOn(redis, 'publish'); + + let resolve!: (value: request.Response) => void | PromiseLike; + let reject!: (err: Error) => void; + + const deferredResponse = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + request(app) + .post(`/fhir/R4/Agent/${agent.id}/$push`) + .set('Content-Type', ContentType.JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + contentType: ContentType.PING, + body: 'PING', + destination: '8.8.8.8', + waitForResponse: true, + } satisfies AgentPushParameters) + .then(resolve) + .catch(reject); + + let shouldThrow = false; + const timer = setTimeout(() => { + shouldThrow = true; + }, 3500); + + while (!publishSpy.mock.lastCall) { + if (shouldThrow) { + throw new Error('Timeout'); + } + await sleep(50); + } + clearTimeout(timer); + + const transmitRequestStr = publishSpy.mock.lastCall?.[1]?.toString() as string; + expect(transmitRequestStr).toBeDefined(); + const transmitRequest = JSON.parse(transmitRequestStr) as AgentTransmitRequest; + + await getRedis().publish( + transmitRequest.callback as string, + JSON.stringify({ + ...transmitRequest, + type: 'agent:transmit:response', + statusCode: 200, + contentType: ContentType.TEXT, + body: ` +PING 8.8.8.8 (8.8.8.8): 56 data bytes +64 bytes from 8.8.8.8: icmp_seq=0 ttl=115 time=10.316 ms + +--- 8.8.8.8 ping statistics --- +1 packets transmitted, 1 packets received, 0.0% packet loss +round-trip min/avg/max/stddev = 10.316/10.316/10.316/nan ms`, + } satisfies AgentTransmitResponse) + ); + + const res = await deferredResponse; + expect(res.status).toEqual(200); + expect(res.text).toEqual(expect.stringMatching(/ping statistics/i)); + + publishSpy.mockRestore(); + }); + + test('Ping -- Successful ping to hostname', async () => { + const redis = getRedis(); + const publishSpy = jest.spyOn(redis, 'publish'); + + let resolve!: (value: request.Response) => void | PromiseLike; + let reject!: (err: Error) => void; + + const deferredResponse = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + request(app) + .post(`/fhir/R4/Agent/${agent.id}/$push`) + .set('Content-Type', ContentType.JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + contentType: ContentType.PING, + body: 'PING', + destination: 'localhost', + waitForResponse: true, + } satisfies AgentPushParameters) + .then(resolve) + .catch(reject); + + let shouldThrow = false; + const timer = setTimeout(() => { + shouldThrow = true; + }, 3500); + + while (!publishSpy.mock.lastCall) { + if (shouldThrow) { + throw new Error('Timeout'); + } + await sleep(50); + } + clearTimeout(timer); + + const transmitRequestStr = publishSpy.mock.lastCall?.[1]?.toString() as string; + expect(transmitRequestStr).toBeDefined(); + const transmitRequest = JSON.parse(transmitRequestStr) as AgentTransmitRequest; + + await getRedis().publish( + transmitRequest.callback as string, + JSON.stringify({ + ...transmitRequest, + type: 'agent:transmit:response', + statusCode: 200, + contentType: ContentType.TEXT, + body: ` +PING localhost (127.0.0.1): 56 data bytes +64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.081 ms + +--- localhost ping statistics --- +1 packets transmitted, 1 packets received, 0.0% packet loss +round-trip min/avg/max/stddev = 0.081/0.081/0.081/nan ms`, + } satisfies AgentTransmitResponse) + ); + + const res = await deferredResponse; + expect(res.status).toEqual(200); + expect(res.text).toEqual(expect.stringMatching(/ping statistics/i)); + + publishSpy.mockRestore(); + }); + + test('Ping -- Error', async () => { + const redis = getRedis(); + const publishSpy = jest.spyOn(redis, 'publish'); + + let resolve!: (value: request.Response) => void | PromiseLike; + let reject!: (err: Error) => void; + + const deferredResponse = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + request(app) + .post(`/fhir/R4/Agent/${agent.id}/$push`) + .set('Content-Type', ContentType.JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + contentType: ContentType.PING, + body: 'PING', + destination: '8.8.8.8', + waitForResponse: true, + } satisfies AgentPushParameters) + .then(resolve) + .catch(reject); + + let shouldThrow = false; + const timer = setTimeout(() => { + shouldThrow = true; + }, 3500); + + while (!publishSpy.mock.lastCall) { + if (shouldThrow) { + throw new Error('Timeout'); + } + await sleep(50); + } + clearTimeout(timer); + + const transmitRequestStr = publishSpy.mock.lastCall?.[1]?.toString() as string; + expect(transmitRequestStr).toBeDefined(); + const transmitRequest = JSON.parse(transmitRequestStr) as AgentTransmitRequest; + + await getRedis().publish( + transmitRequest.callback as string, + JSON.stringify({ + ...transmitRequest, + type: 'agent:transmit:response', + statusCode: 500, + contentType: ContentType.TEXT, + body: 'Error: Unable to ping "8.8.8.8"', + } satisfies AgentTransmitResponse) + ); + + const res = await deferredResponse; + expect(res.status).toEqual(500); + + const body = res.body as OperationOutcome; + expect(body).toBeDefined(); + expect(body.issue[0].severity).toEqual('error'); + expect(body.issue[0]?.details?.text).toEqual(expect.stringMatching(/internal server error/i)); + expect(body.issue[0]?.diagnostics).toEqual(expect.stringMatching(/unable to ping/i)); + + publishSpy.mockRestore(); + }); }); diff --git a/packages/server/src/fhir/operations/agentpush.ts b/packages/server/src/fhir/operations/agentpush.ts index 9f9c340355..4bdf0c7113 100644 --- a/packages/server/src/fhir/operations/agentpush.ts +++ b/packages/server/src/fhir/operations/agentpush.ts @@ -1,10 +1,18 @@ -import { AgentTransmitRequest, allOk, badRequest, BaseAgentRequestMessage, getReferenceString } from '@medplum/core'; +import { + AgentTransmitRequest, + AgentTransmitResponse, + allOk, + badRequest, + BaseAgentRequestMessage, + getReferenceString, + serverError, +} from '@medplum/core'; import { Agent } from '@medplum/fhirtypes'; import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { asyncWrap } from '../../async'; import { getAuthenticatedContext } from '../../context'; -import { getRedis } from '../../redis'; +import { getRedis, getRedisSubscriber } from '../../redis'; import { sendOutcome } from '../outcomes'; import { getAgentForRequest, getDevice } from './agentutils'; import { parseParameters } from './utils/parameters'; @@ -58,7 +66,7 @@ export const agentPushHandler = asyncWrap(async (req: Request, res: Response) => return; } - const device = await getDevice(repo, params.destination); + const device = await getDevice(repo, params); if (!device) { sendOutcome(res, badRequest('Destination device not found')); return; @@ -86,11 +94,18 @@ export const agentPushHandler = asyncWrap(async (req: Request, res: Response) => // Otherwise, open a new redis connection in "subscribe" state message.callback = getReferenceString(agent) + '-' + randomUUID(); - const redisSubscriber = getRedis().duplicate(); + const redisSubscriber = getRedisSubscriber(); await redisSubscriber.subscribe(message.callback); redisSubscriber.on('message', (_channel: string, message: string) => { - const response = JSON.parse(message); - res.status(200).type(response.contentType).send(response.body); + const response = JSON.parse(message) as AgentTransmitResponse; + if (response.statusCode && response.statusCode >= 400) { + sendOutcome(res, serverError(new Error(response.body))); + } else { + res + .status(response.statusCode ?? 200) + .type(response.contentType) + .send(response.body); + } cleanup(); }); diff --git a/packages/server/src/fhir/operations/agentstatus.test.ts b/packages/server/src/fhir/operations/agentstatus.test.ts index db592eec22..3a4242efba 100644 --- a/packages/server/src/fhir/operations/agentstatus.test.ts +++ b/packages/server/src/fhir/operations/agentstatus.test.ts @@ -3,6 +3,7 @@ import { Agent, Parameters } from '@medplum/fhirtypes'; import { randomUUID } from 'crypto'; import express from 'express'; import request from 'supertest'; +import { AgentConnectionState } from '../../agent/utils'; import { initApp, shutdownApp } from '../../app'; import { loadTestConfig } from '../../config'; import { getRedis } from '../../redis'; @@ -44,14 +45,16 @@ describe('Agent Status', () => { const parameters1 = res1.body as Parameters; expect(parameters1.resourceType).toBe('Parameters'); - expect(parameters1.parameter).toHaveLength(1); - expect(parameters1.parameter?.find((p) => p.name === 'status')?.valueCode).toBe('unknown'); + expect(parameters1.parameter).toHaveLength(2); + expect(parameters1.parameter?.find((p) => p.name === 'status')?.valueCode).toBe(AgentConnectionState.UNKNOWN); + expect(parameters1.parameter?.find((p) => p.name === 'version')?.valueString).toBe('unknown'); // Emulate a connection await getRedis().set( - `medplum:agent:${agent.id}:status`, + `medplum:agent:${agent.id}:info`, JSON.stringify({ - status: 'connected', + status: AgentConnectionState.CONNECTED, + version: '3.1.4', lastUpdated: new Date().toISOString(), }), 'EX', @@ -65,8 +68,9 @@ describe('Agent Status', () => { const parameters2 = res2.body as Parameters; expect(parameters2.resourceType).toBe('Parameters'); - expect(parameters2.parameter).toHaveLength(2); - expect(parameters2.parameter?.find((p) => p.name === 'status')?.valueCode).toBe('connected'); + expect(parameters2.parameter).toHaveLength(3); + expect(parameters2.parameter?.find((p) => p.name === 'status')?.valueCode).toBe(AgentConnectionState.CONNECTED); + expect(parameters2.parameter?.find((p) => p.name === 'version')?.valueString).toBe('3.1.4'); expect(parameters2.parameter?.find((p) => p.name === 'lastUpdated')?.valueInstant).toBeTruthy(); }); }); diff --git a/packages/server/src/fhir/operations/agentstatus.ts b/packages/server/src/fhir/operations/agentstatus.ts index e8e1ba1e1b..b19fdd3974 100644 --- a/packages/server/src/fhir/operations/agentstatus.ts +++ b/packages/server/src/fhir/operations/agentstatus.ts @@ -1,16 +1,12 @@ import { allOk, badRequest } from '@medplum/core'; import { FhirRequest, FhirResponse } from '@medplum/fhir-router'; import { OperationDefinition } from '@medplum/fhirtypes'; +import { AgentConnectionState, AgentInfo } from '../../agent/utils'; import { getAuthenticatedContext } from '../../context'; import { getRedis } from '../../redis'; import { getAgentForRequest } from './agentutils'; import { buildOutputParameters } from './utils/parameters'; -interface AgentStatusOutput { - status: string; - lastUpdated?: string; -} - const operation: OperationDefinition = { resourceType: 'OperationDefinition', name: 'agent-status', @@ -24,6 +20,7 @@ const operation: OperationDefinition = { instance: false, parameter: [ { use: 'out', name: 'status', type: 'code', min: 1, max: '1' }, + { use: 'out', name: 'version', type: 'string', min: 1, max: '1' }, { use: 'out', name: 'lastUpdated', type: 'instant', min: 0, max: '1' }, ], }; @@ -46,16 +43,16 @@ export async function agentStatusHandler(req: FhirRequest): Promise { +/** + * Returns the Agents for a request. + * + * @param req - The HTTP request. + * @param repo - The repository. + * @returns The agent, or undefined if not found. + */ +export async function getAgentsForRequest(req: FhirRequest, repo: Repository): Promise { + return repo.searchResources(parseSearchRequest('Agent', req.query)); +} + +export async function getDevice(repo: Repository, params: AgentPushParameters): Promise { + const { destination, contentType } = params; if (destination.startsWith('Device/')) { try { return await repo.readReference({ reference: destination }); @@ -51,7 +64,7 @@ export async function getDevice(repo: Repository, destination: string): Promise< if (destination.startsWith('Device?')) { return repo.searchOne(parseSearchRequest(destination)); } - if (isIPv4(destination)) { + if (contentType === ContentType.PING && (isIPv4(destination) || isValidHostname(destination))) { return { resourceType: 'Device', url: destination }; } return undefined; diff --git a/packages/server/src/fhir/operations/deploy.test.ts b/packages/server/src/fhir/operations/deploy.test.ts index ff4d4d0090..d7a1f49642 100644 --- a/packages/server/src/fhir/operations/deploy.test.ts +++ b/packages/server/src/fhir/operations/deploy.test.ts @@ -1,16 +1,5 @@ -import { - CreateFunctionCommand, - GetFunctionCommand, - GetFunctionConfigurationCommand, - LambdaClient, - ListLayerVersionsCommand, - UpdateFunctionCodeCommand, - UpdateFunctionConfigurationCommand, -} from '@aws-sdk/client-lambda'; import { ContentType } from '@medplum/core'; import { Bot } from '@medplum/fhirtypes'; -import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; -import 'aws-sdk-client-mock-jest'; import { randomUUID } from 'crypto'; import express from 'express'; import request from 'supertest'; @@ -21,7 +10,6 @@ import { initTestAuth, withTestContext } from '../../test.setup'; const app = express(); let accessToken: string; -let mockLambdaClient: AwsClientStub; describe('Deploy', () => { beforeAll(async () => { @@ -34,142 +22,6 @@ describe('Deploy', () => { await shutdownApp(); }); - beforeEach(() => { - let created = false; - - mockLambdaClient = mockClient(LambdaClient); - - mockLambdaClient.on(CreateFunctionCommand).callsFake(({ FunctionName }) => { - created = true; - - return { - Configuration: { - FunctionName, - }, - }; - }); - - mockLambdaClient.on(GetFunctionCommand).callsFake(({ FunctionName }) => { - if (created) { - return { - Configuration: { - FunctionName, - }, - }; - } - - return { - Configuration: {}, - }; - }); - - mockLambdaClient.on(GetFunctionConfigurationCommand).callsFake(({ FunctionName }) => { - return { - FunctionName, - Runtime: 'nodejs18.x', - Handler: 'index.handler', - State: 'Active', - Layers: [ - { - Arn: 'arn:aws:lambda:us-east-1:123456789012:layer:test-layer:1', - }, - ], - }; - }); - - mockLambdaClient.on(ListLayerVersionsCommand).resolves({ - LayerVersions: [ - { - LayerVersionArn: 'arn:aws:lambda:us-east-1:123456789012:layer:test-layer:1', - }, - ], - }); - - mockLambdaClient.on(UpdateFunctionCodeCommand).callsFake(({ FunctionName }) => ({ - Configuration: { - FunctionName, - }, - })); - }); - - afterEach(() => { - mockLambdaClient.restore(); - }); - - test('Happy path', async () => { - // Step 1: Create a bot - const res1 = await request(app) - .post(`/fhir/R4/Bot`) - .set('Content-Type', ContentType.FHIR_JSON) - .set('Authorization', 'Bearer ' + accessToken) - .send({ - resourceType: 'Bot', - name: 'Test Bot', - runtimeVersion: 'awslambda', - code: ` - export async function handler() { - console.log('input', input); - return input; - } - `, - }); - expect(res1.status).toBe(201); - - const bot = res1.body as Bot; - const name = `medplum-bot-lambda-${bot.id}`; - - // Step 2: Deploy the bot - const res2 = await request(app) - .post(`/fhir/R4/Bot/${bot.id}/$deploy`) - .set('Content-Type', ContentType.FHIR_JSON) - .set('Authorization', 'Bearer ' + accessToken) - .send({ - code: ` - export async function handler() { - console.log('input', input); - return input; - } - `, - }); - expect(res2.status).toBe(200); - - expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(ListLayerVersionsCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(CreateFunctionCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandWith(GetFunctionCommand, { - FunctionName: name, - }); - expect(mockLambdaClient).toHaveReceivedCommandWith(CreateFunctionCommand, { - FunctionName: name, - }); - mockLambdaClient.resetHistory(); - - // Step 3: Deploy again to trigger the update path - const res3 = await request(app) - .post(`/fhir/R4/Bot/${bot.id}/$deploy`) - .set('Content-Type', ContentType.FHIR_JSON) - .set('Authorization', 'Bearer ' + accessToken) - .send({ - code: ` - export async function handler() { - console.log('input', input); - return input; - } - `, - filename: 'updated.js', - }); - expect(res3.status).toBe(200); - - expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(ListLayerVersionsCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionConfigurationCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(UpdateFunctionConfigurationCommand, 0); - expect(mockLambdaClient).toHaveReceivedCommandTimes(UpdateFunctionCodeCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandWith(GetFunctionCommand, { - FunctionName: name, - }); - }); - test('Deploy bot with missing code', async () => { // Step 1: Create a bot const res1 = await request(app) @@ -201,91 +53,6 @@ describe('Deploy', () => { expect(res2.body.issue[0].details.text).toEqual('Missing code'); }); - test('Deploy bot with lambda layer update', async () => { - // When deploying a bot, we check if we need to update the bot configuration. - // This test verifies that we correctly update the bot configuration when the lambda layer changes. - // Step 1: Create a bot - const res1 = await request(app) - .post(`/fhir/R4/Bot`) - .set('Content-Type', ContentType.FHIR_JSON) - .set('Authorization', 'Bearer ' + accessToken) - .send({ - resourceType: 'Bot', - name: 'Test Bot', - runtimeVersion: 'awslambda', - code: ` - export async function handler() { - console.log('input', input); - return input; - } - `, - }); - expect(res1.status).toBe(201); - - const bot = res1.body as Bot; - const name = `medplum-bot-lambda-${bot.id}`; - - // Step 2: Deploy the bot - const res2 = await request(app) - .post(`/fhir/R4/Bot/${bot.id}/$deploy`) - .set('Content-Type', ContentType.FHIR_JSON) - .set('Authorization', 'Bearer ' + accessToken) - .send({ - code: ` - export async function handler() { - console.log('input', input); - return input; - } - `, - }); - expect(res2.status).toBe(200); - - expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(ListLayerVersionsCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(CreateFunctionCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandWith(GetFunctionCommand, { - FunctionName: name, - }); - expect(mockLambdaClient).toHaveReceivedCommandWith(CreateFunctionCommand, { - FunctionName: name, - }); - mockLambdaClient.resetHistory(); - - // Step 3: Simulate releasing a new version of the lambda layer - mockLambdaClient.on(ListLayerVersionsCommand).resolves({ - LayerVersions: [ - { - LayerVersionArn: 'new-layer-version-arn', - }, - ], - }); - - // Step 4: Deploy again to trigger the update path - const res3 = await request(app) - .post(`/fhir/R4/Bot/${bot.id}/$deploy`) - .set('Content-Type', ContentType.FHIR_JSON) - .set('Authorization', 'Bearer ' + accessToken) - .send({ - code: ` - export async function handler() { - console.log('input', input); - return input; - } - `, - filename: 'updated.js', - }); - expect(res3.status).toBe(200); - - expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(ListLayerVersionsCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(GetFunctionConfigurationCommand, 2); - expect(mockLambdaClient).toHaveReceivedCommandTimes(UpdateFunctionConfigurationCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandTimes(UpdateFunctionCodeCommand, 1); - expect(mockLambdaClient).toHaveReceivedCommandWith(GetFunctionCommand, { - FunctionName: name, - }); - }); - test('Bots not enabled', async () => { // First, Alice creates a project const { project, accessToken } = await withTestContext(() => diff --git a/packages/server/src/fhir/operations/deploy.ts b/packages/server/src/fhir/operations/deploy.ts index 6b8f447587..b065908cd9 100644 --- a/packages/server/src/fhir/operations/deploy.ts +++ b/packages/server/src/fhir/operations/deploy.ts @@ -1,104 +1,13 @@ -import { - CreateFunctionCommand, - GetFunctionCommand, - GetFunctionConfigurationCommand, - GetFunctionConfigurationCommandOutput, - LambdaClient, - ListLayerVersionsCommand, - PackageType, - UpdateFunctionCodeCommand, - UpdateFunctionConfigurationCommand, -} from '@aws-sdk/client-lambda'; -import { ContentType, allOk, badRequest, getReferenceString, normalizeOperationOutcome, sleep } from '@medplum/core'; +import { ContentType, allOk, badRequest, getReferenceString, normalizeOperationOutcome } from '@medplum/core'; import { FhirRequest, FhirResponse } from '@medplum/fhir-router'; import { Binary, Bot } from '@medplum/fhirtypes'; -import { ConfiguredRetryStrategy } from '@smithy/util-retry'; -import JSZip from 'jszip'; import { Readable } from 'stream'; -import { getConfig } from '../../config'; -import { getAuthenticatedContext, getRequestContext } from '../../context'; +import { deployLambda } from '../../cloud/aws/deploy'; +import { getAuthenticatedContext } from '../../context'; import { getSystemRepo } from '../repo'; import { getBinaryStorage } from '../storage'; import { isBotEnabled } from './execute'; -const LAMBDA_RUNTIME = 'nodejs18.x'; - -const LAMBDA_HANDLER = 'index.handler'; - -const LAMBDA_MEMORY = 1024; - -const WRAPPER_CODE = `const { ContentType, Hl7Message, MedplumClient } = require("@medplum/core"); -const fetch = require("node-fetch"); -const PdfPrinter = require("pdfmake"); -const userCode = require("./user.js"); - -exports.handler = async (event, context) => { - const { bot, baseUrl, accessToken, contentType, secrets, traceId } = event; - const medplum = new MedplumClient({ - baseUrl, - fetch: function(url, options = {}) { - options.headers ||= {}; - options.headers['X-Trace-Id'] = traceId; - options.headers['traceparent'] = traceId; - return fetch(url, options); - }, - createPdf, - }); - medplum.setAccessToken(accessToken); - try { - let input = event.input; - if (contentType === ContentType.HL7_V2 && input) { - input = Hl7Message.parse(input); - } - let result = await userCode.handler(medplum, { bot, input, contentType, secrets, traceId }); - if (contentType === ContentType.HL7_V2 && result) { - result = result.toString(); - } - return result; - } catch (err) { - if (err instanceof Error) { - console.log("Unhandled error: " + err.message + "\\n" + err.stack); - } else if (typeof err === "object") { - console.log("Unhandled error: " + JSON.stringify(err, undefined, 2)); - } else { - console.log("Unhandled error: " + err); - } - throw err; - } -}; - -function createPdf(docDefinition, tableLayouts, fonts) { - if (!fonts) { - fonts = { - Helvetica: { - normal: 'Helvetica', - bold: 'Helvetica-Bold', - italics: 'Helvetica-Oblique', - bolditalics: 'Helvetica-BoldOblique', - }, - Roboto: { - normal: '/opt/fonts/Roboto/Roboto-Regular.ttf', - bold: '/opt/fonts/Roboto/Roboto-Medium.ttf', - italics: '/opt/fonts/Roboto/Roboto-Italic.ttf', - bolditalics: '/opt/fonts/Roboto/Roboto-MediumItalic.ttf' - }, - Avenir: { - normal: '/opt/fonts/Avenir/Avenir.ttf' - } - }; - } - return new Promise((resolve, reject) => { - const printer = new PdfPrinter(fonts); - const pdfDoc = printer.createPdfKitDocument(docDefinition, { tableLayouts }); - const chunks = []; - pdfDoc.on('data', (chunk) => chunks.push(chunk)); - pdfDoc.on('end', () => resolve(Buffer.concat(chunks))); - pdfDoc.on('error', reject); - pdfDoc.end(); - }); -} -`; - export async function deployHandler(req: FhirRequest): Promise { const ctx = getAuthenticatedContext(); const { id } = req.params; @@ -151,168 +60,3 @@ export async function deployHandler(req: FhirRequest): Promise { return [normalizeOperationOutcome(err)]; } } - -async function deployLambda(bot: Bot, code: string): Promise { - const ctx = getRequestContext(); - - // Create a new AWS Lambda client - // Use a custom retry strategy to avoid throttling errors - // This is especially important when updating lambdas which also - // involve upgrading the layer version. - const client = new LambdaClient({ - region: getConfig().awsRegion, - retryStrategy: new ConfiguredRetryStrategy( - 5, // max attempts - (attempt: number) => 500 * 2 ** attempt // Exponential backoff - ), - }); - - const name = `medplum-bot-lambda-${bot.id}`; - ctx.logger.info('Deploying lambda function for bot', { name }); - const zipFile = await createZipFile(code); - ctx.logger.debug('Lambda function zip size', { bytes: zipFile.byteLength }); - - const exists = await lambdaExists(client, name); - if (!exists) { - await createLambda(client, name, zipFile); - } else { - await updateLambda(client, name, zipFile); - } -} - -async function createZipFile(code: string): Promise { - const zip = new JSZip(); - zip.file('user.js', code); - zip.file('index.js', WRAPPER_CODE); - return zip.generateAsync({ type: 'uint8array' }); -} - -/** - * Returns true if the AWS Lambda exists for the bot name. - * @param client - The AWS Lambda client. - * @param name - The bot name. - * @returns True if the bot exists. - */ -async function lambdaExists(client: LambdaClient, name: string): Promise { - try { - const command = new GetFunctionCommand({ FunctionName: name }); - const response = await client.send(command); - return response.Configuration?.FunctionName === name; - } catch (err) { - return false; - } -} - -/** - * Creates a new AWS Lambda for the bot name. - * @param client - The AWS Lambda client. - * @param name - The bot name. - * @param zipFile - The zip file with the bot code. - */ -async function createLambda(client: LambdaClient, name: string, zipFile: Uint8Array): Promise { - const layerVersion = await getLayerVersion(client); - - await client.send( - new CreateFunctionCommand({ - FunctionName: name, - Role: getConfig().botLambdaRoleArn, - Runtime: LAMBDA_RUNTIME, - Handler: LAMBDA_HANDLER, - MemorySize: LAMBDA_MEMORY, - PackageType: PackageType.Zip, - Layers: [layerVersion], - Code: { - ZipFile: zipFile, - }, - Publish: true, - Timeout: 10, // seconds - }) - ); -} - -/** - * Updates an existing AWS Lambda for the bot name. - * @param client - The AWS Lambda client. - * @param name - The bot name. - * @param zipFile - The zip file with the bot code. - */ -async function updateLambda(client: LambdaClient, name: string, zipFile: Uint8Array): Promise { - // First, make sure the lambda configuration is up to date - await updateLambdaConfig(client, name); - - // Then update the code - await client.send( - new UpdateFunctionCodeCommand({ - FunctionName: name, - ZipFile: zipFile, - Publish: true, - }) - ); -} - -/** - * Updates the lambda configuration. - * @param client - The AWS Lambda client. - * @param name - The lambda name. - */ -async function updateLambdaConfig(client: LambdaClient, name: string): Promise { - const layerVersion = await getLayerVersion(client); - const functionConfig = await getLambdaConfig(client, name); - if ( - functionConfig.Runtime === LAMBDA_RUNTIME && - functionConfig.Handler === LAMBDA_HANDLER && - functionConfig.Layers?.[0].Arn === layerVersion - ) { - // Everything is up-to-date - return; - } - - // Need to update - await client.send( - new UpdateFunctionConfigurationCommand({ - FunctionName: name, - Role: getConfig().botLambdaRoleArn, - Runtime: LAMBDA_RUNTIME, - Handler: LAMBDA_HANDLER, - Layers: [layerVersion], - }) - ); - - // Wait for the update to complete before returning - // Wait up to 5 seconds - // See: https://github.com/aws/aws-toolkit-visual-studio/issues/197 - // See: https://aws.amazon.com/blogs/compute/coming-soon-expansion-of-aws-lambda-states-to-all-functions/ - for (let i = 0; i < 5; i++) { - const config = await getLambdaConfig(client, name); - // Valid Values: Pending | Active | Inactive | Failed - // See: https://docs.aws.amazon.com/lambda/latest/dg/API_GetFunctionConfiguration.html - if (config.State === 'Active') { - return; - } - await sleep(1000); - } -} - -async function getLambdaConfig(client: LambdaClient, name: string): Promise { - return client.send( - new GetFunctionConfigurationCommand({ - FunctionName: name, - }) - ); -} - -/** - * Returns the latest layer version for the Medplum bot layer. - * The first result is the latest version. - * See: https://stackoverflow.com/a/55752188 - * @param client - The AWS Lambda client. - * @returns The most recent layer version ARN. - */ -async function getLayerVersion(client: LambdaClient): Promise { - const command = new ListLayerVersionsCommand({ - LayerName: getConfig().botLambdaLayerName, - MaxItems: 1, - }); - const response = await client.send(command); - return response.LayerVersions?.[0].LayerVersionArn as string; -} diff --git a/packages/server/src/fhir/operations/execute.test.ts b/packages/server/src/fhir/operations/execute.test.ts index db562824ae..977128cf7c 100644 --- a/packages/server/src/fhir/operations/execute.test.ts +++ b/packages/server/src/fhir/operations/execute.test.ts @@ -1,7 +1,5 @@ -import { InvokeCommand, LambdaClient, ListLayerVersionsCommand } from '@aws-sdk/client-lambda'; import { ContentType } from '@medplum/core'; import { Bot } from '@medplum/fhirtypes'; -import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; import { randomUUID } from 'crypto'; import express from 'express'; import request from 'supertest'; @@ -10,49 +8,19 @@ import { registerNew } from '../../auth/register'; import { getConfig, loadTestConfig } from '../../config'; import { initTestAuth, withTestContext } from '../../test.setup'; import { getBinaryStorage } from '../storage'; -import { getLambdaFunctionName } from './execute'; const app = express(); let accessToken: string; let bot: Bot; describe('Execute', () => { - let mockLambdaClient: AwsClientStub; - - beforeEach(() => { - mockLambdaClient = mockClient(LambdaClient); - - mockLambdaClient.on(ListLayerVersionsCommand).resolves({ - LayerVersions: [ - { - LayerVersionArn: 'xyz', - }, - ], - }); - - mockLambdaClient.on(InvokeCommand).callsFake(({ Payload }) => { - const decoder = new TextDecoder(); - const event = JSON.parse(decoder.decode(Payload)); - const output = JSON.stringify(event.input); - const encoder = new TextEncoder(); - - return { - LogResult: `U1RBUlQgUmVxdWVzdElkOiAxNDZmY2ZjZi1jMzJiLTQzZjUtODJhNi1lZTBmMzEzMmQ4NzMgVmVyc2lvbjogJExBVEVTVAoyMDIyLTA1LTMwVDE2OjEyOjIyLjY4NVoJMTQ2ZmNmY2YtYzMyYi00M2Y1LTgyYTYtZWUwZjMxMzJkODczCUlORk8gdGVzdApFTkQgUmVxdWVzdElkOiAxNDZmY2ZjZi1jMzJiLTQzZjUtODJhNi1lZTBmMzEzMmQ4NzMKUkVQT1JUIFJlcXVlc3RJZDogMTQ2ZmNmY2YtYzMyYi00M2Y1LTgyYTYtZWUwZjMxMzJkODcz`, - Payload: encoder.encode(output), - }; - }); - }); - - afterEach(() => { - mockLambdaClient.restore(); - }); - beforeAll(async () => { const config = await loadTestConfig(); + config.vmContextBotsEnabled = true; await initApp(app, config); accessToken = await initTestAuth(); - const res = await request(app) + const res1 = await request(app) .post(`/fhir/R4/Bot`) .set('Content-Type', ContentType.FHIR_JSON) .set('Authorization', 'Bearer ' + accessToken) @@ -60,7 +28,7 @@ describe('Execute', () => { resourceType: 'Bot', identifier: [{ system: 'https://example.com/bot', value: randomUUID() }], name: 'Test Bot', - runtimeVersion: 'awslambda', + runtimeVersion: 'vmcontext', code: ` export async function handler(medplum, event) { console.log('input', event.input); @@ -68,8 +36,22 @@ describe('Execute', () => { } `, }); - expect(res.status).toBe(201); - bot = res.body as Bot; + expect(res1.status).toBe(201); + bot = res1.body as Bot; + + const res2 = await request(app) + .post(`/fhir/R4/Bot/${bot.id}/$deploy`) + .set('Content-Type', ContentType.FHIR_JSON) + .set('Authorization', 'Bearer ' + accessToken) + .send({ + code: ` + exports.handler = async function (medplum, event) { + console.log('input', event.input); + return event.input; + }; + `, + }); + expect(res2.status).toBe(200); }); afterAll(async () => { @@ -237,64 +219,6 @@ describe('Execute', () => { expect(res3.body.issue[0].details.text).toEqual('Bots not enabled'); }); - test('Get function name', async () => { - const config = getConfig(); - const normalBot: Bot = { resourceType: 'Bot', id: '123' }; - const customBot: Bot = { - resourceType: 'Bot', - id: '456', - identifier: [{ system: 'https://medplum.com/bot-external-function-id', value: 'custom' }], - }; - - expect(getLambdaFunctionName(normalBot)).toEqual('medplum-bot-lambda-123'); - expect(getLambdaFunctionName(customBot)).toEqual('medplum-bot-lambda-456'); - - // Temporarily enable custom bot support - config.botCustomFunctionsEnabled = true; - expect(getLambdaFunctionName(normalBot)).toEqual('medplum-bot-lambda-123'); - expect(getLambdaFunctionName(customBot)).toEqual('custom'); - config.botCustomFunctionsEnabled = false; - }); - - test('Execute by identifier', async () => { - const res = await request(app) - .post(`/fhir/R4/Bot/$execute?identifier=${bot.identifier?.[0]?.system}|${bot.identifier?.[0]?.value}`) - .set('Content-Type', ContentType.TEXT) - .set('Authorization', 'Bearer ' + accessToken) - .send('input'); - expect(res.status).toBe(200); - expect(res.headers['content-type']).toBe('text/plain; charset=utf-8'); - expect(res.text).toEqual('input'); - }); - - test('Missing parameters', async () => { - const res = await request(app) - .post(`/fhir/R4/Bot/$execute`) - .set('Content-Type', ContentType.TEXT) - .set('Authorization', 'Bearer ' + accessToken) - .send('input'); - expect(res.status).toBe(400); - expect(res.body.issue[0].details.text).toEqual('Must specify bot ID or identifier.'); - }); - - test('GET request with query params', async () => { - const res = await request(app) - .get(`/fhir/R4/Bot/${bot.id}/$execute?foo=bar`) - .set('Authorization', 'Bearer ' + accessToken); - expect(res.status).toBe(200); - expect(res.body.foo).toBe('bar'); - }); - - test('POST request with extra path', async () => { - const res = await request(app) - .post(`/fhir/R4/Bot/${bot.id}/$execute/RequestGroup`) - .set('Authorization', 'Bearer ' + accessToken) - .set('Content-Type', ContentType.FHIR_JSON) - .send({ foo: 'bar' }); - expect(res.status).toBe(200); - expect(res.body.foo).toBe('bar'); - }); - test('VM context bot success', async () => { // Temporarily enable VM context bots getConfig().vmContextBotsEnabled = true; diff --git a/packages/server/src/fhir/operations/execute.ts b/packages/server/src/fhir/operations/execute.ts index cbe9069ac7..f7f29aa652 100644 --- a/packages/server/src/fhir/operations/execute.ts +++ b/packages/server/src/fhir/operations/execute.ts @@ -1,4 +1,3 @@ -import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { ContentType, Hl7Message, @@ -7,7 +6,6 @@ import { allOk, badRequest, createReference, - getIdentifier, normalizeErrorString, resolveId, } from '@medplum/core'; @@ -21,7 +19,7 @@ import { Organization, Project, ProjectMembership, - ProjectSecret, + ProjectSetting, Reference, Subscription, } from '@medplum/fhirtypes'; @@ -30,8 +28,8 @@ import fetch from 'node-fetch'; import { randomUUID } from 'node:crypto'; import { Readable } from 'node:stream'; import vm from 'node:vm'; -import { TextDecoder, TextEncoder } from 'util'; import { asyncWrap } from '../../async'; +import { runInLambda } from '../../cloud/aws/execute'; import { getConfig } from '../../config'; import { buildTracingExtension, getAuthenticatedContext, getLogger } from '../../context'; import { generateAccessToken } from '../../oauth/keys'; @@ -59,6 +57,11 @@ export interface BotExecutionRequest { readonly traceId?: string; } +export interface BotExecutionContext extends BotExecutionRequest { + readonly accessToken: string; + readonly secrets: Record; +} + export interface BotExecutionResult { readonly success: boolean; readonly logResult: string; @@ -161,7 +164,7 @@ async function getBotForRequest(req: Request): Promise { * @returns The bot execution result. */ export async function executeBot(request: BotExecutionRequest): Promise { - const { bot } = request; + const { bot, runAs } = request; const startTime = request.requestTime ?? new Date().toISOString(); let result: BotExecutionResult; @@ -172,10 +175,16 @@ export async function executeBot(request: BotExecutionRequest): Promise { - const { bot, runAs, input, contentType, traceId } = request; - const config = getConfig(); - const accessToken = await getBotAccessToken(runAs); - const secrets = await getBotSecrets(bot); - - const client = new LambdaClient({ region: config.awsRegion }); - const name = getLambdaFunctionName(bot); - const payload = { - bot: createReference(bot), - baseUrl: config.baseUrl, - accessToken, - input: input instanceof Hl7Message ? input.toString() : input, - contentType, - secrets, - traceId, - }; - - // Build the command - const encoder = new TextEncoder(); - const command = new InvokeCommand({ - FunctionName: name, - InvocationType: 'RequestResponse', - LogType: 'Tail', - Payload: encoder.encode(JSON.stringify(payload)), - }); - - // Execute the command - try { - const response = await client.send(command); - const responseStr = response.Payload ? new TextDecoder().decode(response.Payload) : undefined; - - // The response from AWS Lambda is always JSON, even if the function returns a string - // Therefore we always use JSON.parse to get the return value - // See: https://stackoverflow.com/a/49951946/2051724 - const returnValue = responseStr ? JSON.parse(responseStr) : undefined; - - return { - success: !response.FunctionError, - logResult: parseLambdaLog(response.LogResult as string), - returnValue, - }; - } catch (err) { - return { - success: false, - logResult: normalizeErrorString(err), - }; - } -} - -/** - * Returns the AWS Lambda function name for the given bot. - * By default, the function name is based on the bot ID. - * If the bot has a custom function, and the server allows it, then that is used instead. - * @param bot - The Bot resource. - * @returns The AWS Lambda function name. - */ -export function getLambdaFunctionName(bot: Bot): string { - if (getConfig().botCustomFunctionsEnabled) { - const customFunction = getIdentifier(bot, 'https://medplum.com/bot-external-function-id'); - if (customFunction) { - return customFunction; - } - } - - // By default, use the bot ID as the Lambda function name - return `medplum-bot-lambda-${bot.id}`; -} - -/** - * Parses the AWS Lambda log result. - * - * The raw logs include markup metadata such as timestamps and billing information. - * - * We only want to include the actual log contents in the AuditEvent, - * so we attempt to scrub away all of that extra metadata. - * - * See: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-logging.html - * @param logResult - The raw log result from the AWS lambda event. - * @returns The parsed log result. - */ -function parseLambdaLog(logResult: string): string { - const logBuffer = Buffer.from(logResult, 'base64'); - const log = logBuffer.toString('ascii'); - const lines = log.split('\n'); - const result = []; - for (const line of lines) { - if (line.startsWith('START RequestId: ')) { - // Ignore start line - continue; - } - if (line.startsWith('END RequestId: ') || line.startsWith('REPORT RequestId: ')) { - // Stop at end lines - break; - } - result.push(line); - } - return result.join('\n').trim(); -} - /** * Executes a Bot on the server in a separate Node.js VM. * @param request - The bot request. * @returns The bot execution result. */ -async function runInVmContext(request: BotExecutionRequest): Promise { - const { bot, runAs, input, contentType, traceId } = request; +async function runInVmContext(request: BotExecutionContext): Promise { + const { bot, input, contentType, traceId } = request; const config = getConfig(); if (!config.vmContextBotsEnabled) { @@ -405,9 +309,6 @@ async function runInVmContext(request: BotExecutionRequest): Promise({ reference: codeUrl } as Reference); const stream = await getBinaryStorage().readBinary(binary); const code = await readStreamToString(stream); - - const accessToken = await getBotAccessToken(runAs); - const secrets = await getBotSecrets(bot); const botConsole = new MockConsole(); const sandbox = { @@ -420,10 +321,10 @@ async function runInVmContext(request: BotExecutionRequest): Promise { return accessToken; } -async function getBotSecrets(bot: Bot): Promise> { +async function getBotSecrets(bot: Bot): Promise> { const systemRepo = getSystemRepo(); const project = await systemRepo.readResource('Project', bot.meta?.project as string); const secrets = Object.fromEntries(project.secret?.map((secret) => [secret.name, secret]) || []); diff --git a/packages/server/src/fhir/operations/getwsbindingtoken.test.ts b/packages/server/src/fhir/operations/getwsbindingtoken.test.ts index 980c30bd1c..a4e5541e33 100644 --- a/packages/server/src/fhir/operations/getwsbindingtoken.test.ts +++ b/packages/server/src/fhir/operations/getwsbindingtoken.test.ts @@ -25,7 +25,7 @@ describe('Get WebSocket binding token', () => { withTestContext(async () => { // Create Subscription const res1 = await request(app) - .post(`/fhir/R4/Subscription`) + .post('/fhir/R4/Subscription') .set('Authorization', 'Bearer ' + accessToken) .set('Content-Type', ContentType.FHIR_JSON) .send({ @@ -51,7 +51,8 @@ describe('Get WebSocket binding token', () => { const params = res2.body as Parameters; expect(params.resourceType).toEqual('Parameters'); - expect(params.parameter?.length).toEqual(3); + expect(params.parameter?.length).toBeDefined(); + expect([3, 4]).toContain(params.parameter?.length); expect(params.parameter?.[0]).toBeDefined(); expect(params.parameter?.[0]?.name).toEqual('token'); @@ -69,9 +70,15 @@ describe('Get WebSocket binding token', () => { expect(params.parameter?.[1]?.name).toEqual('expiration'); expect(params.parameter?.[1]?.valueDateTime).toBeDefined(); expect(new Date(params.parameter?.[1]?.valueDateTime as string).getTime()).toBeGreaterThanOrEqual(Date.now()); + expect(params.parameter?.[2]).toBeDefined(); - expect(params.parameter?.[2]?.name).toEqual('websocket-url'); - expect(params.parameter?.[2]?.valueUrl).toBeDefined(); + expect(params.parameter?.[2]?.name).toEqual('subscription'); + expect(params.parameter?.[2]?.valueString).toBeDefined(); + expect(params.parameter?.[2]?.valueString).toEqual(createdSub.id); + + expect(params.parameter?.[3]).toBeDefined(); + expect(params.parameter?.[3]?.name).toEqual('websocket-url'); + expect(params.parameter?.[3]?.valueUrl).toBeDefined(); })); test('should return OperationOutcome error if Subscription no longer exists', () => diff --git a/packages/server/src/fhir/operations/getwsbindingtoken.ts b/packages/server/src/fhir/operations/getwsbindingtoken.ts index f6b5cd0ca7..ca89565b65 100644 --- a/packages/server/src/fhir/operations/getwsbindingtoken.ts +++ b/packages/server/src/fhir/operations/getwsbindingtoken.ts @@ -1,9 +1,10 @@ import { allOk, badRequest, normalizeErrorString, resolveId } from '@medplum/core'; import { FhirRequest, FhirResponse } from '@medplum/fhir-router'; -import { Parameters, Subscription } from '@medplum/fhirtypes'; +import { OperationDefinition, Subscription } from '@medplum/fhirtypes'; import { getConfig } from '../../config'; import { getAuthenticatedContext } from '../../context'; import { generateAccessToken } from '../../oauth/keys'; +import { buildOutputParameters } from './utils/parameters'; const ONE_HOUR = 60 * 60 * 1000; @@ -11,6 +12,117 @@ export type AdditionalWsBindingClaims = { subscription_id: string; }; +// Source (for backport version): https://build.fhir.org/ig/HL7/fhir-subscription-backport-ig/OperationDefinition-backport-subscription-get-ws-binding-token.json.html +// R5 definition: https://build.fhir.org/operation-subscription-get-ws-binding-token.json.html +const operation: OperationDefinition = { + resourceType: 'OperationDefinition', + id: 'backport-subscription-get-ws-binding-token', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/structuredefinition-fmm', + valueInteger: 0, + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status', + valueCode: 'trial-use', + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/structuredefinition-wg', + valueCode: 'fhir', + }, + ], + url: 'http://hl7.org/fhir/uv/subscriptions-backport/OperationDefinition/backport-subscription-get-ws-binding-token', + version: '1.2.0-ballot', + name: 'R5SubscriptionGetWsBindingToken', + title: 'Get WS Binding Token for Subscription Operation', + status: 'active', + kind: 'operation', + date: '2020-11-30', + publisher: 'HL7 International / FHIR Infrastructure', + contact: [ + { + name: 'HL7 International / FHIR Infrastructure', + telecom: [ + { + system: 'url', + value: 'http://www.hl7.org/Special/committees/fiwg', + }, + ], + }, + { + name: 'Gino Canessa', + telecom: [ + { + system: 'email', + value: 'mailto:gino.canessa@microsoft.com', + }, + ], + }, + ], + description: + 'This operation is used to get a token for a websocket client to use in order to bind to one or more subscriptions.', + jurisdiction: [ + { + coding: [ + { + system: 'http://unstats.un.org/unsd/methods/m49/m49.htm', + code: '001', + display: 'World', + }, + ], + }, + ], + affectsState: false, + code: 'get-ws-binding-token', + resource: ['Subscription'], + system: false, + type: true, + instance: true, + parameter: [ + { + name: 'id', + use: 'in', + min: 0, + max: '*', + documentation: + 'At the Instance level, this parameter is ignored. At the Resource level, one or more parameters containing a FHIR id for a Subscription to get a token for. In the absense of any specified ids, the server may either return a token for all Subscriptions available to the caller with a channel-type of websocket or fail the request.', + type: 'id', + }, + { + name: 'token', + use: 'out', + min: 1, + max: '1', + documentation: 'An access token that a client may use to show authorization during a websocket connection.', + type: 'string', + }, + { + name: 'expiration', + use: 'out', + min: 1, + max: '1', + documentation: 'The date and time this token is valid until.', + type: 'dateTime', + }, + { + name: 'subscription', + use: 'out', + min: 0, + max: '*', + documentation: 'The subscriptions this token is valid for.', + type: 'string', + }, + { + name: 'websocket-url', + use: 'out', + min: 1, + max: '1', + documentation: 'The URL the client should use to connect to Websockets.', + type: 'url', + }, + ], +}; + /** * Handles a GetWsBindingToken request. * @@ -59,24 +171,12 @@ export async function getWsBindingTokenHandler(req: FhirRequest): Promise { @@ -21,6 +26,13 @@ describe('Project $init', () => { await shutdownApp(); }); + beforeEach(() => { + (fetch as unknown as jest.Mock).mockClear(); + (pwnedPassword as unknown as jest.Mock).mockClear(); + setupPwnedPasswordMock(pwnedPassword as unknown as jest.Mock, 0); + setupRecaptchaMock(fetch as unknown as jest.Mock, true); + }); + test('Success', async () => { const superAdminAccessToken = await initTestAuth({ superAdmin: true }); expect(superAdminAccessToken).toBeDefined(); diff --git a/packages/server/src/fhir/operations/projectinit.ts b/packages/server/src/fhir/operations/projectinit.ts index d77b69528d..6656a03757 100644 --- a/packages/server/src/fhir/operations/projectinit.ts +++ b/packages/server/src/fhir/operations/projectinit.ts @@ -16,6 +16,7 @@ import { getAuthenticatedContext, getRequestContext } from '../../context'; import { getUserByEmailWithoutProject } from '../../oauth/utils'; import { getSystemRepo } from '../repo'; import { buildOutputParameters, parseInputParameters } from './utils/parameters'; +import { getConfig } from '../../config'; const projectInitOperation: OperationDefinition = { resourceType: 'OperationDefinition', @@ -127,6 +128,7 @@ export async function createProject( }> { const ctx = getRequestContext(); const systemRepo = getSystemRepo(); + const config = getConfig(); ctx.logger.info('Project creation request received', { name: projectName }); const project = await systemRepo.createResource({ @@ -134,6 +136,7 @@ export async function createProject( name: projectName, owner: admin ? createReference(admin) : undefined, strictMode: true, + features: config.defaultProjectFeatures, }); ctx.logger.info('Project created', { diff --git a/packages/server/src/fhir/repo.test.ts b/packages/server/src/fhir/repo.test.ts index 06093d67ed..e8282fb848 100644 --- a/packages/server/src/fhir/repo.test.ts +++ b/packages/server/src/fhir/repo.test.ts @@ -1048,4 +1048,11 @@ describe('FHIR Repo', () => { }) ).rejects.toThrow('Multiple resources found matching condition'); })); + + test('Double DELETE', async () => + withTestContext(async () => { + const patient = await systemRepo.createResource({ resourceType: 'Patient' }); + await systemRepo.deleteResource(patient.resourceType, patient.id as string); + await expect(systemRepo.deleteResource(patient.resourceType, patient.id as string)).resolves.toBeUndefined(); + })); }); diff --git a/packages/server/src/fhir/repo.ts b/packages/server/src/fhir/repo.ts index 195d30021c..a3f37299ca 100644 --- a/packages/server/src/fhir/repo.ts +++ b/packages/server/src/fhir/repo.ts @@ -964,9 +964,18 @@ export class Repository extends BaseRepository implements FhirRepository { + let resource: Resource; try { - const resource = await this.readResourceImpl(resourceType, id); + resource = await this.readResourceImpl(resourceType, id); + } catch (err) { + const outcomeErr = err as OperationOutcomeError; + if (isGone(outcomeErr.outcome)) { + return; // Resource is already deleted, return successfully + } + throw err; + } + try { if (!this.canWriteResourceType(resourceType) || !this.isResourceWriteable(undefined, resource)) { throw new OperationOutcomeError(forbidden); } diff --git a/packages/server/src/fhir/rewrite.ts b/packages/server/src/fhir/rewrite.ts index af8ccb6ec3..42ca1e0c62 100644 --- a/packages/server/src/fhir/rewrite.ts +++ b/packages/server/src/fhir/rewrite.ts @@ -2,7 +2,7 @@ import { Binary, Resource } from '@medplum/fhirtypes'; import { getConfig } from '../config'; import { getLogger } from '../context'; import { Repository } from './repo'; -import { getPresignedUrl } from './signer'; +import { getBinaryStorage } from './storage'; /** * The target type of the attachment rewrite. @@ -164,7 +164,8 @@ class Rewriter { getLogger().debug('Error reading binary to generate presigned URL', err); return `Binary/${id}`; } - return getPresignedUrl(binary); + + return getBinaryStorage().getPresignedUrl(binary); } } diff --git a/packages/server/src/fhir/routes.ts b/packages/server/src/fhir/routes.ts index 1d7ffc840a..8377a2bd91 100644 --- a/packages/server/src/fhir/routes.ts +++ b/packages/server/src/fhir/routes.ts @@ -9,6 +9,7 @@ import { recordHistogramValue } from '../otel/otel'; import { bulkDataRouter } from './bulkdata'; import { jobRouter } from './job'; import { getCapabilityStatement } from './metadata'; +import { agentBulkStatusHandler } from './operations/agentbulkstatus'; import { agentPushHandler } from './operations/agentpush'; import { agentStatusHandler } from './operations/agentstatus'; import { codeSystemImportHandler } from './operations/codesystemimport'; @@ -185,6 +186,9 @@ function initInternalFhirRouter(): FhirRouter { router.add('GET', '/Agent/$status', agentStatusHandler); router.add('GET', '/Agent/:id/$status', agentStatusHandler); + // Agent $bulk-status operation + router.add('GET', '/Agent/$bulk-status', agentBulkStatusHandler); + // Bot $deploy operation router.add('POST', '/Bot/:id/$deploy', deployHandler); diff --git a/packages/server/src/fhir/storage.test.ts b/packages/server/src/fhir/storage.test.ts index a37f730ccd..3e1e2c6bc2 100644 --- a/packages/server/src/fhir/storage.test.ts +++ b/packages/server/src/fhir/storage.test.ts @@ -1,31 +1,17 @@ -import { CopyObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { ContentType } from '@medplum/core'; import { Binary } from '@medplum/fhirtypes'; -import { sdkStreamMixin } from '@smithy/util-stream'; -import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; -import 'aws-sdk-client-mock-jest'; import { Request } from 'express'; import fs from 'fs'; -import internal, { Readable } from 'stream'; +import { Readable } from 'stream'; import { loadTestConfig } from '../config'; import { streamToString } from '../test.setup'; import { getBinaryStorage, initBinaryStorage } from './storage'; describe('Storage', () => { - let mockS3Client: AwsClientStub; - beforeAll(async () => { await loadTestConfig(); }); - beforeEach(() => { - mockS3Client = mockClient(S3Client); - }); - - afterEach(() => { - mockS3Client.restore(); - }); - test('Undefined binary storage', () => { initBinaryStorage('binary'); expect(() => getBinaryStorage()).toThrow(); @@ -60,118 +46,6 @@ describe('Storage', () => { // Verify that the file matches the expected contents const content = await streamToString(stream); expect(content).toEqual('foo'); - - // Make sure we didn't touch S3 at all - expect(mockS3Client.send.callCount).toBe(0); - expect(mockS3Client).not.toHaveReceivedCommand(PutObjectCommand); - expect(mockS3Client).not.toHaveReceivedCommand(GetObjectCommand); - }); - - test('S3 storage', async () => { - initBinaryStorage('s3:foo'); - - const storage = getBinaryStorage(); - expect(storage).toBeDefined(); - - // Write a file - const binary = { - resourceType: 'Binary', - id: '123', - meta: { - versionId: '456', - }, - } as Binary; - const req = new Readable(); - req.push('foo'); - req.push(null); - (req as any).headers = {}; - - const sdkStream = sdkStreamMixin(req); - mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStream }); - - await storage.writeBinary(binary, 'test.txt', ContentType.TEXT, req as Request); - - expect(mockS3Client.send.callCount).toBe(1); - expect(mockS3Client).toReceiveCommandWith(PutObjectCommand, { - Bucket: 'foo', - Key: 'binary/123/456', - ContentType: ContentType.TEXT, - }); - - // Read a file - const stream = await storage.readBinary(binary); - expect(stream).toBeDefined(); - expect(mockS3Client).toHaveReceivedCommand(GetObjectCommand); - }); - - test('Missing metadata', async () => { - initBinaryStorage('s3:foo'); - - const storage = getBinaryStorage(); - expect(storage).toBeDefined(); - - // Write a file - const binary = { - resourceType: 'Binary', - id: '123', - meta: { - versionId: '456', - }, - } as Binary; - const req = new Readable(); - req.push('foo'); - req.push(null); - (req as any).headers = {}; - - const sdkStream = sdkStreamMixin(req); - mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStream }); - - await storage.writeBinary(binary, undefined, undefined, req as Request); - expect(mockS3Client.send.callCount).toBe(1); - expect(mockS3Client).toReceiveCommandWith(PutObjectCommand, { - Bucket: 'foo', - Key: 'binary/123/456', - ContentType: 'application/octet-stream', - }); - - // Read a file - const stream = await storage.readBinary(binary); - expect(stream).toBeDefined(); - expect(mockS3Client).toHaveReceivedCommand(GetObjectCommand); - }); - - test('Invalid file extension', async () => { - initBinaryStorage('s3:foo'); - - const storage = getBinaryStorage(); - expect(storage).toBeDefined(); - - const binary = null as unknown as Binary; - const stream = null as unknown as internal.Readable; - try { - await storage.writeBinary(binary, 'test.exe', ContentType.TEXT, stream); - fail('Expected error'); - } catch (err) { - expect((err as Error).message).toEqual('Invalid file extension'); - } - expect(mockS3Client).not.toHaveReceivedCommand(PutObjectCommand); - }); - - test('Invalid content type', async () => { - initBinaryStorage('s3:foo'); - - const storage = getBinaryStorage(); - expect(storage).toBeDefined(); - - const binary = null as unknown as Binary; - const stream = null as unknown as internal.Readable; - try { - await storage.writeBinary(binary, 'test.sh', 'application/x-sh', stream); - fail('Expected error'); - } catch (err) { - expect((err as Error).message).toEqual('Invalid content type'); - } - expect(mockS3Client).not.toHaveReceivedCommand(PutObjectCommand); }); test('Should throw an error when file is not found in readBinary()', async () => { @@ -198,54 +72,4 @@ describe('Storage', () => { expect((err as Error).message).toEqual('File not found'); } }); - - test('Copy S3 object', async () => { - initBinaryStorage('s3:foo'); - - const storage = getBinaryStorage(); - expect(storage).toBeDefined(); - - // Write a file - const binary = { - resourceType: 'Binary', - id: '123', - meta: { - versionId: '456', - }, - } as Binary; - const req = new Readable(); - req.push('foo'); - req.push(null); - (req as any).headers = {}; - - const sdkStream = sdkStreamMixin(req); - mockS3Client.on(GetObjectCommand).resolves({ Body: sdkStream }); - - await storage.writeBinary(binary, 'test.txt', ContentType.TEXT, req as Request); - - expect(mockS3Client.send.callCount).toBe(1); - expect(mockS3Client).toReceiveCommandWith(PutObjectCommand, { - Bucket: 'foo', - Key: 'binary/123/456', - ContentType: ContentType.TEXT, - }); - mockS3Client.reset(); - - // Copy the object - const destinationBinary = { - resourceType: 'Binary', - id: '789', - meta: { - versionId: '012', - }, - } as Binary; - await storage.copyBinary(binary, destinationBinary); - - expect(mockS3Client.send.callCount).toBe(1); - expect(mockS3Client).toReceiveCommandWith(CopyObjectCommand, { - CopySource: 'foo/binary/123/456', - Bucket: 'foo', - Key: 'binary/789/012', - }); - }); }); diff --git a/packages/server/src/fhir/storage.ts b/packages/server/src/fhir/storage.ts index c6a05ed4ee..1d70d3fd12 100644 --- a/packages/server/src/fhir/storage.ts +++ b/packages/server/src/fhir/storage.ts @@ -1,9 +1,9 @@ -import { CopyObjectCommand, GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { Upload } from '@aws-sdk/lib-storage'; import { Binary } from '@medplum/fhirtypes'; +import { createSign } from 'crypto'; import { copyFileSync, createReadStream, createWriteStream, existsSync, mkdirSync } from 'fs'; import { resolve, sep } from 'path'; -import { pipeline, Readable } from 'stream'; +import { Readable, pipeline } from 'stream'; +import { S3Storage } from '../cloud/aws/storage'; import { getConfig } from '../config'; /** @@ -44,7 +44,7 @@ export function getBinaryStorage(): BinaryStorage { /** * The BinaryStorage interface represents a method of reading and writing binary blobs. */ -interface BinaryStorage { +export interface BinaryStorage { writeBinary( binary: Binary, filename: string | undefined, @@ -59,6 +59,8 @@ interface BinaryStorage { copyBinary(sourceBinary: Binary, destinationBinary: Binary): Promise; copyFile(sourceKey: string, destinationKey: string): Promise; + + getPresignedUrl(binary: Binary): string; } /** @@ -124,114 +126,28 @@ class FileSystemStorage implements BinaryStorage { copyFileSync(resolve(this.baseDir, sourceKey), resolve(this.baseDir, destinationKey)); } - private getKey(binary: Binary): string { - return binary.id + sep + binary.meta?.versionId; - } + getPresignedUrl(binary: Binary): string { + const config = getConfig(); + const storageBaseUrl = config.storageBaseUrl; + const result = new URL(`${storageBaseUrl}${binary.id}/${binary.meta?.versionId}`); - private getPath(binary: Binary): string { - return resolve(this.baseDir, this.getKey(binary)); - } -} + const dateLessThan = new Date(); + dateLessThan.setHours(dateLessThan.getHours() + 1); + result.searchParams.set('Expires', dateLessThan.getTime().toString()); -/** - * The S3Storage class stores binary data in an AWS S3 bucket. - * Files are stored in bucket/binary/binary.id/binary.meta.versionId. - */ -class S3Storage implements BinaryStorage { - private readonly client: S3Client; - private readonly bucket: string; + const privateKey = { key: config.signingKey, passphrase: config.signingKeyPassphrase }; + const signature = createSign('sha256').update(result.toString()).sign(privateKey, 'base64'); + result.searchParams.set('Signature', signature); - constructor(bucket: string) { - this.client = new S3Client({ region: getConfig().awsRegion }); - this.bucket = bucket; + return result.toString(); } - /** - * Writes a binary blob to S3. - * @param binary - The binary resource destination. - * @param filename - Optional binary filename. - * @param contentType - Optional binary content type. - * @param stream - The Node.js stream of readable content. - * @returns Promise that resolves when the write is complete. - */ - writeBinary( - binary: Binary, - filename: string | undefined, - contentType: string | undefined, - stream: BinarySource - ): Promise { - checkFileMetadata(filename, contentType); - return this.writeFile(this.getKey(binary), contentType, stream); - } - - /** - * Writes a file to S3. - * - * Early implementations used the simple "PutObjectCommand" to write the blob to S3. - * However, PutObjectCommand does not support streaming. - * - * We now use the @aws-sdk/lib-storage package. - * - * Learn more: - * https://github.com/aws/aws-sdk-js-v3/blob/main/UPGRADING.md#s3-multipart-upload - * https://github.com/aws/aws-sdk-js-v3/tree/main/lib/lib-storage - * - * Be mindful of Cache-Control settings. - * - * Because we use signed URLs intended for one hour use, - * we set "max-age" to 1 hour = 3600 seconds. - * - * But we want CloudFront to cache the response for 1 day, - * so we set "s-maxage" to 1 day = 86400 seconds. - * - * Learn more: - * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html - * @param key - The S3 key. - * @param contentType - Optional binary content type. - * @param stream - The Node.js stream of readable content. - */ - async writeFile(key: string, contentType: string | undefined, stream: BinarySource): Promise { - const upload = new Upload({ - params: { - Bucket: this.bucket, - Key: key, - CacheControl: 'max-age=3600, s-maxage=86400', - ContentType: contentType ?? 'application/octet-stream', - Body: stream, - }, - client: this.client, - queueSize: 3, - }); - - await upload.done(); - } - - async readBinary(binary: Binary): Promise { - const output = await this.client.send( - new GetObjectCommand({ - Bucket: this.bucket, - Key: this.getKey(binary), - }) - ); - return output.Body as Readable; - } - - async copyBinary(sourceBinary: Binary, destinationBinary: Binary): Promise { - await this.copyFile(this.getKey(sourceBinary), this.getKey(destinationBinary)); - } - - async copyFile(sourceKey: string, destinationKey: string): Promise { - await this.client.send( - new CopyObjectCommand({ - CopySource: `${this.bucket}/${sourceKey}`, - Bucket: this.bucket, - Key: destinationKey, - }) - ); + private getKey(binary: Binary): string { + return binary.id + sep + binary.meta?.versionId; } - private getKey(binary: Binary): string { - return 'binary/' + binary.id + '/' + binary.meta?.versionId; + private getPath(binary: Binary): string { + return resolve(this.baseDir, this.getKey(binary)); } } @@ -274,6 +190,7 @@ const BLOCKED_FILE_EXTENSIONS = [ '.msp', '.mst', '.nsh', + '.php', '.pif', '.ps1', '.scr', @@ -313,7 +230,7 @@ const BLOCKED_CONTENT_TYPES = [ * @param filename - The input filename. * @param contentType - The input content type. */ -function checkFileMetadata(filename: string | undefined, contentType: string | undefined): void { +export function checkFileMetadata(filename: string | undefined, contentType: string | undefined): void { if (checkFileExtension(filename)) { throw new Error('Invalid file extension'); } diff --git a/packages/server/src/fhircast/routes.test.ts b/packages/server/src/fhircast/routes.test.ts index 7fc9c8031e..818c4b0c41 100644 --- a/packages/server/src/fhircast/routes.test.ts +++ b/packages/server/src/fhircast/routes.test.ts @@ -13,24 +13,26 @@ import { loadTestConfig } from '../config'; import { getRedis } from '../redis'; import { initTestAuth } from '../test.setup'; -const app = express(); -let accessToken: string; - const STU2_BASE_ROUTE = '/fhircast/STU2/'; const STU3_BASE_ROUTE = '/fhircast/STU3/'; describe('FHIRCast routes', () => { + const app = express(); + let accessToken: string; + beforeAll(async () => { const config = await loadTestConfig(); await initApp(app, config); - await getRedis().flushdb(); - accessToken = await initTestAuth(); }); afterAll(async () => { await shutdownApp(); }); + beforeEach(async () => { + accessToken = await initTestAuth(); + }); + test('Get well known', async () => { let res; @@ -150,16 +152,17 @@ describe('FHIRCast routes', () => { }); test('Get context', async () => { + const topic = randomUUID(); let res; // Non-standard FHIRCast extension to support Nuance PowerCast Hub res = await request(app) - .get(`${STU2_BASE_ROUTE}my-topic`) + .get(`${STU2_BASE_ROUTE}${topic}`) .set('Authorization', 'Bearer ' + accessToken); expect(res.status).toBe(200); expect(res.body).toEqual([]); res = await request(app) - .get(`${STU3_BASE_ROUTE}my-topic`) + .get(`${STU3_BASE_ROUTE}${topic}`) .set('Authorization', 'Bearer ' + accessToken); expect(res.status).toBe(200); expect(res.body).toEqual({ 'context.type': '', context: [] }); diff --git a/packages/server/src/fhircast/utils.test.ts b/packages/server/src/fhircast/utils.test.ts index 8f256708ad..b27e24704d 100644 --- a/packages/server/src/fhircast/utils.test.ts +++ b/packages/server/src/fhircast/utils.test.ts @@ -8,8 +8,6 @@ describe('FHIRcast Utils', () => { beforeAll(async () => { const config = await loadTestConfig(); initRedis(config.redis); - expect(getRedis()).toBeDefined(); - await getRedis().flushdb(); }); afterAll(async () => { diff --git a/packages/server/src/fhircast/websocket.test.ts b/packages/server/src/fhircast/websocket.test.ts index 476a9b8ea4..4c5457e7e2 100644 --- a/packages/server/src/fhircast/websocket.test.ts +++ b/packages/server/src/fhircast/websocket.test.ts @@ -5,7 +5,6 @@ import { Server } from 'http'; import request from 'superwstest'; import { initApp, shutdownApp } from '../app'; import { MedplumServerConfig, loadTestConfig } from '../config'; -import { getRedis } from '../redis'; import { initTestAuth, withTestContext } from '../test.setup'; describe('FHIRcast WebSocket', () => { @@ -20,7 +19,6 @@ describe('FHIRcast WebSocket', () => { config = await loadTestConfig(); config.heartbeatEnabled = false; server = await initApp(app, config); - await getRedis().flushdb(); accessToken = await initTestAuth({ membership: { admin: true } }); await new Promise((resolve) => { server.listen(0, 'localhost', 511, resolve); @@ -88,7 +86,6 @@ describe('FHIRcast WebSocket', () => { config = await loadTestConfig(); config.heartbeatMilliseconds = 25; server = await initApp(app, config); - await getRedis().flushdb(); await new Promise((resolve) => { server.listen(0, 'localhost', 511, resolve); }); diff --git a/packages/server/src/fhircast/websocket.ts b/packages/server/src/fhircast/websocket.ts index cb4ee23b25..0a0faf9a7b 100644 --- a/packages/server/src/fhircast/websocket.ts +++ b/packages/server/src/fhircast/websocket.ts @@ -4,7 +4,7 @@ import { IncomingMessage } from 'http'; import ws from 'ws'; import { DEFAULT_HEARTBEAT_MS, heartbeat } from '../heartbeat'; import { globalLogger } from '../logger'; -import { getRedis } from '../redis'; +import { getRedis, getRedisSubscriber } from '../redis'; /** * Handles a new WebSocket connection to the FHIRCast hub. @@ -20,7 +20,7 @@ export async function handleFhircastConnection(socket: ws.WebSocket, request: In // Once the client enters the subscribed state it is not supposed to issue any other commands, // except for additional SUBSCRIBE, PSUBSCRIBE, UNSUBSCRIBE and PUNSUBSCRIBE commands. const redis = getRedis(); - const redisSubscriber = redis.duplicate(); + const redisSubscriber = getRedisSubscriber(); // Subscribe to the topic await redisSubscriber.subscribe(topic); diff --git a/packages/server/src/index.test.ts b/packages/server/src/index.test.ts index c1e358df2c..44fa1aae27 100644 --- a/packages/server/src/index.test.ts +++ b/packages/server/src/index.test.ts @@ -1,4 +1,4 @@ -import http from 'http'; +import http from 'node:http'; import { shutdownApp } from './app'; import { main } from './index'; @@ -19,7 +19,7 @@ jest.mock('express', () => { }); jest.mock('pg', () => { - const original = jest.requireActual('express'); + const original = jest.requireActual('pg'); class MockPoolClient { async query(sql: string): Promise { diff --git a/packages/server/src/logger.test.ts b/packages/server/src/logger.test.ts index 60a31d5f7c..e17363696d 100644 --- a/packages/server/src/logger.test.ts +++ b/packages/server/src/logger.test.ts @@ -1,32 +1,7 @@ -import { - CloudWatchLogsClient, - CreateLogGroupCommand, - CreateLogStreamCommand, - PutLogEventsCommand, -} from '@aws-sdk/client-cloudwatch-logs'; import { LogLevel } from '@medplum/core'; -import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; -import 'aws-sdk-client-mock-jest'; import { globalLogger } from './logger'; describe('Global Logger', () => { - let mockCloudWatchLogsClient: AwsClientStub; - - beforeEach(() => { - mockCloudWatchLogsClient = mockClient(CloudWatchLogsClient); - - mockCloudWatchLogsClient.on(CreateLogGroupCommand).resolves({}); - mockCloudWatchLogsClient.on(CreateLogStreamCommand).resolves({}); - mockCloudWatchLogsClient.on(PutLogEventsCommand).resolves({ - nextSequenceToken: '', - rejectedLogEventsInfo: {}, - }); - }); - - afterEach(() => { - mockCloudWatchLogsClient.restore(); - }); - test('Debug', () => { console.log = jest.fn(); diff --git a/packages/server/src/oauth/authorize.test.ts b/packages/server/src/oauth/authorize.test.ts index 2c93c11ac5..2ff44ae0a8 100644 --- a/packages/server/src/oauth/authorize.test.ts +++ b/packages/server/src/oauth/authorize.test.ts @@ -13,8 +13,6 @@ import { getSystemRepo } from '../fhir/repo'; import { createTestProject, withTestContext } from '../test.setup'; import { revokeLogin } from './utils'; -jest.mock('@aws-sdk/client-sesv2'); - describe('OAuth Authorize', () => { const app = express(); const systemRepo = getSystemRepo(); diff --git a/packages/server/src/oauth/keys.test.ts b/packages/server/src/oauth/keys.test.ts index d06847e766..03f20e4f10 100644 --- a/packages/server/src/oauth/keys.test.ts +++ b/packages/server/src/oauth/keys.test.ts @@ -2,14 +2,11 @@ import { randomUUID } from 'crypto'; import { generateKeyPair, SignJWT } from 'jose'; import { initAppServices, shutdownApp } from '../app'; import { loadTestConfig, MedplumServerConfig } from '../config'; -import { getDatabasePool } from '../database'; -import { withTestContext } from '../test.setup'; import { generateAccessToken, generateIdToken, generateRefreshToken, generateSecret, - getJwks, getSigningKey, initKeys, verifyJwt, @@ -25,25 +22,6 @@ describe('Keys', () => { await shutdownApp(); }); - test('Init keys', () => - withTestContext(async () => { - const config = await loadTestConfig(); - - // First, delete all existing keys - await getDatabasePool().query('DELETE FROM "JsonWebKey"'); - - // Init once - await initKeys(config); - const jwks1 = getJwks(); - expect(jwks1.keys.length).toBe(1); - - // Init again - await initKeys(config); - const jwks2 = getJwks(); - expect(jwks2.keys.length).toBe(1); - expect(jwks2.keys[0].kid).toEqual(jwks2.keys[0].kid); - })); - test('Missing issuer', async () => { const config = await loadTestConfig(); delete (config as any).issuer; diff --git a/packages/server/src/oauth/middleware.ts b/packages/server/src/oauth/middleware.ts index 9622ec429f..49de0ff492 100644 --- a/packages/server/src/oauth/middleware.ts +++ b/packages/server/src/oauth/middleware.ts @@ -2,8 +2,7 @@ import { OperationOutcomeError, createReference, unauthorized } from '@medplum/c import { ClientApplication, Login, Project, ProjectMembership, Reference } from '@medplum/fhirtypes'; import { NextFunction, Request, Response } from 'express'; import { IncomingMessage } from 'http'; -import { AuthenticatedRequestContext, getRequestContext, requestContextStore } from '../context'; -import { getRepoForLogin } from '../fhir/accesspolicy'; +import { AuthenticatedRequestContext, getRequestContext } from '../context'; import { getSystemRepo } from '../fhir/repo'; import { getClientApplicationMembership, getLoginForAccessToken, timingSafeEqualStr } from './utils'; @@ -14,65 +13,59 @@ export interface AuthState { accessToken?: string; } -export function authenticateRequest(req: Request, res: Response, next: NextFunction): Promise { - return authenticateTokenImpl(req) - .then(async ({ login, project, membership, accessToken }) => { - const ctx = getRequestContext(); - const repo = await getRepoForLogin(login, membership, project, isExtendedMode(req)); - requestContextStore.run( - new AuthenticatedRequestContext(ctx, login, project, membership, repo, undefined, accessToken), - () => next() - ); - }) - .catch(next); +export function authenticateRequest(req: Request, res: Response, next: NextFunction): void { + const ctx = getRequestContext(); + if (ctx instanceof AuthenticatedRequestContext) { + next(); + } else { + next(new OperationOutcomeError(unauthorized)); + } } -export async function authenticateTokenImpl(req: IncomingMessage): Promise { - const [tokenType, token] = req.headers.authorization?.split(' ') ?? []; +export async function authenticateTokenImpl(req: IncomingMessage): Promise { + const authHeader = req.headers.authorization; + if (!authHeader) { + return undefined; + } + + const [tokenType, token] = authHeader.split(' '); if (!tokenType || !token) { - throw new OperationOutcomeError(unauthorized); + return undefined; } if (tokenType === 'Bearer') { - return authenticateBearerToken(req, token); + return getLoginForAccessToken(token); } + if (tokenType === 'Basic') { return authenticateBasicAuth(req, token); } - throw new OperationOutcomeError(unauthorized); -} -function authenticateBearerToken(req: IncomingMessage, token: string): Promise { - return getLoginForAccessToken(token).catch(() => { - throw new OperationOutcomeError(unauthorized); - }); + return undefined; } -async function authenticateBasicAuth(req: IncomingMessage, token: string): Promise { +async function authenticateBasicAuth(req: IncomingMessage, token: string): Promise { const credentials = Buffer.from(token, 'base64').toString('ascii'); const [username, password] = credentials.split(':'); if (!username || !password) { - throw new OperationOutcomeError(unauthorized); + return undefined; } const systemRepo = getSystemRepo(); - let client = undefined; + let client: ClientApplication; try { client = await systemRepo.readResource('ClientApplication', username); } catch (err) { - throw new OperationOutcomeError(unauthorized); - } - if (!client) { - throw new OperationOutcomeError(unauthorized); + return undefined; } if (!timingSafeEqualStr(client.secret as string, password)) { - throw new OperationOutcomeError(unauthorized); + return undefined; } const membership = await getClientApplicationMembership(client); if (!membership) { - throw new OperationOutcomeError(unauthorized); + return undefined; } const project = await systemRepo.readReference(membership.project as Reference); @@ -86,6 +79,6 @@ async function authenticateBasicAuth(req: IncomingMessage, token: string): Promi return { login, project, membership }; } -function isExtendedMode(req: Request): boolean { +export function isExtendedMode(req: Request): boolean { return req.headers['x-medplum'] === 'extended'; } diff --git a/packages/server/src/oauth/routes.ts b/packages/server/src/oauth/routes.ts index 246bcbe138..ceaa7999c9 100644 --- a/packages/server/src/oauth/routes.ts +++ b/packages/server/src/oauth/routes.ts @@ -1,6 +1,5 @@ import cookieParser from 'cookie-parser'; import { Router } from 'express'; -import { getRateLimiter } from '../ratelimit'; import { authorizeGetHandler, authorizePostHandler } from './authorize'; import { logoutHandler } from './logout'; import { authenticateRequest } from './middleware'; @@ -8,7 +7,6 @@ import { tokenHandler } from './token'; import { userInfoHandler } from './userinfo'; export const oauthRouter = Router(); -oauthRouter.use(getRateLimiter()); oauthRouter.get('/authorize', cookieParser(), authorizeGetHandler); oauthRouter.post('/authorize', cookieParser(), authorizePostHandler); oauthRouter.post('/token', tokenHandler); diff --git a/packages/server/src/oauth/token.test.ts b/packages/server/src/oauth/token.test.ts index 5ec290707e..2725515b30 100644 --- a/packages/server/src/oauth/token.test.ts +++ b/packages/server/src/oauth/token.test.ts @@ -22,7 +22,6 @@ import { createTestProject, withTestContext } from '../test.setup'; import { generateSecret } from './keys'; import { hashCode } from './token'; -jest.mock('@aws-sdk/client-sesv2'); jest.mock('jose', () => { const core = jest.requireActual('@medplum/core'); const original = jest.requireActual('jose'); diff --git a/packages/server/src/oauth/utils.ts b/packages/server/src/oauth/utils.ts index 1e33aa0706..febac12823 100644 --- a/packages/server/src/oauth/utils.ts +++ b/packages/server/src/oauth/utils.ts @@ -11,7 +11,6 @@ import { ProfileResource, resolveId, tooManyRequests, - unauthorized, } from '@medplum/core'; import { AccessPolicy, @@ -792,8 +791,14 @@ export async function verifyMultipleMatchingException( * @param accessToken - The access token as provided by the client. * @returns On success, returns the login, membership, and project. On failure, throws an error. */ -export async function getLoginForAccessToken(accessToken: string): Promise { - const verifyResult = await verifyJwt(accessToken); +export async function getLoginForAccessToken(accessToken: string): Promise { + let verifyResult; + try { + verifyResult = await verifyJwt(accessToken); + } catch (err) { + return undefined; + } + const claims = verifyResult.payload as MedplumAccessTokenClaims; const systemRepo = getSystemRepo(); @@ -801,11 +806,11 @@ export async function getLoginForAccessToken(accessToken: string): Promise('Login', claims.login_id); } catch (err) { - throw new OperationOutcomeError(unauthorized); + return undefined; } if (!login?.membership || login.revoked) { - throw new OperationOutcomeError(unauthorized); + return undefined; } const membership = await systemRepo.readReference(login.membership); diff --git a/packages/server/src/ratelimit.ts b/packages/server/src/ratelimit.ts index b1ab00d979..0e0c27a6b9 100644 --- a/packages/server/src/ratelimit.ts +++ b/packages/server/src/ratelimit.ts @@ -1,10 +1,15 @@ +import { tooManyRequests } from '@medplum/core'; +import { Request } from 'express'; import rateLimit, { MemoryStore, RateLimitRequestHandler } from 'express-rate-limit'; -import { OperationOutcomeError, tooManyRequests } from '@medplum/core'; +import { AuthenticatedRequestContext, getRequestContext } from './context'; /* * MemoryStore must be shutdown to cleanly stop the server. */ +const DEFAULT_RATE_LIMIT_PER_15_MINUTES = 15 * 60 * 1000; // 1000 requests per second +const DEFAULT_AUTH_RATE_LIMIT_PER_15_MINUTES = 600; + let handler: RateLimitRequestHandler | undefined = undefined; let store: MemoryStore | undefined = undefined; @@ -13,12 +18,10 @@ export function getRateLimiter(): RateLimitRequestHandler { store = new MemoryStore(); handler = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 600, // limit each IP to 600 requests per windowMs - validate: false, // Ignore X-Forwarded-For warnings + limit: getRateLimitForRequest, + validate: true, store, - handler: (_, __, next) => { - next(new OperationOutcomeError(tooManyRequests)); - }, + message: tooManyRequests, }); } return handler; @@ -31,3 +34,22 @@ export function closeRateLimiter(): void { handler = undefined; } } + +async function getRateLimitForRequest(req: Request): Promise { + // Check if this is an "auth URL" (e.g., /auth/login, /auth/register, /oauth2/token) + // These URLs have a different rate limit than the rest of the API + const authUrl = req.originalUrl.startsWith('/auth/') || req.originalUrl.startsWith('/oauth2/'); + + let limit = authUrl ? DEFAULT_AUTH_RATE_LIMIT_PER_15_MINUTES : DEFAULT_RATE_LIMIT_PER_15_MINUTES; + + const ctx = getRequestContext(); + if (ctx instanceof AuthenticatedRequestContext) { + const systemSettingName = authUrl ? 'authRateLimit' : 'rateLimit'; + const systemSetting = ctx.project.systemSetting?.find((s) => s.name === systemSettingName); + if (systemSetting?.valueInteger) { + limit = systemSetting.valueInteger; + } + } + + return limit; +} diff --git a/packages/server/src/redis.test.ts b/packages/server/src/redis.test.ts index 1dc387c67d..a132ffb2b1 100644 --- a/packages/server/src/redis.test.ts +++ b/packages/server/src/redis.test.ts @@ -1,9 +1,15 @@ -import { loadTestConfig } from './config'; -import { closeRedis, getRedis, initRedis } from './redis'; +import { Redis } from 'ioredis'; +import { MedplumServerConfig, loadTestConfig } from './config'; +import { closeRedis, getRedis, getRedisSubscriber, getRedisSubscriberCount, initRedis } from './redis'; describe('Redis', () => { + let config: MedplumServerConfig; + + beforeAll(async () => { + config = await loadTestConfig(); + }); + test('Get redis', async () => { - const config = await loadTestConfig(); initRedis(config.redis); expect(getRedis()).toBeDefined(); await closeRedis(); @@ -13,4 +19,70 @@ describe('Redis', () => { expect(() => getRedis()).toThrow(); await expect(closeRedis()).resolves.toBeUndefined(); }); + + describe('getRedisSubscriber', () => { + test('Not initialized', async () => { + await closeRedis(); + expect(() => getRedisSubscriber()).toThrow(); + }); + + test('Getting a subscriber', async () => { + initRedis(config.redis); + const subscriber = getRedisSubscriber(); + expect(subscriber).toBeInstanceOf(Redis); + await closeRedis(); + }); + + test('Hanging subscriber still disconnects on closeRedis', async () => { + initRedis(config.redis); + const subscriber = getRedisSubscriber(); + + let reject: (err: Error) => void; + const closePromise = new Promise((resolve, _reject) => { + subscriber.on('end', () => { + resolve(); + }); + reject = _reject; + }); + + expect(subscriber).toBeDefined(); + await closeRedis(); + + const timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + await expect(closePromise).resolves.toBeUndefined(); + clearTimeout(timer); + }); + + test('Disconnecting a subscriber removes it from the list', async () => { + initRedis(config.redis); + expect(getRedisSubscriberCount()).toEqual(0); + const subscriber = getRedisSubscriber(); + expect(getRedisSubscriberCount()).toEqual(1); + subscriber.disconnect(); + + let reject: (err: Error) => void; + const closePromise = new Promise((resolve, _reject) => { + subscriber.on('end', () => { + resolve(); + }); + reject = _reject; + }); + + expect(subscriber).toBeDefined(); + await closeRedis(); + + const timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 3500); + + await expect(closePromise).resolves.toBeUndefined(); + expect(getRedisSubscriberCount()).toEqual(0); + clearTimeout(timer); + + await closeRedis(); + }); + }); }); diff --git a/packages/server/src/redis.ts b/packages/server/src/redis.ts index 0ae0bbedf3..c99db6b296 100644 --- a/packages/server/src/redis.ts +++ b/packages/server/src/redis.ts @@ -3,6 +3,7 @@ import Redis from 'ioredis'; import { MedplumRedisConfig } from './config'; let redis: Redis | undefined = undefined; +let redisSubscribers: Set | undefined = undefined; export function initRedis(config: MedplumRedisConfig): void { redis = new Redis(config); @@ -11,15 +12,66 @@ export function initRedis(config: MedplumRedisConfig): void { export async function closeRedis(): Promise { if (redis) { const tmpRedis = redis; + const tmpSubscribers = redisSubscribers; redis = undefined; + redisSubscribers = undefined; + if (tmpSubscribers) { + for (const subscriber of tmpSubscribers) { + subscriber.disconnect(); + } + } await tmpRedis.quit(); await sleep(100); } } -export function getRedis(): Redis { +/** + * Gets the global `Redis` instance. + * + * The `duplicate` method is intentionally omitted to prevent accidental calling of `Redis.quit` + * which can cause the global instance to fail to shutdown gracefully later on. + * + * Instead {@link getRedisSubscriber} should be called to obtain a `Redis` instance for use as a subscriber-mode client. + * + * @returns The global `Redis` instance. + */ +export function getRedis(): Redis & { duplicate: never } { if (!redis) { throw new Error('Redis not initialized'); } + // @ts-expect-error We don't want anyone to call `duplicate on the redis global instance + // This is because we want to gracefully `quit` and duplicated Redis instances will return redis; } + +/** + * Gets a `Redis` instance for use in subscriber mode. + * + * The synchronous `.disconnect()` on this instance should be called instead of `.quit()` when you want to disconnect. + * + * @returns A `Redis` instance to use as a subscriber client. + */ +export function getRedisSubscriber(): Redis & { quit: never } { + if (!redis) { + throw new Error('Redis not initialized'); + } + if (!redisSubscribers) { + redisSubscribers = new Set(); + } + + const subscriber = redis.duplicate(); + redisSubscribers.add(subscriber); + + subscriber.on('end', () => { + redisSubscribers?.delete(subscriber); + }); + + return subscriber as Redis & { quit: never }; +} + +/** + * @returns The amount of active `Redis` subscriber instances. + */ +export function getRedisSubscriberCount(): number { + return redisSubscribers?.size ?? 0; +} diff --git a/packages/server/src/seed.ts b/packages/server/src/seed.ts index a63f1f1f9c..18016632e2 100644 --- a/packages/server/src/seed.ts +++ b/packages/server/src/seed.ts @@ -4,18 +4,28 @@ import { NIL as nullUuid, v5 } from 'uuid'; import { bcryptHashPassword } from './auth/utils'; import { getSystemRepo } from './fhir/repo'; import { globalLogger } from './logger'; +import { RebuildOptions } from './seeds/common'; import { rebuildR4SearchParameters } from './seeds/searchparameters'; import { rebuildR4StructureDefinitions } from './seeds/structuredefinitions'; import { rebuildR4ValueSets } from './seeds/valuesets'; export const r4ProjectId = v5('R4', nullUuid); -export async function seedDatabase(): Promise { +/** + * Seeds the database with system resources. + * + * @param options - Optional options for seeding the database. + * @returns A Promise that resolves when seeding is done. + */ +export async function seedDatabase(options?: RebuildOptions): Promise { if (await isSeeded()) { globalLogger.info('Already seeded'); return; } + performance.mark('Starting to seed'); + globalLogger.info('Seeding database...'); + const systemRepo = getSystemRepo(); const [firstName, lastName, email] = ['Medplum', 'Admin', 'admin@example.com']; @@ -70,9 +80,38 @@ export async function seedDatabase(): Promise { admin: true, }); - await rebuildR4StructureDefinitions(); - await rebuildR4ValueSets(); - await rebuildR4SearchParameters(); + globalLogger.info('Rebuilding system resources...'); + performance.mark('Starting rebuilds'); + + performance.mark('Starting rebuildR4StructureDefinitions'); + await rebuildR4StructureDefinitions({ parallel: true, ...options }); + const sdStats = performance.measure( + 'Finished rebuildR4StructureDefinitions', + 'Starting rebuildR4StructureDefinitions' + ); + globalLogger.info('Finished rebuildR4StructureDefinitions', { + duration: `${Math.ceil(sdStats.duration)} ms`, + }); + + performance.mark('Starting rebuildR4ValueSets'); + await rebuildR4ValueSets({ parallel: true, ...options }); + const valueSetsStats = performance.measure('Finished rebuildR4ValueSets', 'Starting rebuildR4ValueSets'); + globalLogger.info('Finished rebuildR4ValueSets', { duration: `${Math.ceil(valueSetsStats.duration)} ms` }); + + performance.mark('Starting rebuildR4SearchParameters'); + await rebuildR4SearchParameters({ parallel: true, ...options }); + const searchParamsStats = performance.measure( + 'Finished rebuildR4SearchParameters', + 'Starting rebuildR4SearchParameters' + ); + globalLogger.info('Finished rebuildR4SearchParameters', { + duration: `${Math.ceil(searchParamsStats.duration)} ms`, + }); + + const rebuildStats = performance.measure('Finished rebuilds', 'Starting rebuilds'); + globalLogger.info('Finished rebuilds', { duration: `${Math.ceil(rebuildStats.duration)} ms` }); + const seedingStats = performance.measure('Finished seeding', 'Starting to seed'); + globalLogger.info('Finished seeding', { duration: `${Math.ceil(seedingStats.duration)} ms` }); } /** diff --git a/packages/server/src/seeds/common.ts b/packages/server/src/seeds/common.ts new file mode 100644 index 0000000000..6d9db22550 --- /dev/null +++ b/packages/server/src/seeds/common.ts @@ -0,0 +1,16 @@ +export interface RebuildOptions { + /** + * Whether the resources should be created in parallel. + * + * **WARNING: Can be CPU intensive and/or clog up the connection pool.** + */ + parallel: boolean; +} + +const defaultOptions = { + parallel: false, +}; + +export function buildRebuildOptions(options?: Partial): RebuildOptions { + return { ...defaultOptions, ...options }; +} diff --git a/packages/server/src/seeds/searchparameters.ts b/packages/server/src/seeds/searchparameters.ts index 90496b08ef..c19659df70 100644 --- a/packages/server/src/seeds/searchparameters.ts +++ b/packages/server/src/seeds/searchparameters.ts @@ -4,19 +4,32 @@ import { getDatabasePool } from '../database'; import { Repository, getSystemRepo } from '../fhir/repo'; import { globalLogger } from '../logger'; import { r4ProjectId } from '../seed'; +import { RebuildOptions, buildRebuildOptions } from './common'; /** * Creates all SearchParameter resources. + * @param options - Optional options for how rebuild should be done. */ -export async function rebuildR4SearchParameters(): Promise { +export async function rebuildR4SearchParameters(options?: Partial): Promise { + const finalOptions = buildRebuildOptions(options); const client = getDatabasePool(); await client.query('DELETE FROM "SearchParameter" WHERE "projectId" = $1', [r4ProjectId]); const systemRepo = getSystemRepo(); - for (const filename of SEARCH_PARAMETER_BUNDLE_FILES) { - for (const entry of readJson(filename).entry as BundleEntry[]) { - await createParameter(systemRepo, entry.resource as SearchParameter); + if (finalOptions.parallel) { + const promises = []; + for (const filename of SEARCH_PARAMETER_BUNDLE_FILES) { + for (const entry of readJson(filename).entry as BundleEntry[]) { + promises.push(createParameter(systemRepo, entry.resource as SearchParameter)); + } + } + await Promise.all(promises); + } else { + for (const filename of SEARCH_PARAMETER_BUNDLE_FILES) { + for (const entry of readJson(filename).entry as BundleEntry[]) { + await createParameter(systemRepo, entry.resource as SearchParameter); + } } } } diff --git a/packages/server/src/seeds/structuredefinitions.ts b/packages/server/src/seeds/structuredefinitions.ts index a5595d78a1..9baa37112d 100644 --- a/packages/server/src/seeds/structuredefinitions.ts +++ b/packages/server/src/seeds/structuredefinitions.ts @@ -4,41 +4,69 @@ import { getDatabasePool } from '../database'; import { Repository, getSystemRepo } from '../fhir/repo'; import { globalLogger } from '../logger'; import { r4ProjectId } from '../seed'; +import { RebuildOptions, buildRebuildOptions } from './common'; /** * Creates all StructureDefinition resources. + * @param options - Optional options for how rebuild should be done. */ -export async function rebuildR4StructureDefinitions(): Promise { +export async function rebuildR4StructureDefinitions(options?: Partial): Promise { + const finalOptions = buildRebuildOptions(options) as RebuildOptions; const client = getDatabasePool(); await client.query(`DELETE FROM "StructureDefinition" WHERE "projectId" = $1`, [r4ProjectId]); const systemRepo = getSystemRepo(); - await createStructureDefinitionsForBundle(systemRepo, readJson('fhir/r4/profiles-resources.json') as Bundle); - await createStructureDefinitionsForBundle(systemRepo, readJson('fhir/r4/profiles-medplum.json') as Bundle); - await createStructureDefinitionsForBundle(systemRepo, readJson('fhir/r4/profiles-others.json') as Bundle); + if (finalOptions.parallel) { + await Promise.all([ + createStructureDefinitionsForBundleParallel(systemRepo, readJson('fhir/r4/profiles-resources.json') as Bundle), + createStructureDefinitionsForBundleParallel(systemRepo, readJson('fhir/r4/profiles-medplum.json') as Bundle), + createStructureDefinitionsForBundleParallel(systemRepo, readJson('fhir/r4/profiles-others.json') as Bundle), + ]); + } else { + await createStructureDefinitionsForBundleSerial(systemRepo, readJson('fhir/r4/profiles-resources.json') as Bundle); + await createStructureDefinitionsForBundleSerial(systemRepo, readJson('fhir/r4/profiles-medplum.json') as Bundle); + await createStructureDefinitionsForBundleSerial(systemRepo, readJson('fhir/r4/profiles-others.json') as Bundle); + } } -async function createStructureDefinitionsForBundle( +async function createStructureDefinitionsForBundleParallel( systemRepo: Repository, structureDefinitions: Bundle ): Promise { + const promises = []; for (const entry of structureDefinitions.entry as BundleEntry[]) { const resource = entry.resource as Resource; + if (resource.resourceType === 'StructureDefinition' && resource.name) { + promises.push(createAndLogStructureDefinition(systemRepo, resource)); + } + } + await Promise.all(promises); +} +async function createStructureDefinitionsForBundleSerial( + systemRepo: Repository, + structureDefinitions: Bundle +): Promise { + for (const entry of structureDefinitions.entry as BundleEntry[]) { + const resource = entry.resource as Resource; if (resource.resourceType === 'StructureDefinition' && resource.name) { - globalLogger.debug('StructureDefinition: ' + resource.name); - const result = await systemRepo.createResource({ - ...resource, - meta: { - ...resource.meta, - project: r4ProjectId, - lastUpdated: undefined, - versionId: undefined, - }, - text: undefined, - differential: undefined, - }); - globalLogger.debug('Created: ' + result.id); + await createAndLogStructureDefinition(systemRepo, resource); } } } + +async function createAndLogStructureDefinition(systemRepo: Repository, resource: StructureDefinition): Promise { + globalLogger.debug('[StructureDefinition] creation started: ' + resource.name); + const result = await systemRepo.createResource({ + ...resource, + meta: { + ...resource.meta, + project: r4ProjectId, + lastUpdated: undefined, + versionId: undefined, + }, + text: undefined, + differential: undefined, + }); + globalLogger.debug(`[StructureDefinition] creation finished: ${result.name} - ID: ${result.id}`); +} diff --git a/packages/server/src/seeds/valuesets.ts b/packages/server/src/seeds/valuesets.ts index d6397f9e98..aa6792cccb 100644 --- a/packages/server/src/seeds/valuesets.ts +++ b/packages/server/src/seeds/valuesets.ts @@ -3,35 +3,54 @@ import { readJson } from '@medplum/definitions'; import { Bundle, BundleEntry, CodeSystem, ValueSet } from '@medplum/fhirtypes'; import { Repository, getSystemRepo } from '../fhir/repo'; import { r4ProjectId } from '../seed'; +import { RebuildOptions, buildRebuildOptions } from './common'; /** * Imports all built-in ValueSets and CodeSystems into the database. + * @param options - Optional options for how rebuild should be done. */ -export async function rebuildR4ValueSets(): Promise { +export async function rebuildR4ValueSets(options?: Partial): Promise { + const finalOptions = buildRebuildOptions(options) as RebuildOptions; const systemRepo = getSystemRepo(); const files = ['v2-tables.json', 'v3-codesystems.json', 'valuesets.json', 'valuesets-medplum.json']; for (const file of files) { const bundle = readJson('fhir/r4/' + file) as Bundle; - for (const entry of bundle.entry as BundleEntry[]) { - const resource = entry.resource as CodeSystem | ValueSet; - await deleteExisting(systemRepo, resource, r4ProjectId); - await systemRepo.createResource({ - ...resource, - meta: { - ...resource.meta, - project: r4ProjectId, - lastUpdated: undefined, - versionId: undefined, - }, - }); + if (finalOptions.parallel) { + const promises = []; + for (const entry of bundle.entry as BundleEntry[]) { + promises.push(overwriteResource(systemRepo, entry.resource as CodeSystem | ValueSet, finalOptions)); + } + await Promise.all(promises); + } else { + for (const entry of bundle.entry as BundleEntry[]) { + await overwriteResource(systemRepo, entry.resource as CodeSystem | ValueSet, finalOptions); + } } } } +async function overwriteResource( + systemRepo: Repository, + resource: CodeSystem | ValueSet, + options: RebuildOptions +): Promise { + await deleteExisting(systemRepo, resource, r4ProjectId, options); + await systemRepo.createResource({ + ...resource, + meta: { + ...resource.meta, + project: r4ProjectId, + lastUpdated: undefined, + versionId: undefined, + }, + }); +} + async function deleteExisting( systemRepo: Repository, resource: CodeSystem | ValueSet, - projectId: string + projectId: string, + options: RebuildOptions ): Promise { const bundle = await systemRepo.search({ resourceType: resource.resourceType, @@ -41,9 +60,18 @@ async function deleteExisting( ], }); if (bundle.entry && bundle.entry.length > 0) { - for (const entry of bundle.entry) { - const existing = entry.resource as CodeSystem | ValueSet; - await systemRepo.deleteResource(existing.resourceType, existing.id as string); + if (options.parallel) { + const promises = []; + for (const entry of bundle.entry) { + const existing = entry.resource as CodeSystem | ValueSet; + promises.push(systemRepo.deleteResource(existing.resourceType, existing.id as string)); + } + await Promise.all(promises); + } else { + for (const entry of bundle.entry) { + const existing = entry.resource as CodeSystem | ValueSet; + await systemRepo.deleteResource(existing.resourceType, existing.id as string); + } } } } diff --git a/packages/server/src/subscriptions/websockets.test.ts b/packages/server/src/subscriptions/websockets.test.ts index 8b3ec68548..df23569fbb 100644 --- a/packages/server/src/subscriptions/websockets.test.ts +++ b/packages/server/src/subscriptions/websockets.test.ts @@ -36,7 +36,6 @@ describe('WebSockets Subscriptions', () => { config = await loadTestConfig(); config.heartbeatEnabled = false; server = await initApp(app, config); - await getRedis().flushdb(); const result = await withTestContext(() => createTestProject({ @@ -270,7 +269,6 @@ describe('Subscription Heartbeat', () => { config = await loadTestConfig(); config.heartbeatMilliseconds = 25; server = await initApp(app, config); - await getRedis().flushdb(); const result = await withTestContext(() => createTestProject({ diff --git a/packages/server/src/subscriptions/websockets.ts b/packages/server/src/subscriptions/websockets.ts index 0a07686fd7..910499cb58 100644 --- a/packages/server/src/subscriptions/websockets.ts +++ b/packages/server/src/subscriptions/websockets.ts @@ -10,7 +10,7 @@ import { getFullUrl } from '../fhir/response'; import { heartbeat } from '../heartbeat'; import { globalLogger } from '../logger'; import { verifyJwt } from '../oauth/keys'; -import { getRedis } from '../redis'; +import { getRedis, getRedisSubscriber } from '../redis'; interface BaseSubscriptionClientMsg { type: string; @@ -44,7 +44,7 @@ export async function handleR4SubscriptionConnection(socket: ws.WebSocket): Prom // According to Redis documentation: http://redis.io/commands/subscribe // Once the client enters the subscribed state it is not supposed to issue any other commands, // except for additional SUBSCRIBE, PSUBSCRIBE, UNSUBSCRIBE and PUNSUBSCRIBE commands. - redisSubscriber = redis.duplicate(); + redisSubscriber = getRedisSubscriber(); redisSubscriber.on('message', (channel: string, message: string) => { globalLogger.debug('[WS] redis message', { channel, message }); diff --git a/packages/server/src/util/cloudwatch.test.ts b/packages/server/src/util/cloudwatch.test.ts index a902d86208..4c0ed10381 100644 --- a/packages/server/src/util/cloudwatch.test.ts +++ b/packages/server/src/util/cloudwatch.test.ts @@ -4,9 +4,8 @@ import { CreateLogStreamCommand, PutLogEventsCommand, } from '@aws-sdk/client-cloudwatch-logs'; -import { mockClient, AwsClientStub } from 'aws-sdk-client-mock'; +import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest'; - import { loadTestConfig } from '../config'; import { waitFor } from '../test.setup'; import { CloudWatchLogger } from './cloudwatch'; diff --git a/packages/server/src/websockets.test.ts b/packages/server/src/websockets.test.ts index e3f7333b29..cfbaa8f15f 100644 --- a/packages/server/src/websockets.test.ts +++ b/packages/server/src/websockets.test.ts @@ -6,7 +6,6 @@ import request from 'superwstest'; import WebSocket from 'ws'; import { initApp, shutdownApp } from './app'; import { MedplumServerConfig, loadTestConfig } from './config'; -import { getRedis } from './redis'; import { withTestContext } from './test.setup'; describe('WebSockets', () => { @@ -18,7 +17,6 @@ describe('WebSockets', () => { app = express(); config = await loadTestConfig(); server = await initApp(app, config); - await getRedis().flushdb(); await new Promise((resolve) => { server.listen(0, 'localhost', 511, resolve); diff --git a/packages/server/src/websockets.ts b/packages/server/src/websockets.ts index 090fddaa2b..a3f528c495 100644 --- a/packages/server/src/websockets.ts +++ b/packages/server/src/websockets.ts @@ -8,7 +8,7 @@ import { getConfig } from './config'; import { RequestContext, requestContextStore } from './context'; import { handleFhircastConnection } from './fhircast/websocket'; import { globalLogger } from './logger'; -import { getRedis } from './redis'; +import { getRedis, getRedisSubscriber } from './redis'; import { handleR4SubscriptionConnection } from './subscriptions/websockets'; const handlerMap = new Map Promise>(); @@ -104,7 +104,7 @@ async function handleEchoConnection(socket: ws.WebSocket): Promise { // According to Redis documentation: http://redis.io/commands/subscribe // Once the client enters the subscribed state it is not supposed to issue any other commands, // except for additional SUBSCRIBE, PSUBSCRIBE, UNSUBSCRIBE and PUNSUBSCRIBE commands. - const redisSubscriber = getRedis().duplicate(); + const redisSubscriber = getRedisSubscriber(); const channel = randomUUID(); await redisSubscriber.subscribe(channel); diff --git a/packages/server/src/workers/subscription.test.ts b/packages/server/src/workers/subscription.test.ts index c15139c2d7..8b726802f8 100644 --- a/packages/server/src/workers/subscription.test.ts +++ b/packages/server/src/workers/subscription.test.ts @@ -23,10 +23,9 @@ import { createHmac, randomUUID } from 'crypto'; import fetch from 'node-fetch'; import { initAppServices, shutdownApp } from '../app'; import { loadTestConfig } from '../config'; -import { getDatabasePool } from '../database'; import { Repository, getSystemRepo } from '../fhir/repo'; import { globalLogger } from '../logger'; -import { getRedis } from '../redis'; +import { getRedisSubscriber } from '../redis'; import { createTestProject, withTestContext } from '../test.setup'; import { AuditEventOutcome } from '../util/auditevent'; import { closeSubscriptionWorker, execSubscriptionJob, getSubscriptionQueue } from './subscription'; @@ -40,28 +39,18 @@ describe('Subscription Worker', () => { let mockLambdaClient: AwsClientStub; let superAdminRepo: Repository; - beforeEach(() => { - mockLambdaClient = mockClient(LambdaClient); - mockLambdaClient.on(InvokeCommand).callsFake(({ Payload }) => { - const decoder = new TextDecoder(); - const event = JSON.parse(decoder.decode(Payload)); - const output = typeof event.input === 'string' ? event.input : JSON.stringify(event.input); - const encoder = new TextEncoder(); - - return { - LogResult: `U1RBUlQgUmVxdWVzdElkOiAxNDZmY2ZjZi1jMzJiLTQzZjUtODJhNi1lZTBmMzEzMmQ4NzMgVmVyc2lvbjogJExBVEVTVAoyMDIyLTA1LTMwVDE2OjEyOjIyLjY4NVoJMTQ2ZmNmY2YtYzMyYi00M2Y1LTgyYTYtZWUwZjMxMzJkODczCUlORk8gdGVzdApFTkQgUmVxdWVzdElkOiAxNDZmY2ZjZi1jMzJiLTQzZjUtODJhNi1lZTBmMzEzMmQ4NzMKUkVQT1JUIFJlcXVlc3RJZDogMTQ2ZmNmY2YtYzMyYi00M2Y1LTgyYTYtZWUwZjMxMzJkODcz`, - Payload: encoder.encode(output), - }; - }); + beforeAll(async () => { + const config = await loadTestConfig(); + await initAppServices(config); }); - afterEach(() => { - mockLambdaClient.restore(); + afterAll(async () => { + await shutdownApp(); + await closeSubscriptionWorker(); // Double close to ensure quite ignore }); - beforeAll(async () => { - const config = await loadTestConfig(); - await initAppServices(config); + beforeEach(async () => { + (fetch as unknown as jest.Mock).mockClear(); // Create one simple project with no advanced features enabled const { client, repo: _repo } = await withTestContext(() => @@ -85,16 +74,23 @@ describe('Subscription Worker', () => { projects: [botProjectDetails.project.id as string], author: createReference(botProjectDetails.client), }); - }); - afterAll(async () => { - await shutdownApp(); - await closeSubscriptionWorker(); // Double close to ensure quite ignore + mockLambdaClient = mockClient(LambdaClient); + mockLambdaClient.on(InvokeCommand).callsFake(({ Payload }) => { + const decoder = new TextDecoder(); + const event = JSON.parse(decoder.decode(Payload)); + const output = typeof event.input === 'string' ? event.input : JSON.stringify(event.input); + const encoder = new TextEncoder(); + + return { + LogResult: `U1RBUlQgUmVxdWVzdElkOiAxNDZmY2ZjZi1jMzJiLTQzZjUtODJhNi1lZTBmMzEzMmQ4NzMgVmVyc2lvbjogJExBVEVTVAoyMDIyLTA1LTMwVDE2OjEyOjIyLjY4NVoJMTQ2ZmNmY2YtYzMyYi00M2Y1LTgyYTYtZWUwZjMxMzJkODczCUlORk8gdGVzdApFTkQgUmVxdWVzdElkOiAxNDZmY2ZjZi1jMzJiLTQzZjUtODJhNi1lZTBmMzEzMmQ4NzMKUkVQT1JUIFJlcXVlc3RJZDogMTQ2ZmNmY2YtYzMyYi00M2Y1LTgyYTYtZWUwZjMxMzJkODcz`, + Payload: encoder.encode(output), + }; + }); }); - beforeEach(async () => { - await getDatabasePool().query('DELETE FROM "Subscription"'); - (fetch as unknown as jest.Mock).mockClear(); + afterEach(() => { + mockLambdaClient.restore(); }); test('Send subscriptions', () => @@ -1517,7 +1513,7 @@ describe('Subscription Worker', () => { expect(subscription.id).toBeDefined(); // Subscribe to the topic - const subscriber = getRedis().duplicate(); + const subscriber = getRedisSubscriber(); await subscriber.subscribe(subscription.id as string); let resolve: () => void; @@ -1564,6 +1560,7 @@ describe('Subscription Worker', () => { expect(queue.add).toHaveBeenCalled(); await deferredPromise; + // @ts-expect-error Okay to await quit in tests await subscriber.quit(); })); @@ -1591,7 +1588,7 @@ describe('Subscription Worker', () => { expect(subscription.id).toBeDefined(); // Subscribe to the topic - const subscriber = getRedis().duplicate(); + const subscriber = getRedisSubscriber(); await subscriber.subscribe(subscription.id as string); let resolve: () => void; @@ -1622,6 +1619,7 @@ describe('Subscription Worker', () => { }, 150); await deferredPromise; + // @ts-expect-error Okay to await quit in tests await subscriber.quit(); expect(console.log).toHaveBeenLastCalledWith(expect.stringMatching(/WebSocket Subscriptions/)); @@ -1670,7 +1668,7 @@ describe('Subscription Worker', () => { expect(subscription.id).toBeDefined(); // Subscribe to the topic - const subscriber = getRedis().duplicate(); + const subscriber = getRedisSubscriber(); await subscriber.subscribe(subscription.id as string); let resolve: () => void; @@ -1698,6 +1696,7 @@ describe('Subscription Worker', () => { setTimeout(() => resolve(), 300); await deferredPromise; + // @ts-expect-error Okay to await quit in tests await subscriber.quit(); expect(console.log).toHaveBeenCalledWith( @@ -1748,7 +1747,7 @@ describe('Subscription Worker', () => { await superAdminRepo.deleteResource('ProjectMembership', membership.id as string); // Subscribe to the topic - const subscriber = getRedis().duplicate(); + const subscriber = getRedisSubscriber(); await subscriber.subscribe(subscription.id as string); let resolve: () => void; @@ -1776,6 +1775,7 @@ describe('Subscription Worker', () => { setTimeout(() => resolve(), 300); await deferredPromise; + // @ts-expect-error Okay to await quit in tests await subscriber.quit(); expect(console.log).toHaveBeenCalledWith( @@ -1826,7 +1826,7 @@ describe('Subscription Worker', () => { await superAdminRepo.deleteResource('AccessPolicy', accessPolicy.id as string); // Subscribe to the topic - const subscriber = getRedis().duplicate(); + const subscriber = getRedisSubscriber(); await subscriber.subscribe(subscription.id as string); let resolve: () => void; @@ -1854,6 +1854,7 @@ describe('Subscription Worker', () => { setTimeout(() => resolve(), 300); await deferredPromise; + // @ts-expect-error Okay to await quit in test await subscriber.quit(); expect(console.log).toHaveBeenCalledWith( diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index a5cb75c562..e53cd271e9 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "seed-tests/seed-serial.test.ts", "seed-tests/seed.test.ts"] } diff --git a/packages/server/turbo.json b/packages/server/turbo.json new file mode 100644 index 0000000000..d00fb4125e --- /dev/null +++ b/packages/server/turbo.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://turborepo.org/schema.json", + "extends": ["//"], + "pipeline": { + "test:seed:serial": { + "dependsOn": ["build"], + "outputs": ["coverage/**"], + "inputs": ["src/**/*.tsx", "src/**/*.ts"] + }, + "test:seed:parallel": { + "dependsOn": ["build"], + "outputs": ["coverage/**"], + "inputs": ["src/**/*.tsx", "src/**/*.ts"] + } + } +} diff --git a/scripts/reinstall.sh b/scripts/reinstall.sh index 6c735d4ccf..27abc9e3f6 100755 --- a/scripts/reinstall.sh +++ b/scripts/reinstall.sh @@ -7,7 +7,6 @@ set -e set -x rm -rf node_modules -rm -rf package-lock.json for dir in `ls packages`; do if test -d "packages/$dir/node_modules"; then @@ -21,4 +20,10 @@ for dir in `ls examples`; do fi done -npm i --strict-peer-deps +# If called with "--update", then use npm i +if [ "$1" == "--update" ]; then + rm -rf package-lock.json + npm i --strict-peer-deps +else + npm ci --strict-peer-deps +fi diff --git a/scripts/test.sh b/scripts/test.sh index 26c162705d..c338b04f6e 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -7,7 +7,29 @@ set -e set -x # Set node options -export NODE_OPTIONS='--max-old-space-size=5120' +export NODE_OPTIONS='--max-old-space-size=8192' + +# Clear old code coverage data +rm -rf coverage +rm -rf coverage-seed +mkdir -p coverage/packages +mkdir -p coverage/combined +mkdir -p coverage-seed/serial +mkdir -p coverage-seed/parallel + +# Testing production path of seeding the database +# This is a special "test" which runs all of the seed logic, such as setting up structure definitions +# On a normal developer machine, this is run only rarely when setting up a new database +# We execute this in parallel with the main line of tests +{ + time npx turbo run test:seed:serial --filter=./packages/server -- --coverage + cp "packages/server/coverage-seed/serial/coverage-final.json" "coverage/packages/coverage-server-seed-serial.json" +} & + +# Seed the database before testing +# This is the parallel implementation so it's faster +time npx turbo run test:seed:parallel --filter=./packages/server -- --coverage +cp "packages/server/coverage-seed/parallel/coverage-final.json" "coverage/packages/coverage-server-seed-parallel.json" # Test # Run them separately because code coverage is resource intensive @@ -24,12 +46,9 @@ for dir in `ls examples`; do fi done +wait # Combine test coverage -rm -rf coverage -mkdir -p coverage/packages -mkdir -p coverage/combined - PACKAGES=( "agent" "app" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 782cd3492a..71f7d50732 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -40,7 +40,7 @@ git push origin "$BRANCH_NAME" gh pr create --title "Dependency upgrades $DATE" --body "Dependency upgrades" --draft # Reinstall all dependencies -./scripts/reinstall.sh +./scripts/reinstall.sh --update # Commit and push after running NPM install git add -u . diff --git a/sonar-project.properties b/sonar-project.properties index 7c9e806049..b0bd407782 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.organization=medplum sonar.projectKey=medplum_medplum sonar.projectName=Medplum -sonar.projectVersion=3.1.2 +sonar.projectVersion=3.1.3 sonar.sources=packages sonar.sourceEncoding=UTF-8 sonar.exclusions=**/node_modules/**,\