diff --git a/package-lock.json b/package-lock.json index ab6a8be2e..5abbb3917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.515.0", "@axe-core/playwright": "^4.10.2", + "@azure/functions": "^4.7.2-preview", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", "@next/mdx": "^15.3.5", @@ -28,6 +29,7 @@ "next": "^15.3.5", "next-auth": "^5.0.0-beta.29", "next-nprogress-bar": "^2.4.4", + "node-fetch": "^2.7.0", "node-hl7-client": "^3.0.0", "pg": "^8.16.3", "pg-promise": "^11.15.0", @@ -57,6 +59,7 @@ "@types/fhir": "^0.0.40", "@types/jest-axe": "^3.5.9", "@types/node": "^22.13.9", + "@types/node-fetch": "^2.6.13", "@types/pg": "^8.15.5", "@types/react": "19.1.6", "@types/react-dom": "19.1.5", @@ -1118,6 +1121,48 @@ "playwright-core": ">= 1.0.0" } }, + "node_modules/@azure/functions": { + "version": "4.7.2-preview", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.7.2-preview.tgz", + "integrity": "sha512-pRIcxTz1e5RGaKKRdLXgfjAKfrmjLMod+S5TUx3eG7Ga1yDYjC/R4lbYNQXgqSI5Pr8YwkFBE2/G32Na9VLFAQ==", + "license": "MIT", + "dependencies": { + "@azure/functions-extensions-base": "0.2.0-preview", + "cookie": "^0.7.0", + "long": "^4.0.0", + "undici": "^5.13.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@azure/functions-extensions-base": { + "version": "0.2.0-preview", + "resolved": "https://registry.npmjs.org/@azure/functions-extensions-base/-/functions-extensions-base-0.2.0-preview.tgz", + "integrity": "sha512-kwbtV16ahkM3w9Vzp6Aof8gy4TkmIgGxfbPGKBQ0pd804fsp+eeWxQlgbal5+4l4dzMLSkzXC7mkURM1cyihnA==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@azure/functions/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/@azure/functions/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1985,6 +2030,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -5418,6 +5472,17 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/pg": { "version": "8.15.5", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", @@ -7697,6 +7762,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", @@ -14276,6 +14350,48 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-hl7-client": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/node-hl7-client/-/node-hl7-client-3.2.0.tgz", diff --git a/package.json b/package.json index d4ec37852..06b496d9d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.515.0", "@axe-core/playwright": "^4.10.2", + "@azure/functions": "^4.7.2-preview", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", "@next/mdx": "^15.3.5", @@ -43,9 +44,10 @@ "next": "^15.3.5", "next-auth": "^5.0.0-beta.29", "next-nprogress-bar": "^2.4.4", + "node-fetch": "^2.7.0", "node-hl7-client": "^3.0.0", - "pg-promise": "^11.15.0", "pg": "^8.16.3", + "pg-promise": "^11.15.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-highlight-words": "^0.21.0", @@ -72,6 +74,7 @@ "@types/fhir": "^0.0.40", "@types/jest-axe": "^3.5.9", "@types/node": "^22.13.9", + "@types/node-fetch": "^2.6.13", "@types/pg": "^8.15.5", "@types/react": "19.1.6", "@types/react-dom": "19.1.5", diff --git a/src/docs/deployment.mdx b/src/docs/deployment.mdx index 69668f4f0..e1cd9e53b 100644 --- a/src/docs/deployment.mdx +++ b/src/docs/deployment.mdx @@ -198,7 +198,7 @@ If using Microsoft Entra ID: - Go to Azure Portal > Microsoft Entra ID > App registrations. - Create a new registration. - - Set the redirect URI to: `https://your-query-connector-url/api/auth/callback/microsoft-entra`. + - Set the redirect URI to: `https://your-query-connector-url/api/auth/callback/microsoft-entra-id`. 2. **Configure API permissions**: diff --git a/terraform/implementation/app_function/config.tf b/terraform/implementation/app_function/config.tf new file mode 100644 index 000000000..48a4e4bd7 --- /dev/null +++ b/terraform/implementation/app_function/config.tf @@ -0,0 +1,32 @@ +/* + * The Terraform Azure backend requires a pre-existing Azure Storage Account and a container to store the state file. If you do not already + * have a pre-configured resource for this, we recommend setting one up manually. + * + * WARNING: Make sure you don't use the same resource group for your state storage and your DIBBs resources to avoid accidental deletion. + * DIBBs resources should be deployed in their own resource group. + */ +terraform { + backend "azurerm" { + resource_group_name = "qc-aca-rg" // TODO: Change this to match the resource group that contains your storage account for Terraform state storage. + storage_account_name = "qcacastorageaccount" // TODO: Change this to match the storage account that contains/will contain your Terraform state files. + container_name = "tfstate" // We recommend leaving this alone, to keep state files separate from the rest of your resources. + key = "dev/app_func/terraform.tfstate" // TODO: Change the prefix to match the environment you are working in. + } + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.116.0" + } + random = { + source = "hashicorp/random" + version = "3.7.2" + } + } + required_version = "~> 1.9.8" +} + +provider "azurerm" { + features {} + skip_provider_registration = true +} + diff --git a/terraform/implementation/app_function/dist/host.json b/terraform/implementation/app_function/dist/host.json new file mode 100644 index 000000000..fe2cdbc22 --- /dev/null +++ b/terraform/implementation/app_function/dist/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.0.0, 5.0.0)" + } +} diff --git a/terraform/implementation/app_function/dist/index.ts b/terraform/implementation/app_function/dist/index.ts new file mode 100644 index 000000000..45a9f836b --- /dev/null +++ b/terraform/implementation/app_function/dist/index.ts @@ -0,0 +1,2 @@ +import "./src/functions/ProcessHL7/process-hl7"; +import "./src/functions/PostHL7/post-hl7"; diff --git a/terraform/implementation/app_function/dist/package-lock.json b/terraform/implementation/app_function/dist/package-lock.json new file mode 100644 index 000000000..9b42a6065 --- /dev/null +++ b/terraform/implementation/app_function/dist/package-lock.json @@ -0,0 +1,92 @@ +{ + "name": "app-func", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "app-func", + "version": "1.0.0", + "dependencies": { + "@azure/functions": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@azure/functions": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.7.2.tgz", + "integrity": "sha512-5ps8yz4gn6oZSzeQbpUreWHFYl/YS03F1Sk/pz7YJphfctRcHuLF5tcrdm9AyRiYzja4Bkd63bju+g/E27opPQ==", + "dependencies": { + "cookie": "^0.7.0", + "long": "^4.0.0", + "undici": "^5.29.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "20.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", + "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } +} diff --git a/terraform/implementation/app_function/dist/package.json b/terraform/implementation/app_function/dist/package.json new file mode 100644 index 000000000..a6af45ee1 --- /dev/null +++ b/terraform/implementation/app_function/dist/package.json @@ -0,0 +1,17 @@ +{ + "name": "app-func", + "version": "1.0.0", + "main": "src/functions/ProcessHL7/process-hl7.js", + "scripts": { + "compile": "tsc", + "build": "npm run compile", + "start": "func start" + }, + "dependencies": { + "@azure/functions": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/terraform/implementation/app_function/dist/src/functions/PostHL7/post-hl7.js b/terraform/implementation/app_function/dist/src/functions/PostHL7/post-hl7.js new file mode 100644 index 000000000..6f20e530b --- /dev/null +++ b/terraform/implementation/app_function/dist/src/functions/PostHL7/post-hl7.js @@ -0,0 +1,30 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.postHL7 = postHL7; +/** + * Sends a raw HL7v2 message to the Query Connector API as text/plain. + * @param root0 - The parameters for the HL7 query. + * @param root0.endpoint - The endpoint URL of the Query Connector API. + * @param root0.queryId - The unique identifier for the query. + * @param root0.fhirServer - The FHIR server to associate with the query. + * @param root0.serviceToken - The service token for authentication. + * @param root0.hl7Message - The HL7v2 message to be sent. + * @returns A promise that resolves to the fetch response. + */ +async function postHL7({ + endpoint, + queryId, + fhirServer, + serviceToken, + hl7Message, +}) { + const url = `${endpoint}?id=${encodeURIComponent(queryId)}&fhir_server=${encodeURIComponent(fhirServer)}&message_format=HL7`; + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "text/plain", + Authorization: `Bearer ${serviceToken}`, + }, + body: hl7Message, + }); +} diff --git a/terraform/implementation/app_function/dist/src/functions/ProcessHL7/function.json b/terraform/implementation/app_function/dist/src/functions/ProcessHL7/function.json new file mode 100644 index 000000000..45ffd36ec --- /dev/null +++ b/terraform/implementation/app_function/dist/src/functions/ProcessHL7/function.json @@ -0,0 +1,12 @@ +{ + "bindings": [ + { + "type": "blobTrigger", + "direction": "in", + "name": "content", + "connection": "AzureWebJobsStorage", + "path": "hl7-message/{name}" + } + ], + "scriptFile": "src/functions/ProcessHL7/process-hl7.js" +} diff --git a/terraform/implementation/app_function/dist/src/functions/ProcessHL7/process-hl7.js b/terraform/implementation/app_function/dist/src/functions/ProcessHL7/process-hl7.js new file mode 100644 index 000000000..964e92780 --- /dev/null +++ b/terraform/implementation/app_function/dist/src/functions/ProcessHL7/process-hl7.js @@ -0,0 +1,34 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const functions_1 = require("@azure/functions"); +const post_hl7_1 = require("../PostHL7/post-hl7"); +const queryId = process.env.QUERY_ID; +const fhirServer = process.env.FHIR_SERVER; +const serviceToken = process.env.SERVICE_TOKEN; +const endpoint = process.env.QUERY_CONNECTOR_ENDPOINT; +const blobPath = process.env.BLOB_PATH; +async function hl7BlobHandler(content, context) { + const hl7 = content.toString("utf-8"); + try { + const res = await (0, post_hl7_1.postHL7)({ + queryId, + fhirServer, + serviceToken, + hl7Message: hl7, + endpoint: endpoint || "https://queryconnector.dev/api/query", + }); + const body = await res.text(); + if (!res.ok) { + context.log("HL7 POST failed", { status: res.status, body }); + } else { + context.log("HL7 POST succeeded"); + } + } catch (err) { + context.log("Exception during HL7 POST:", err); + } +} +functions_1.app.storageBlob("hl7BlobTrigger", { + path: blobPath, + connection: "AzureWebJobsStorage", + handler: hl7BlobHandler, +}); diff --git a/terraform/implementation/app_function/dist/src/index.js b/terraform/implementation/app_function/dist/src/index.js new file mode 100644 index 000000000..6e16ed0eb --- /dev/null +++ b/terraform/implementation/app_function/dist/src/index.js @@ -0,0 +1,3 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +require("./functions/ProcessHL7/process-hl7"); diff --git a/terraform/implementation/app_function/host.json b/terraform/implementation/app_function/host.json new file mode 100644 index 000000000..fe2cdbc22 --- /dev/null +++ b/terraform/implementation/app_function/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.0.0, 5.0.0)" + } +} diff --git a/terraform/implementation/app_function/main.tf b/terraform/implementation/app_function/main.tf new file mode 100644 index 000000000..9a237391f --- /dev/null +++ b/terraform/implementation/app_function/main.tf @@ -0,0 +1,191 @@ + +locals { + qc_resource_group = "qc-aca-rg" + location = "East US 2" + project = "qc" + environment = "dev" + storage_account = "qcacastorageaccount" + auth_provider = "microsoft-entra-id" # TODO + auth_client_id = "query-connector" # TODO: "Client ID" + auth_issuer = "https://login.microsoftonline.com/your-tenant-id/v2.0" # TODO: URL for the Auth issuer for Entra (https://login.microsoftonline.com//v2.0 or keycloak) + auth_url = "http://localhost:3000" # TODO: Change to URL for the Auth server + entra_tenant_id = "value" +} + + +data "azurerm_resource_group" "qc_rg" { + name = local.qc_resource_group +} + +data "azurerm_storage_account" "qc_storage_account" { + name = local.storage_account + resource_group_name = local.qc_resource_group +} + +data "azurerm_application_insights" "qc-function-insight" { + name = "qc-linux-function-app-insights" + resource_group_name = data.azurerm_resource_group.qc_rg.name +} + +locals { + app_files = fileset("${path.module}/src/functions", "**") + app_hash = md5(join("", [for f in local.app_files : filemd5("${path.module}/src/functions/${f}")])) + package_url = "https://${data.azurerm_storage_account.qc_storage_account.name}.blob.core.windows.net/${azurerm_storage_container.pkg.name}/${azurerm_storage_blob.pkgzip.name}${data.azurerm_storage_account_sas.pkg.sas}" +} + +resource "null_resource" "build" { + triggers = { app = local.app_hash } + provisioner "local-exec" { + working_dir = path.module + command = "npm i && npm run build && npm prune --omit=dev" + } +} + +data "archive_file" "zip" { + type = "zip" + source_dir = "${path.module}/dist" + output_path = "${path.module}/dist/functionapp.zip" + excludes = [ + ".git/**", ".vscode/**", ".terraform/**", "local.settings.json", "functionapp.zip" + ] + depends_on = [null_resource.build] +} + + + +# App Service Plan & Function App +resource "azurerm_service_plan" "qc_plan" { + name = "appservice-plan-qc" + location = local.location + resource_group_name = data.azurerm_resource_group.qc_rg.name + os_type = "Linux" + sku_name = "P1v2" + + depends_on = [data.azurerm_resource_group.qc_rg] +} + +#Manages container within an Azure Storage Account +resource "azurerm_storage_container" "pkg" { + name = "function-releases" + storage_account_name = data.azurerm_storage_account.qc_storage_account.name + container_access_type = "private" +} + +#Manages a Blob within the Storage container +resource "azurerm_storage_blob" "pkgzip" { + name = "functionapp-${formatdate("YYYYMMDDHHmmss", timestamp())}.zip" + storage_account_name = data.azurerm_storage_account.qc_storage_account.name + storage_container_name = azurerm_storage_container.pkg.name + type = "Block" + source = data.archive_file.zip.output_path #TO DO BY ME + content_type = "application/zip" + + depends_on = [null_resource.build] +} + + + +# Shared Access Token (SAS) URL for the package +data "azurerm_storage_account_sas" "pkg" { + connection_string = data.azurerm_storage_account.qc_storage_account.primary_connection_string + https_only = true + start = timeadd(timestamp(), "-5m") + expiry = timeadd(timestamp(), "168h") # 7 days; extend if needed + + resource_types { + service = false + container = true + object = true + } + services { + blob = true + queue = false + table = false + file = false + } + permissions { + read = true + write = false + delete = false + list = true + add = false + create = false + update = true + process = true + tag = false + filter = false + } +} + +resource "azurerm_storage_container" "message" { + name = "hl7-message" + storage_account_name = data.azurerm_storage_account.qc_storage_account.name + container_access_type = "private" +} + +resource "azurerm_linux_function_app" "qc_linux_function_app" { + name = "qc-linux-function-app" + location = local.location + resource_group_name = data.azurerm_resource_group.qc_rg.name + service_plan_id = azurerm_service_plan.qc_plan.id + + storage_account_name = data.azurerm_storage_account.qc_storage_account.name + storage_account_access_key = data.azurerm_storage_account.qc_storage_account.primary_access_key + functions_extension_version = "~4" + + https_only = true + identity { + type = "SystemAssigned" + } + site_config { + application_stack { + node_version = 20 + } + } + + app_settings = { + FUNCTIONS_EXTENSION_VERSION = "~4" + FUNCTION_APP_EDIT_MODE = "readwrite" + FUNCTIONS_WORKER_RUNTIME = "node" + AzureWebJobsStorage = data.azurerm_storage_account.qc_storage_account.primary_connection_string + APPLICATIONINSIGHTS_CONNECTION_STRING = data.azurerm_application_insights.qc-function-insight.connection_string + + QUERY_CONNECTOR_ENDPOINT = "https://queryconnector.dev/api/query" # <- set target endpoint + + AUTH_CLIENT_ID = local.auth_client_id + AUTH_ISSUER = local.auth_issuer + ENTRA_TENANT_ID = local.entra_tenant_id + AUTH_DISABLED = "true" + AUTH_URL = local.auth_url + NEXT_PUBLIC_AUTH_PROVIDER = local.auth_provider + # Zip Deploy (Run From Package) Use this option once function has been finalized + WEBSITE_RUN_FROM_PACKAGE = local.package_url + } + + # Deploy code in a writable way so Terraform can add function.json + # zip_deploy_file = data.archive_file.zip.output_path + + depends_on = [azurerm_storage_blob.pkgzip] +} + + + + + +# resource "azurerm_function_app_function" "qc_app_function" { +# name = "${local.project}-${local.environment}-function-app" +# function_app_id = azurerm_linux_function_app.qc_linux_function_app.id +# language = "TypeScript" + +# config_json = jsonencode({ +# bindings = [ +# { type = "blobTrigger", direction = "in", name = "content", connection = "AzureWebJobsStorage", path = "hl7-message/{name}" }, #The name should be the param the typescript code receives +# ], +# scriptFile = "dist/src/functions/ProcessHL7/process-hl7.js" +# }) +# } + +output "package_sas_url" { + value = local.package_url + sensitive = true +} \ No newline at end of file diff --git a/terraform/implementation/app_function/package-lock.json b/terraform/implementation/app_function/package-lock.json new file mode 100644 index 000000000..9b42a6065 --- /dev/null +++ b/terraform/implementation/app_function/package-lock.json @@ -0,0 +1,92 @@ +{ + "name": "app-func", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "app-func", + "version": "1.0.0", + "dependencies": { + "@azure/functions": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@azure/functions": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.7.2.tgz", + "integrity": "sha512-5ps8yz4gn6oZSzeQbpUreWHFYl/YS03F1Sk/pz7YJphfctRcHuLF5tcrdm9AyRiYzja4Bkd63bju+g/E27opPQ==", + "dependencies": { + "cookie": "^0.7.0", + "long": "^4.0.0", + "undici": "^5.29.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "20.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", + "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } +} diff --git a/terraform/implementation/app_function/package.json b/terraform/implementation/app_function/package.json new file mode 100644 index 000000000..a6af45ee1 --- /dev/null +++ b/terraform/implementation/app_function/package.json @@ -0,0 +1,17 @@ +{ + "name": "app-func", + "version": "1.0.0", + "main": "src/functions/ProcessHL7/process-hl7.js", + "scripts": { + "compile": "tsc", + "build": "npm run compile", + "start": "func start" + }, + "dependencies": { + "@azure/functions": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/terraform/implementation/app_function/sample1.json b/terraform/implementation/app_function/sample1.json new file mode 100644 index 000000000..c883b1dbf --- /dev/null +++ b/terraform/implementation/app_function/sample1.json @@ -0,0 +1,7 @@ +{ + "queryId": "abc-123", + "fhirServer": "https://example-fhir-server.com", + "serviceToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "hl7Message": "MSH|^~\\&|HIS|RIH|EKG|EKG|200202150930||ADT^A01|MSG00001|P|2.4", + "endpoint": "custom/hl7" +} diff --git a/terraform/implementation/app_function/src/functions/PostHL7/post-hl7.ts b/terraform/implementation/app_function/src/functions/PostHL7/post-hl7.ts new file mode 100644 index 000000000..7bbffb72d --- /dev/null +++ b/terraform/implementation/app_function/src/functions/PostHL7/post-hl7.ts @@ -0,0 +1,38 @@ +type HL7QueryParams = { + endpoint: string; + queryId: string; + fhirServer: string; + serviceToken: string; + hl7Message: string; +}; + +/** + * Sends a raw HL7v2 message to the Query Connector API as text/plain. + * @param root0 - The parameters for the HL7 query. + * @param root0.endpoint - The endpoint URL of the Query Connector API. + * @param root0.queryId - The unique identifier for the query. + * @param root0.fhirServer - The FHIR server to associate with the query. + * @param root0.serviceToken - The service token for authentication. + * @param root0.hl7Message - The HL7v2 message to be sent. + * @returns A promise that resolves to the fetch response. + */ +export async function postHL7({ + endpoint, + queryId, + fhirServer, + serviceToken, + hl7Message, +}: HL7QueryParams): Promise { + const url = `${endpoint}?id=${encodeURIComponent(queryId)}&fhir_server=${encodeURIComponent( + fhirServer, + )}&message_format=HL7`; + + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "text/plain", + Authorization: `Bearer ${serviceToken}`, + }, + body: hl7Message, + }); +} diff --git a/terraform/implementation/app_function/src/functions/ProcessHL7/process-hl7.ts b/terraform/implementation/app_function/src/functions/ProcessHL7/process-hl7.ts new file mode 100644 index 000000000..43b933ff5 --- /dev/null +++ b/terraform/implementation/app_function/src/functions/ProcessHL7/process-hl7.ts @@ -0,0 +1,41 @@ +import { app, InvocationContext } from "@azure/functions"; +import { postHL7 } from "../PostHL7/post-hl7"; + +const queryId = process.env.QUERY_ID!; +const fhirServer = process.env.FHIR_SERVER!; +const serviceToken = process.env.SERVICE_TOKEN!; +const endpoint = process.env.QUERY_CONNECTOR_ENDPOINT; +const blobPath = process.env.BLOB_PATH!; + +async function hl7BlobHandler( + content: Buffer, + context: InvocationContext, +): Promise { + const hl7 = content.toString("utf-8"); + + try { + const res = await postHL7({ + queryId, + fhirServer, + serviceToken, + hl7Message: hl7, + endpoint: endpoint || "https://queryconnector.dev/api/query", + }); + + const body: string = await res.text(); + + if (!res.ok) { + context.log("HL7 POST failed", { status: res.status, body }); + } else { + context.log("HL7 POST succeeded"); + } + } catch (err: unknown) { + context.log("Exception during HL7 POST:", err); + } +} + +app.storageBlob("hl7BlobTrigger", { + path: blobPath, + connection: "AzureWebJobsStorage", + handler: hl7BlobHandler, +}); diff --git a/terraform/implementation/app_function/src/index.ts b/terraform/implementation/app_function/src/index.ts new file mode 100644 index 000000000..548392264 --- /dev/null +++ b/terraform/implementation/app_function/src/index.ts @@ -0,0 +1 @@ +import "./functions/ProcessHL7/process-hl7"; diff --git a/terraform/implementation/app_function/tsconfig.json b/terraform/implementation/app_function/tsconfig.json new file mode 100644 index 000000000..b5e28a871 --- /dev/null +++ b/terraform/implementation/app_function/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "Commonjs", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": ".", + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": [ + "src/**/*" + ] +} diff --git a/terraform/implementation/app_service/azur_verify_db_dns.md b/terraform/implementation/app_service/azur_verify_db_dns.md new file mode 100644 index 000000000..b8f5cfe56 --- /dev/null +++ b/terraform/implementation/app_service/azur_verify_db_dns.md @@ -0,0 +1,44 @@ +# Using Azure Private Endpoint with App Service + +## 1. Keep the host as the FQDN for the DATABASE_URL + +Use the normal Azure PostgreSQL host in your connection string, for example: + +``` +pgflexserver-qc-12345f.postgres.database.azure.com +``` + +Example connection string: + +``` +postgres://testuser:testpasswd@pgflexserver-qc-12345f.postgres.database.azure.com:5432/qc_db?sslmode=require +``` + +> **Note:** Do **not** use `localhost` and do **not** use the raw IP. + +--- + +## 2. Make the Database's FQDN resolve to the private IP + +1. Navigate to **Azure Database for PostgreSQL Flexible Server** → click your server name → **Settings** → **Networking**. +2. Scroll down to **Private DNS integration** and copy the name of the **Private DNS Zone**. +3. Paste that into the Azure portal search bar. Select your **Private DNS Zone**. +4. Navigate to **DNS Management** → **Recordsets**. +5. Create or verify a **Private DNS zone**: + `privatelink.postgres.database.azure.com`. +6. Add an **A record** for your server name (e.g., `pgflexserver-qc-12345f`) pointing to the **private endpoint IP**. +7. **Link** that Private DNS zone to the **VNet** your App Service is integrated with (Regional VNet Integration). +8. Once linked, your App Service will automatically resolve `pgflexserver-qc-12345f.postgres.database.azure.com` to the **private IP**. + +--- + +## 3. Verify name resolution from App Service + +1. In **Azure Portal**, go to your **App Service**. +2. In the left menu, select **Advanced Tools** → **Go** (opens **Kudu** in a new tab). +3. In Kudu, select **Bash**. +4. Run: +``` +nslookup pgflexserver-qc-12345f.postgres.database.azure.com +``` +