Repository: StanfordBDHG/ENGAGE-HF-Firebase
Pull request #237: Health Summary Sharing via One-Time-Code
Author: login: pauljohanneskraft
Labels: name: enhancement
description: New feature or request
Created: 2025-03-05T11:46:31Z | Merged: 2025-03-21T12:19:17Z

# Health Summary Sharing via One-Time-Code

## :recycle: Current situation & Problem
- Closes #232 
- Introduces a new subcollection `/users/$userId$/shareCodes` to include all share codes, also some recent expired ones.. Whenever we interact with share codes, we make sure to remove expired ones to keep Firestore clean. Except for the Firebase functions, nobody is supposed to access this subcollection directly.
- Introduces a new Firebase function `shareHealthSummary` to generate a new share code. This is also the only way one is supposed to get a share code.
- Adapts `generateHealthSummary` to also be called with an unauthenticated account, as long as a valid share code is provided.


## :gear: Release Notes 
*Add a bullet point list summary of the feature and possible migration guides if this is a breaking change so this section can be added to the release notes.*
*Include code snippets that provide examples of the feature implemented or links to the documentation if it appends or changes the public interface.*


## :books: Documentation
*Please ensure that you properly document any additions in conformance to [Spezi Documentation Guide](https://github.com/StanfordSpezi/.github/blob/main/DOCUMENTATIONGUIDE.md).*
*You can use this section to describe your solution, but we encourage contributors to document your reasoning and changes using in-line documentation.* 


## :white_check_mark: Testing
*Please ensure that the PR meets the testing requirements set by CodeCov and that new functionality is appropriately tested.*
*This section describes important information about the tests and why some elements might not be testable.*


### Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md).


Linked issue #232: [Extension]: Health Summary sharing via one-time-code
### Problem

Currently, only an authenticated clinician can view a patient's health summary. We want to extend this to clinicians that are shown a QR code or given a link with a one-time code to access a patient's health summary.

### Solution

Extend user object to optionally contain a one-time code to access its health summary, including amount of tries and creation date (this one-time code should only be valid for 5 minutes).

Create a new cloud function to create the one-time code given an authenticated patient calling the function (or a clinician of the same organization?).

Extend the exportHealthSummary cloud function to also allow unauthenticated users to call the function, given the request contains a one-time code relevant for the requested userId.

### Additional context

One-time code contains 8 alphanumeric characters.

It may be interesting to decide whether the server just provides the entire URL for the user or whether we let the client decide it and have like a fixed url format for that.

### Code of Conduct

- [x] I agree to follow this project's Code of Conduct and Contributing Guidelines

Diff:
diff --git a/README.md b/README.md
index c490f48a..c509e9f0 100644
--- a/README.md
+++ b/README.md
@@ -640,6 +640,20 @@ Based on [FHIR QuestionnaireResponse](https://hl7.org/fhir/R4B/questionnaireresp
 |item[x]>answer|list of Answer|-|response(s) to the question|
 |item[x]>answer[y]>value|any|-|Value depending on the type of question in the survey, e.g. boolean, integer, date, string, etc|
 
+#### users/$userId$/shareCodes/$shareCodeId$
+
+In some circumstances, a patient may want to share a health summary to a clinician that isn't authenticated via ENGAGE-HF. In this case, they can use share codes to allow access to exporting a health summary temporarily. In this collection, these share codes are stored when generating a share code and retrieved when an export is requested without a properly authenticated account. A share code may be deleted at any time, if it is already expired (by time or by tries).
+
+The `shareCodeId` that is used when calling the `exportHealthSummary` function is the id of a document in this collection.
+
+This collection may only be used by the functions themselves and should neither be visible nor being accessed by client applications or the web frontend. All required functionality is provided using the `shareHealthSummary` and `exportHealthSummary` functions.
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|code|string|-|A 4 character alphanumeric string.|
+|expiresAt|Date|-|An expiration date .|
+|tries|number|-|The amount of REMAINING tries to request a health summary using this share code.|
+
 #### users/$userId$/symptomScores/$symptomScoreId$
 
 Only users with a `patient` role assigned have values in this collection.
@@ -800,7 +814,9 @@ None.
 
 #### Security
 
-This function may be called by admins (for any patient), owners/clinicians (for patients of the same organization), or patients (for themselves).
+This function may be called by admins (for any patient), owners/clinicians (for patients of the same organization), or patients (for themselves). 
+
+Additionally, unauthenticated users can request a health summary using a `shareCodeId` and `shareCode`. A share code expires after 5 minutes and you may only attempt to request a health summary 3 times with one given `shareCodeId`.
 
 #### Input
 
@@ -809,6 +825,8 @@ This function may be called by admins (for any patient), owners/clinicians (for
 |userId|string|-|The patient's user id. Needs to be specified, even if a patient is requesting the health summary for themselves.|
 |language|optional string|e.g. 'en-US'|See [`LocalizedText`](#localizedtext) for specification.|
 |weightUnit|optional string|e.g. '[lb_av]'|A loinc code for the weight unit to be used during generation of the health summary PDF|
+|shareCode|optional string|-|A 4 character alphanumeric code.|
+|shareCodeId|optional string|-|An id identifying the `shareCode`.|
 
 #### Output
 
@@ -842,6 +860,28 @@ If a notification token could not be generated on the device (e.g. due to missin
 
 None.
 
+### shareHealthSummary
+
+`shareHealthSummary` creates a share code for an unauthenticated user to export a health summary using alternative credentials.
+
+#### Security
+
+This function may be called by admins (for any patient), owners/clinicians (for patients of the same organization), or patients (for themselves).
+
+#### Input
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|userId|optional string|-|The patient's user id. If not provided or null, the requesting user's userId is used instead.|
+
+#### Output
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|code|string|-|A 4 character alphanumeric code. It is only checked case-insensitively, so you may change its casing when displaying it to the user.|
+|expiresAt|Date|-|The date the share code expires at. Exporting a health summary with an expired share code will fail. Currently, the expiration date is 5 minutes after requesting the share code.|
+|url|string|-|A url directing at the web frontend to display the health summary that requires input of the code (e.g. to be used for displaying it in the app).|
+
 ### unregisterDevice
 
 Use `unregisterDevice` to remove a notification token associated to a user account. This will only remove it from the authenticated user.
diff --git a/functions/models/src/functions/exportHealthSummary.ts b/functions/models/src/functions/exportHealthSummary.ts
index cd500cd8..289e6503 100644
--- a/functions/models/src/functions/exportHealthSummary.ts
+++ b/functions/models/src/functions/exportHealthSummary.ts
@@ -7,17 +7,37 @@
 //
 
 import { z } from 'zod'
-import { optionalish } from '../helpers/optionalish.js'
+import { QuantityUnit } from '../codes/quantityUnit.js'
+import { optionalish, optionalishDefault } from '../helpers/optionalish.js'
 
 export const exportHealthSummaryInputSchema = z.object({
   userId: z.string(),
   languages: optionalish(z.array(z.string())),
-  weightUnit: optionalish(z.string()),
+  weightUnit: optionalishDefault(
+    z.string().transform((value, context) => {
+      const availableWeightUnits = [QuantityUnit.lbs, QuantityUnit.kg]
+      const unit = availableWeightUnits.find((u) => u.code === value)
+      if (unit === undefined) {
+        const availableUnitsString = availableWeightUnits
+          .map((u) => `'${u.code}'`)
+          .join(', ')
+        context.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: `Invalid weight unit. Must be one of the following: ${availableUnitsString}.`,
+        })
+        return z.NEVER
+      }
+      return unit
+    }),
+    QuantityUnit.lbs,
+  ),
+  shareCode: optionalish(z.string()),
+  shareCodeId: optionalish(z.string()),
 })
+
 export type ExportHealthSummaryInput = z.input<
   typeof exportHealthSummaryInputSchema
 >
-
 export interface ExportHealthSummaryOutput {
   content: string
 }
diff --git a/functions/models/src/functions/shareHealthSummary.ts b/functions/models/src/functions/shareHealthSummary.ts
new file mode 100644
index 00000000..7e5a9c84
--- /dev/null
+++ b/functions/models/src/functions/shareHealthSummary.ts
@@ -0,0 +1,23 @@
+//
+// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
+//
+// SPDX-FileCopyrightText: 2023 Stanford University
+//
+// SPDX-License-Identifier: MIT
+//
+
+import { z } from 'zod'
+import { optionalish } from '../helpers/optionalish.js'
+
+export const shareHealthSummaryInputSchema = z.object({
+  userId: optionalish(z.string()),
+})
+
+export type ShareHealthSummaryInput = z.input<
+  typeof shareHealthSummaryInputSchema
+>
+export interface ShareHealthSummaryOutput {
+  code: string
+  expiresAt: string
+  url: string
+}
diff --git a/functions/models/src/index.ts b/functions/models/src/index.ts
index 6d3a9d1f..ee0cc93b 100644
--- a/functions/models/src/index.ts
+++ b/functions/models/src/index.ts
@@ -39,6 +39,7 @@ export * from './functions/enrollUser.js'
 export * from './functions/exportHealthSummary.js'
 export * from './functions/getUsersInformation.js'
 export * from './functions/registerDevice.js'
+export * from './functions/shareHealthSummary.js'
 export * from './functions/unregisterDevice.js'
 export * from './functions/updateStaticData.js'
 export * from './functions/updateUserInformation.js'
@@ -63,6 +64,7 @@ export * from './types/userMedicationRecommendation.js'
 export * from './types/userMessage.js'
 export * from './types/userMessagesSettings.js'
 export * from './types/userRegistration.js'
+export * from './types/userShareCode.js'
 export * from './types/userType.js'
 export * from './types/video.js'
 export * from './types/videoSection.js'
diff --git a/functions/models/src/types/userShareCode.ts b/functions/models/src/types/userShareCode.ts
new file mode 100644
index 00000000..817d8214
--- /dev/null
+++ b/functions/models/src/types/userShareCode.ts
@@ -0,0 +1,26 @@
+//
+// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
+//
+// SPDX-FileCopyrightText: 2023 Stanford University
+//
+// SPDX-License-Identifier: MIT
+//
+
+import { z } from 'zod'
+import { dateConverter } from '../helpers/dateConverter.js'
+import { SchemaConverter } from '../helpers/schemaConverter.js'
+
+export const userShareCodeConverter = new SchemaConverter({
+  schema: z.object({
+    code: z.string(),
+    tries: z.number(),
+    expiresAt: dateConverter.schema,
+  }),
+  encode: (object) => ({
+    code: object.code,
+    tries: object.tries,
+    expiresAt: dateConverter.encode(object.expiresAt),
+  }),
+})
+
+export type UserShareCode = z.output<typeof userShareCodeConverter.schema>
diff --git a/functions/src/env.ts b/functions/src/env.ts
new file mode 100644
index 00000000..ae893838
--- /dev/null
+++ b/functions/src/env.ts
@@ -0,0 +1,15 @@
+//
+// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
+//
+// SPDX-FileCopyrightText: 2023 Stanford University
+//
+// SPDX-License-Identifier: MIT
+//
+
+/* eslint-disable @typescript-eslint/no-extraneous-class */
+
+export class Env {
+  static get WEB_FRONTEND_BASE_URL() {
+    return `https://${process.env.GCLOUD_PROJECT}.web.app`
+  }
+}
diff --git a/functions/src/functions/exportHealthSummary.test.ts b/functions/src/functions/exportHealthSummary.test.ts
new file mode 100644
index 00000000..37ab6f23
--- /dev/null
+++ b/functions/src/functions/exportHealthSummary.test.ts
@@ -0,0 +1,164 @@
+//
+// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
+//
+// SPDX-FileCopyrightText: 2023 Stanford University
+//
+// SPDX-License-Identifier: MIT
+//
+
+import { UserType } from '@stanfordbdhg/engagehf-models'
+import { expect } from 'chai'
+import { logger } from 'firebase-functions'
+import { it } from 'mocha'
+import { exportHealthSummary } from './exportHealthSummary.js'
+import { shareHealthSummary } from './shareHealthSummary.js'
+import { describeWithEmulators } from '../tests/functions/testEnvironment.js'
+import { expectError } from '../tests/helpers.js'
+
+describeWithEmulators('function: exportHealthSummary', (env) => {
+  it('exports health summary for authenticated user', async () => {
+    const patientId = await env.createUser({
+      type: UserType.patient,
+      organization: 'stanford',
+    })
+
+    const healthSummary = await env.call(
+      exportHealthSummary,
+      { userId: patientId },
+      {
+        uid: patientId,
+        token: { type: UserType.patient, organization: 'stanford' },
+      },
+    )
+
+    logger.info('healthSummary', healthSummary)
+    expect(healthSummary).to.exist
+    expect(healthSummary).to.have.property('content')
+    expect(healthSummary.content).to.be.a('string')
+  })
+
+  it('fails to export health summary for unauthenticated user without share code', async () => {
+    const patientId = await env.createUser({
+      type: UserType.patient,
+      organization: 'stanford',
+    })
+
+    await expectError(
+      async () =>
+        env.call(
+          exportHealthSummary,
+          {
+            userId: patientId,
+          },
+          {},
+        ),
+      (error) => expect(error).to.have.property('code', 'unauthenticated'),
+    )
+  })
+
+  it('exports health summary for unauthenticated user with share code', async () => {
+    const patientId = await env.createUser({
+      type: UserType.patient,
+      organization: 'stanford',
+    })
+
+    const shareCode = await env.call(
+      shareHealthSummary,
+      {},
+      {
+        uid: patientId,
+        token: { type: UserType.patient, organization: 'stanford' },
+      },
+    )
+
+    logger.info('shareCode', shareCode)
+
+    const healthSummary = await env.call(
+      exportHealthSummary,
+      {
+        userId: patientId,
+        shareCodeId: shareCode.url.split('/').at(-1),
+        shareCode: shareCode.code,
+      },
+      {},
+    )
+
+    logger.info('healthSummary', healthSummary)
+    expect(healthSummary).to.exist
+    expect(healthSummary).to.have.property('content')
+    expect(healthSummary.content).to.be.a('string')
+  })
+
+  it('fails to export health summary for unauthenticated user with missing or incorrect share code', async () => {
+    const patientId = await env.createUser({
+      type: UserType.patient,
+      organization: 'stanford',
+    })
+
+    const shareCode = await env.call(
+      shareHealthSummary,
+      {},
+      {
+        uid: patientId,
+        token: { type: UserType.patient, organization: 'stanford' },
+      },
+    )
+
+    logger.info('shareCode', shareCode)
+
+    const shareCodes3 = await env.collections.userShareCodes(patientId).get()
+    expect(shareCodes3.docs).to.have.lengthOf(1)
+    expect(shareCodes3.docs.at(0)?.data()).to.have.property('tries', 3)
+
+    await expectError(
+      async () =>
+        env.call(
+          exportHealthSummary,
+          {
+            userId: patientId,
+            shareCodeId: shareCode.url.split('/').at(-1),
+          },
+          {},
+        ),
+      (error) => expect(error).to.have.property('code', 'unauthenticated'),
+    )
+
+    const shareCodes2 = await env.collections.userShareCodes(patientId).get()
+    expect(shareCodes2.docs).to.have.lengthOf(1)
+    expect(shareCodes2.docs.at(0)?.data()).to.have.property('tries', 2)
+
+    await expectError(
+      async () =>
+        env.call(
+          exportHealthSummary,
+          {
+            userId: patientId,
+            shareCodeId: shareCode.url.split('/').at(-1),
+            shareCode: 'invalid',
+          },
+          {},
+        ),
+      (error) => expect(error).to.have.property('code', 'unauthenticated'),
+    )
+
+    const shareCodes1 = await env.collections.userShareCodes(patientId).get()
+    expect(shareCodes1.docs).to.have.lengthOf(1)
+    expect(shareCodes1.docs.at(0)?.data()).to.have.property('tries', 1)
+
+    await expectError(
+      async () =>
+        env.call(
+          exportHealthSummary,
+          {
+            userId: patientId,
+            shareCodeId: shareCode.url.split('/').at(-1),
+          },
+          {},
+        ),
+      (error) => expect(error).to.have.property('code', 'unauthenticated'),
+    )
+
+    const shareCodes0 = await env.collections.userShareCodes(patientId).get()
+    expect(shareCodes0.docs).to.have.lengthOf(0)
+  })
+})
diff --git a/functions/src/functions/exportHealthSummary.ts b/functions/src/functions/exportHealthSummary.ts
index 5b2b118a..ac76027f 100644
--- a/functions/src/functions/exportHealthSummary.ts
+++ b/functions/src/functions/exportHealthSummary.ts
@@ -9,9 +9,7 @@
 import {
   exportHealthSummaryInputSchema,
   type ExportHealthSummaryOutput,
-  QuantityUnit,
 } from '@stanfordbdhg/engagehf-models'
-import { https } from 'firebase-functions/v2'
 import { validatedOnCall } from './helpers.js'
 import { generateHealthSummary } from '../healthSummary/generate.js'
 import { UserRole } from '../services/credential/credential.js'
@@ -22,35 +20,42 @@ export const exportHealthSummary = validatedOnCall(
   exportHealthSummaryInputSchema,
   async (request): Promise<ExportHealthSummaryOutput> => {
     const factory = getServiceFactory()
-    const credential = factory.credential(request.auth)
 
     const userService = factory.user()
     const user = await userService.getUser(request.data.userId)
 
-    credential.check(
-      UserRole.admin,
-      UserRole.user(request.data.userId),
-      ...(user?.content.organization ?
-        [
-          UserRole.owner(user.content.organization),
-          UserRole.clinician(user.content.organization),
-        ]
-      : []),
-    )
-
-    const weightUnit = [QuantityUnit.lbs, QuantityUnit.kg].find(
-      (unit) => unit.code === request.data.weightUnit,
-    )
-
-    if (request.data.weightUnit !== undefined && weightUnit === undefined)
-      throw new https.HttpsError('invalid-argument', 'Invalid weight unit')
+    try {
+      const credential = factory.credential(request.auth)
+      credential.check(
+        UserRole.admin,
+        UserRole.user(request.data.userId),
+        ...(user?.content.organization ?
+          [
+            UserRole.owner(user.content.organization),
+            UserRole.clinician(user.content.organization),
+          ]
+        : []),
+      )
+    } catch (error: unknown) {
+      if (request.data.shareCodeId !== undefined) {
+        const patientService = factory.patient()
+        const isValid = await patientService.validateShareCode(
+          request.data.userId,
+          request.data.shareCodeId,
+          request.data.shareCode ?? '',
+        )
+        if (!isValid) throw error
+      } else {
+        throw error
+      }
+    }
 
     const now = new Date()
     const healthSummaryService = factory.healthSummary()
     const data = await healthSummaryService.getHealthSummaryData(
       request.data.userId,
       now,
-      weightUnit ?? QuantityUnit.lbs,
+      request.data.weightUnit,
     )
     const pdf = generateHealthSummary(data, {
       languages:
diff --git a/functions/src/functions/shareHealthSummary.test.ts b/functions/src/functions/shareHealthSummary.test.ts
new file mode 100644
index 00000000..ed1a050d
--- /dev/null
+++ b/functions/src/functions/shareHealthSummary.test.ts
@@ -0,0 +1,68 @@
+//
+// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
+//
+// SPDX-FileCopyrightText: 2023 Stanford University
+//
+// SPDX-License-Identifier: MIT
+//
+
+import { UserType } from '@stanfordbdhg/engagehf-models'
+import { expect } from 'chai'
+import { logger } from 'firebase-functions'
+import { it } from 'mocha'
+import { shareHealthSummary } from './shareHealthSummary.js'
+import { describeWithEmulators } from '../tests/functions/testEnvironment.js'
+
+describeWithEmulators('function: shareHealthSummary', (env) => {
+  it('should create a new share code for patient', async () => {
+    const patientId = await env.createUser({
+      type: UserType.patient,
+      organization: 'stanford',
+    })
+
+    const shareCode = await env.call(
+      shareHealthSummary,
+      {},
+      {
+        uid: patientId,
+        token: { type: UserType.patient, organization: 'stanford' },
+      },
+    )
+
+    logger.info('shareCode', shareCode)
+    expect(shareCode).to.exist
+    expect(shareCode).to.have.property('code')
+    expect(shareCode.code).to.be.a('string')
+    expect(shareCode.code).to.have.length(4)
+    expect(shareCode).to.have.property('expiresAt')
+  })
+
+  it('should create a new share code for clinician', async () => {
+    const clinicianId = await env.createUser({
+      type: UserType.clinician,
+      organization: 'stanford',
+    })
+    const patientId = await env.createUser({
+      type: UserType.patient,
+      organization: 'stanford',
+    })
+
+    const shareCode = await env.call(
+      shareHealthSummary,
+      {
+        userId: patientId,
+      },
+      {
+        uid: clinicianId,
+        token: { type: UserType.clinician, organization: 'stanford' },
+      },
+    )
+
+    logger.info('shareCode', shareCode)
+    expect(shareCode).to.exist
+    expect(shareCode).to.have.property('code')
+    expect(shareCode.code).to.be.a('string')
+    expect(shareCode.code).to.have.length(4)
+    expect(shareCode).to.have.property('expiresAt')
+  })
+})
diff --git a/functions/src/functions/shareHealthSummary.ts b/functions/src/functions/shareHealthSummary.ts
new file mode 100644
index 00000000..5e335c39
--- /dev/null
+++ b/functions/src/functions/shareHealthSummary.ts
@@ -0,0 +1,48 @@
+//
+// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
+//
+// SPDX-FileCopyrightText: 2023 Stanford University
+//
+// SPDX-License-Identifier: MIT
+//
+
+import {
+  dateConverter,
+  shareHealthSummaryInputSchema,
+  type ShareHealthSummaryOutput,
+} from '@stanfordbdhg/engagehf-models'
+import { validatedOnCall } from './helpers.js'
+import { Env } from '../env.js'
+import { UserRole } from '../services/credential/credential.js'
+import { getServiceFactory } from '../services/factory/getServiceFactory.js'
+
+export const shareHealthSummary = validatedOnCall(
+  'shareHealthSummary',
+  shareHealthSummaryInputSchema,
+  async (request): Promise<ShareHealthSummaryOutput> => {
+    const factory = getServiceFactory()
+    const credential = factory.credential(request.auth)
+    const userId = request.data.userId ?? credential.userId
+
+    await credential.checkAsync(
+      () => [UserRole.admin, UserRole.user(userId)],
+      async () => {
+        const user = await factory.user().getUser(userId)
+        return user?.content.organization !== undefined ?
+            [
+              UserRole.owner(user.content.organization),
+              UserRole.clinician(user.content.organization),
+            ]
+          : []
+      },
+    )
+
+    const shareCodeDocument = await factory.patient().createShareCode(userId)
+    const url = `${Env.WEB_FRONTEND_BASE_URL}/patients/${userId}/healthSummary/${shareCodeDocument.id}`
+    return {
+      code: shareCodeDocument.content.code,
+      expiresAt: dateConverter.encode(shareCodeDocument.content.expiresAt),
+      url: url,
+    }
+  },
+)
diff --git a/functions/src/index.ts b/functions/src/index.ts
index c375a266..6ded2528 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -28,6 +28,7 @@ export * from './functions/onSchedule.js'
 export * from './functions/onUserDocumentWritten.js'
 export * from './functions/onUserWritten.js'
 export * from './functions/registerDevice.js'
+export * from './functions/shareHealthSummary.js'
 export * from './functions/unregisterDevice.js'
 export * from './functions/updateStaticData.js'
 export * from './functions/updateUserInformation.js'
diff --git a/functions/src/services/database/collections.ts b/functions/src/services/database/collections.ts
index a79e0162..0adec885 100644
--- a/functions/src/services/database/collections.ts
+++ b/functions/src/services/database/collections.ts
@@ -22,6 +22,7 @@ import {
   userDeviceConverter,
   userMedicationRecommendationConverter,
   userMessageConverter,
+  userShareCodeConverter,
   videoConverter,
   videoSectionConverter,
   type UserObservationCollection,
@@ -272,6 +273,14 @@ export class CollectionsService {
       )
   }
 
+  userShareCodes(userId: string) {
+    return this.firestore
+      .collection('users')
+      .doc(userId)
+      .collection('shareCodes')
+      .withConverter(new DatabaseConverter(userShareCodeConverter.value))
+  }
+
   userSymptomScores(userId: string) {
     return this.firestore
       .collection('users')
diff --git a/functions/src/services/patient/databasePatientService.ts b/functions/src/services/patient/databasePatientService.ts
index 83644b59..52ef1079 100644
--- a/functions/src/services/patient/databasePatientService.ts
+++ b/functions/src/services/patient/databasePatientService.ts
@@ -9,6 +9,7 @@
 import { isDeepStrictEqual } from 'util'
 import {
   advanceDateByDays,
+  advanceDateByMinutes,
   compactMap,
   type FHIRAllergyIntolerance,
   type FHIRAppointment,
@@ -21,8 +22,15 @@ import {
   type UserMedicationRecommendation,
   UserMedicationRecommendationType,
   UserObservationCollection,
+  type UserShareCode,
 } from '@stanfordbdhg/engagehf-models'
+import {
+  FieldValue,
+  type QueryDocumentSnapshot,
+  type Transaction,
+} from 'firebase-admin/firestore'
 import { type PatientService } from './patientService.js'
+import { type CollectionsService } from '../database/collections.js'
 import {
   type Document,
   type DatabaseService,
@@ -341,4 +349,87 @@ export class DatabasePatientService implements PatientService {
         return 7
     }
   }
+
+  // Share Code
+
+  async createShareCode(userId: string): Promise<Document<UserShareCode>> {
+    const now = new Date()
+    const object: UserShareCode = {
+      code: Math.random().toString(36).substring(2, 6),
+      tries: 3,
+      expiresAt: advanceDateByMinutes(now, 5),
+    }
+
+    const ref = await this.runShareCodeTransaction(
+      userId,
+      now,
+      (collections, transaction) => {
+        const ref = collections.userShareCodes(userId).doc()
+        transaction.create(ref, object)
+        return ref
+      },
+    )
+
+    return {
+      id: ref.id,
+      path: ref.path,
+      lastUpdate: now,
+      content: object,
+    }
+  }
+
+  async validateShareCode(
+    userId: string,
+    documentId: string,
+    code: string,
+  ): Promise<boolean> {
+    const now = new Date()
+
+    return this.runShareCodeTransaction(userId, now, (_, transaction, docs) => {
+      const shareCodeDoc = docs.find((doc) => doc.id === documentId)
+      if (shareCodeDoc === undefined) return false
+      const shareCodeData = shareCodeDoc.data()
+      if (shareCodeData.code.toLowerCase() !== code.toLowerCase()) {
+        if (shareCodeData.tries > 1) {
+          transaction.update(
+            shareCodeDoc.ref,
+            'tries',
+            FieldValue.increment(-1),
+          )
+        } else {
+          transaction.delete(shareCodeDoc.ref)
+        }
+        return false
+      }
+      return true
+    })
+  }
+
+  private async runShareCodeTransaction<T>(
+    userId: string,
+    now: Date,
+    perform: (
+      collections: CollectionsService,
+      transaction: Transaction,
+      codes: Array<QueryDocumentSnapshot<UserShareCode>>,
+    ) => Promise<T> | T,
+  ): Promise<T> {
+    return this.databaseService.runTransaction(
+      async (collections, transaction) => {
+        const existingCodes = await collections.userShareCodes(userId).get()
+        const nonExpiredCodes: Array<QueryDocumentSnapshot<UserShareCode>> = []
+
+        for (const existingDoc of existingCodes.docs) {
+          const existingCode = existingDoc.data()
+          if (existingCode.expiresAt < now) {
+            transaction.delete(existingDoc.ref)
+          } else {
+            nonExpiredCodes.push(existingDoc)
+          }
+        }
+
+        return perform(collections, transaction, nonExpiredCodes)
+      },
+    )
+  }
 }
diff --git a/functions/src/services/patient/patientService.mock.ts b/functions/src/services/patient/patientService.mock.ts
index 83817442..d6a7c327 100644
--- a/functions/src/services/patient/patientService.mock.ts
+++ b/functions/src/services/patient/patientService.mock.ts
@@ -18,6 +18,7 @@ import {
   QuantityUnit,
   SymptomScore,
   type UserMedicationRecommendation,
+  type UserShareCode,
 } from '@stanfordbdhg/engagehf-models'
 import { type PatientService } from './patientService.js'
 import { mockQuestionnaireResponse } from '../../tests/mocks/questionnaireResponse.js'
@@ -355,4 +356,27 @@ export class MockPatientService implements PatientService {
   ): Promise<void> {
     return
   }
+
+  // Share Code
+
+  async createShareCode(userId: string): Promise<Document<UserShareCode>> {
+    return {
+      id: '123',
+      lastUpdate: new Date(),
+      path: 'users/123/shareCode/123',
+      content: {
+        code: '12345678',
+        tries: 0,
+        expiresAt: new Date(2024, 2, 2, 12, 30),
+      },
+    }
+  }
+
+  async validateShareCode(
+    userId: string,
+    documentId: string,
+    code: string,
+  ): Promise<boolean> {
+    return false
+  }
 }
diff --git a/functions/src/services/patient/patientService.ts b/functions/src/services/patient/patientService.ts
index a99e2347..da2f3e04 100644
--- a/functions/src/services/patient/patientService.ts
+++ b/functions/src/services/patient/patientService.ts
@@ -15,6 +15,7 @@ import {
   type FHIRQuestionnaireResponse,
   type SymptomScore,
   type UserMedicationRecommendation,
+  type UserShareCode,
 } from '@stanfordbdhg/engagehf-models'
 import { type Document } from '../database/databaseService.js'
 
@@ -98,4 +99,13 @@ export interface PatientService {
     symptomScoreId: string,
     symptomScore: SymptomScore | undefined,
   ): Promise<void>
+
+  // Share Code
+
+  createShareCode(userId: string): Promise<Document<UserShareCode>>
+  validateShareCode(
+    userId: string,
+    documentId: string,
+    code: string,
+  ): Promise<boolean>
 }
diff --git a/functions/src/tests/functions/testEnvironment.ts b/functions/src/tests/functions/testEnvironment.ts
index 9c4842b7..94857428 100644
--- a/functions/src/tests/functions/testEnvironment.ts
+++ b/functions/src/tests/functions/testEnvironment.ts
@@ -80,7 +80,7 @@ export class EmulatorTestEnvironment {
   async call<Input, Output>(
     func: CallableFunction<Input, Output>,
     input: Input,
-    auth: { uid: string; token?: object },
+    auth: { uid?: string; token?: object },
   ): Promise<Output> {
     const wrapped = this.wrapper.wrap(func)
     return wrapped({


Reviews:
user: login: PSchmiedmayer
body: @pauljohanneskraft Thank you for working on this! Agree, building the URL in the function should be the way to go, makes it easier in the long run and more flexible.

It might make sense to email @garricko (or if you already have thoughts here) about the deployment setup.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:06:43Z
user: login: PSchmiedmayer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T19:00:57Z
user: login: pauljohanneskraft
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T19:03:18Z
user: login: PSchmiedmayer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T19:10:19Z
user: login: pauljohanneskraft
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T19:15:02Z
user: login: pauljohanneskraft
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T21:44:37Z
user: login: PSchmiedmayer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T23:46:35Z
user: login: arkadiuszbachorski
body: I reviewed it from the dashboard integration point of view - looks good! 🚀 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T11:00:03Z
user: login: PSchmiedmayer
body: Thank you for all the work here; as long as @NikolaiMadlener & @eldcn also sign off on the work from the client sides, I am happy to see it merged.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T21:01:49Z
user: login: NikolaiMadlener
body: Thanks @pauljohanneskraft, looks good and had no issues integrating it into the iOS client 🚀 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T08:24:25Z
user: login: NikolaiMadlener
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T16:12:23Z
user: login: pauljohanneskraft
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T22:18:50Z
user: login: arkadiuszbachorski
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T17:57:53Z
user: login: PSchmiedmayer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T21:57:33Z
user: login: PSchmiedmayer
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-20T21:57:57Z
user: login: pauljohanneskraft
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-21T11:17:09Z

Review comments:
diff_hunk: @@ -12,3 +12,8 @@ export namespace Flags {
   export const isEmulator = process.env.FUNCTIONS_EMULATOR === 'true'
   export const requireInvitationOrganizationToMatchSsoProviderId = false
 }
+
+export namespace Constants {
+  export const webFrontendBaseUrl =
+    process.env.WEB_FRONTEND_BASE_URL ?? 'https://engagehf-mock-base-url.com'
path: functions/src/flags.ts
user: login: PSchmiedmayer
body: What is the best way that we can eventually inject this in the production setup. I suspect something we can set on the Firebase side? Otherwise, would it make sense to make this part of the static data or seeding mechanism?
created_at: 2025-03-05T19:00:56Z
author_association: MEMBER
diff_hunk: @@ -12,3 +12,8 @@ export namespace Flags {
   export const isEmulator = process.env.FUNCTIONS_EMULATOR === 'true'
   export const requireInvitationOrganizationToMatchSsoProviderId = false
 }
+
+export namespace Constants {
+  export const webFrontendBaseUrl =
+    process.env.WEB_FRONTEND_BASE_URL ?? 'https://engagehf-mock-base-url.com'
path: functions/src/flags.ts
user: login: pauljohanneskraft
body: My assumption would be to inject it in the CI pipeline, does that make sense? Then, we could simply inject one of three different urls into the system depending on the workflow `environment`, right?
created_at: 2025-03-05T19:03:17Z
author_association: COLLABORATOR
diff_hunk: @@ -12,3 +12,8 @@ export namespace Flags {
   export const isEmulator = process.env.FUNCTIONS_EMULATOR === 'true'
   export const requireInvitationOrganizationToMatchSsoProviderId = false
 }
+
+export namespace Constants {
+  export const webFrontendBaseUrl =
+    process.env.WEB_FRONTEND_BASE_URL ?? 'https://engagehf-mock-base-url.com'
path: functions/src/flags.ts
user: login: PSchmiedmayer
body: Would that persist when we deploy it?
created_at: 2025-03-05T19:10:19Z
author_association: MEMBER
diff_hunk: @@ -12,3 +12,8 @@ export namespace Flags {
   export const isEmulator = process.env.FUNCTIONS_EMULATOR === 'true'
   export const requireInvitationOrganizationToMatchSsoProviderId = false
 }
+
+export namespace Constants {
+  export const webFrontendBaseUrl =
+    process.env.WEB_FRONTEND_BASE_URL ?? 'https://engagehf-mock-base-url.com'
path: functions/src/flags.ts
user: login: pauljohanneskraft
body: Mhmm, okay - valid point - we will need some way to persist this for the Twilio setup as well though in #238, so we can technically just inject a source file containing all these constants, right?
created_at: 2025-03-05T19:15:01Z
author_association: COLLABORATOR
diff_hunk: @@ -12,3 +12,8 @@ export namespace Flags {
   export const isEmulator = process.env.FUNCTIONS_EMULATOR === 'true'
   export const requireInvitationOrganizationToMatchSsoProviderId = false
 }
+
+export namespace Constants {
+  export const webFrontendBaseUrl =
+    process.env.WEB_FRONTEND_BASE_URL ?? 'https://engagehf-mock-base-url.com'
path: functions/src/flags.ts
user: login: pauljohanneskraft
body: I changed it to use https://firebase.google.com/docs/functions/config-env?gen=2nd to inject the environment values. It still builds and runs fine even without me injecting values, which is somewhat weird to me, since the documentation makes it seem that this wouldn't be the case... but let's see
created_at: 2025-03-05T21:44:37Z
author_association: COLLABORATOR
diff_hunk: @@ -12,3 +12,8 @@ export namespace Flags {
   export const isEmulator = process.env.FUNCTIONS_EMULATOR === 'true'
   export const requireInvitationOrganizationToMatchSsoProviderId = false
 }
+
+export namespace Constants {
+  export const webFrontendBaseUrl =
+    process.env.WEB_FRONTEND_BASE_URL ?? 'https://engagehf-mock-base-url.com'
path: functions/src/flags.ts
user: login: PSchmiedmayer
body: Thank you!
created_at: 2025-03-05T23:46:35Z
author_association: MEMBER
diff_hunk: @@ -844,6 +862,29 @@ If a notification token could not be generated on the device (e.g. due to missin
 
 None.
 
+### shareHealthSummary
+
+`shareHealthSummary` creates a share code for an unauthenticated user to export a health summary using alternative credentials.
+
+#### Security
+
+This function may be called by admins (for any patient), owners/clinicians (for patients of the same organization), or patients (for themselves).
+
+#### Input
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|userId|optional string|-|The patient's user id. If not provided or null, the requesting user's userId is used instead.|
+
+#### Output
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|code|string|-|An 8 character alphanumeric code. It is only checked case-insensitively, so you may change its casing when displaying it to the user.|
+|expiresAt|Date|-|The date the share code expires at. Exporting a health summary with an expired share code will fail. Currently, the expiration date is 5 minutes after requesting the share code.|
path: README.md
user: login: NikolaiMadlener
body: @pauljohanneskraft I've seen the function actually returning a date 10 minutes after the current time, not 5.
created_at: 2025-03-13T16:12:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -844,6 +862,29 @@ If a notification token could not be generated on the device (e.g. due to missin
 
 None.
 
+### shareHealthSummary
+
+`shareHealthSummary` creates a share code for an unauthenticated user to export a health summary using alternative credentials.
+
+#### Security
+
+This function may be called by admins (for any patient), owners/clinicians (for patients of the same organization), or patients (for themselves).
+
+#### Input
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|userId|optional string|-|The patient's user id. If not provided or null, the requesting user's userId is used instead.|
+
+#### Output
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|code|string|-|An 8 character alphanumeric code. It is only checked case-insensitively, so you may change its casing when displaying it to the user.|
+|expiresAt|Date|-|The date the share code expires at. Exporting a health summary with an expired share code will fail. Currently, the expiration date is 5 minutes after requesting the share code.|
path: README.md
user: login: pauljohanneskraft
body: I changed it to 5 minutes now - and also removed the fullUrl property.
created_at: 2025-03-13T22:18:50Z
author_association: COLLABORATOR
diff_hunk: @@ -842,6 +860,28 @@ If a notification token could not be generated on the device (e.g. due to missin
 
 None.
 
+### shareHealthSummary
+
+`shareHealthSummary` creates a share code for an unauthenticated user to export a health summary using alternative credentials.
+
+#### Security
+
+This function may be called by admins (for any patient), owners/clinicians (for patients of the same organization), or patients (for themselves).
+
+#### Input
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|userId|optional string|-|The patient's user id. If not provided or null, the requesting user's userId is used instead.|
+
+#### Output
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|code|string|-|A 4 character alphanumeric code. It is only checked case-insensitively, so you may change its casing when displaying it to the user.|
+|expiresAt|Date|-|The date the share code expires at. Exporting a health summary with an expired share code will fail. Currently, the expiration date is 5 minutes after requesting the share code.|
+|url|string|-|A url directing at the web frontend to display the health summary that requires input of the code (e.g. to be used for displaying it in the app).|
path: README.md
user: login: arkadiuszbachorski
body: Might be worth specifying this is meant to be absolute URL. For local environment this returns just path, because `WEB_FRONTEND_BASE_URL` is empty. 
created_at: 2025-03-18T17:55:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,17 +7,38 @@
 //
 
 import { z } from 'zod'
+import { QuantityUnit } from '../codes/quantityUnit.js'
 import { optionalish } from '../helpers/optionalish.js'
 
 export const exportHealthSummaryInputSchema = z.object({
   userId: z.string(),
   languages: optionalish(z.array(z.string())),
-  weightUnit: optionalish(z.string()),
+  weightUnit: optionalish(z.string()).transform((value, context) => {
+    if (value === undefined) return undefined
+    const availableWeightUnits = [QuantityUnit.lbs, QuantityUnit.kg]
+    const unit = availableWeightUnits.find((u) => u.code === value)
+    if (unit === undefined) {
+      const availableUnitsString = availableWeightUnits
+        .map((u) => `'${u.code}'`)
+        .join(', ')
+      context.addIssue({
+        code: z.ZodIssueCode.custom,
+        message:
+          'Invalid weight unit. Must be one of the following: ' +
+          availableUnitsString +
+          '.',
path: functions/models/src/functions/exportHealthSummary.ts
user: login: arkadiuszbachorski
body: ```suggestion
          `Invalid weight unit. Must be one of the following: ${availableUnitsString}.`
```
created_at: 2025-03-18T17:56:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,17 +7,38 @@
 //
 
 import { z } from 'zod'
+import { QuantityUnit } from '../codes/quantityUnit.js'
 import { optionalish } from '../helpers/optionalish.js'
 
 export const exportHealthSummaryInputSchema = z.object({
   userId: z.string(),
   languages: optionalish(z.array(z.string())),
-  weightUnit: optionalish(z.string()),
+  weightUnit: optionalish(z.string()).transform((value, context) => {
path: functions/models/src/functions/exportHealthSummary.ts
user: login: arkadiuszbachorski
body: 🚀 
created_at: 2025-03-18T17:56:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -842,6 +860,28 @@ If a notification token could not be generated on the device (e.g. due to missin
 
 None.
 
+### shareHealthSummary
+
+`shareHealthSummary` creates a share code for an unauthenticated user to export a health summary using alternative credentials.
+
+#### Security
+
+This function may be called by admins (for any patient), owners/clinicians (for patients of the same organization), or patients (for themselves).
+
+#### Input
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|userId|optional string|-|The patient's user id. If not provided or null, the requesting user's userId is used instead.|
+
+#### Output
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|code|string|-|A 4 character alphanumeric code. It is only checked case-insensitively, so you may change its casing when displaying it to the user.|
+|expiresAt|Date|-|The date the share code expires at. Exporting a health summary with an expired share code will fail. Currently, the expiration date is 5 minutes after requesting the share code.|
+|url|string|-|A url directing at the web frontend to display the health summary that requires input of the code (e.g. to be used for displaying it in the app).|
path: README.md
user: login: PSchmiedmayer
body: We should probably set that to localhost for local testing setups @pauljohanneskraft 
created_at: 2025-03-20T21:57:33Z
author_association: MEMBER
diff_hunk: @@ -842,6 +860,28 @@ If a notification token could not be generated on the device (e.g. due to missin
 
 None.
 
+### shareHealthSummary
+
+`shareHealthSummary` creates a share code for an unauthenticated user to export a health summary using alternative credentials.
+
+#### Security
+
+This function may be called by admins (for any patient), owners/clinicians (for patients of the same organization), or patients (for themselves).
+
+#### Input
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|userId|optional string|-|The patient's user id. If not provided or null, the requesting user's userId is used instead.|
+
+#### Output
+
+|Property|Type|Values|Comments|
+|-|-|-|-|
+|code|string|-|A 4 character alphanumeric code. It is only checked case-insensitively, so you may change its casing when displaying it to the user.|
+|expiresAt|Date|-|The date the share code expires at. Exporting a health summary with an expired share code will fail. Currently, the expiration date is 5 minutes after requesting the share code.|
+|url|string|-|A url directing at the web frontend to display the health summary that requires input of the code (e.g. to be used for displaying it in the app).|
path: README.md
user: login: pauljohanneskraft
body: will be tackled in: https://github.com/StanfordBDHG/ENGAGE-HF-Firebase/issues/250
created_at: 2025-03-21T11:17:09Z
author_association: COLLABORATOR

PR comments:
user: login: pauljohanneskraft
created_at: 2025-03-05T11:51:06Z
body: I'm still in the process of writing more documentation and adding more tests, but I feel like this is already some progress that we can talk about and discuss whether this is the right way of approaching this feature. From a "code that will actually be run in production" perspective, this PR can be seen as done, only testing code and documentation is missing.

Additionally, I'd like to talk with you about whether we should only provide the share code itself +expiresAt (current state) or whether we should actually build the entire URL for client applications. I'm bringing this up, since app updates may be delayed quite a bit, so determining the full URL on the server and simply already building them in the `shareHealthSummary` function may give us more flexibility for future changes. @PSchmiedmayer @arkadiuszbachorski 
author_association: COLLABORATOR
user: login: pauljohanneskraft
created_at: 2025-03-05T14:11:39Z
body: > It might make sense to email @garricko (or if you already have thoughts here) about the deployment setup.

Mhmmm, I thought we may be able to actually simply deploy these urls as part of the web dashboard setup - what do you think? Since we already have that deployment and it would simply be calling a function and then displaying the PDF, right? or do you want to have this hosted separately? It would add some complexity to the frontend by having some authenticated parts and this one thing that doesn't require authentication, but that shouldn't be a problem, right? @PSchmiedmayer 
author_association: COLLABORATOR
user: login: PSchmiedmayer
created_at: 2025-03-05T14:24:11Z
body: Oh, sorry, I might have misunderstood things. You are right that we can just deploy this as an additive change to the current setup 👍
author_association: MEMBER
user: login: arkadiuszbachorski
created_at: 2025-03-21T08:48:02Z
body: @pauljohanneskraft Can we merge it soon? I'd like to merge FE part as well, just to make sure we can test whole flow on mobile devices. 
author_association: CONTRIBUTOR
user: login: pauljohanneskraft
created_at: 2025-03-21T11:16:32Z
body: Since I unfortunately cannot quickly implement the base-url issue, I have created this issue instead: https://github.com/StanfordBDHG/ENGAGE-HF-Firebase/issues/250. With this, I am fine with merging and will soon follow up with a PR resolving the remaining issues. @arkadiuszbachorski @PSchmiedmayer  
author_association: COLLABORATOR
user: login: PSchmiedmayer
created_at: 2025-03-21T12:17:28Z
body: Sounds great, thank you @pauljohanneskraft 🚀
author_association: MEMBER