improvement: QRcode and shared link added to cases inorder to make it possible for staff users to share a case info with other staffs or other clinics.

This commit is contained in:
2026-07-14 21:07:05 +03:30
parent 953e609fb8
commit 08df2f71ea
41 changed files with 1535 additions and 83 deletions

View File

@@ -41,6 +41,8 @@
"passport-local": "^1.0.0",
"pg": "^8.18.0",
"prisma": "^6.19.2",
"qrcode": "^1.5.4",
"react-qr-code": "^2.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"styled-components": "^6.3.11",
@@ -59,6 +61,7 @@
"@types/multer": "^2.1.0",
"@types/node": "^22.10.7",
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/styled-components": "^5.1.36",
@@ -6290,6 +6293,16 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.5.tgz",
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -7540,7 +7553,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7550,7 +7562,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -8163,7 +8174,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -8441,7 +8451,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -8454,7 +8463,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -8777,6 +8785,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -8890,6 +8907,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -9008,7 +9031,6 @@
"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,
"license": "MIT"
},
"node_modules/empathic": {
@@ -10052,7 +10074,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -10670,7 +10691,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12875,7 +12895,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13189,6 +13208,15 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/polished": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
@@ -13607,6 +13635,133 @@
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -13816,6 +13971,19 @@
"react-dom": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-qr-code": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/react-qr-code/-/react-qr-code-2.2.0.tgz",
"integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1",
"qrcode-generator": "^2.0.4"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-redux": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
@@ -14041,7 +14209,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14057,6 +14224,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -14341,6 +14514,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -14647,7 +14826,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,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -14678,7 +14856,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -16169,6 +16346,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -16190,7 +16373,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",

View File

@@ -80,8 +80,6 @@
"@types/multer": "^2.1.0",
"@types/node": "^22.10.7",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/styled-components": "^5.1.36",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "lab_cases" ADD COLUMN "accessToken" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "lab_cases_accessToken_key" ON "lab_cases"("accessToken");

View File

@@ -217,6 +217,7 @@ model LabCase {
sentAt DateTime?
dueDate DateTime?
isImportant Boolean @default(false)
accessToken String? @unique
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
details LabCaseDetail[]

View File

@@ -42,6 +42,7 @@ export const ErrorCode = {
PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF',
PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF',
PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND',
LAB_CASE_ACCESS_DENIED: 'LAB_CASE_ACCESS_DENIED',
// Validation
VALIDATION_FAILED: 'VALIDATION_FAILED',

View File

@@ -0,0 +1,11 @@
import { randomBytes } from 'crypto';
export function generateLabCaseAccessToken(): string {
return randomBytes(32).toString('base64url');
}
export function buildLabCaseShareUrl(accessToken: string, locale = 'en'): string {
const appUrl = (process.env.FRONTEND_URL || 'http://localhost:3001').replace(/\/$/, '');
const normalizedLocale = locale.trim() || 'en';
return `${appUrl}/${normalizedLocale}/lab-case/${accessToken}`;
}

View File

@@ -3,13 +3,18 @@ import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { NotificationsModule } from '../notifications/notifications.module';
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
import { TasksModule } from '../tasks/tasks.module';
import { CatalogModule } from '../catalog/catalog.module';
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { CasesController } from './cases.controller';
import { CasesService } from './cases.service';
import { LabCaseAccessController } from './lab-case-access.controller';
import { LabCaseAccessService } from './lab-case-access.service';
@Module({
imports: [ProsthesisCatalogModule, NotificationsModule],
controllers: [CasesController],
providers: [CasesService, PrismaService, LabOrgGuard],
exports: [CasesService],
imports: [ProsthesisCatalogModule, NotificationsModule, TasksModule, CatalogModule, LabCaseCommentsModule],
controllers: [LabCaseAccessController, CasesController],
providers: [CasesService, LabCaseAccessService, PrismaService, LabOrgGuard],
exports: [CasesService, LabCaseAccessService],
})
export class CasesModule {}

View File

@@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { LabCaseAccessService } from './lab-case-access.service';
const labCaseListInclude = {
treatment: {
@@ -95,6 +96,7 @@ export class CasesService {
private readonly prosthesisCatalog: ProsthesisCatalogService,
private readonly catalogLabels: CatalogLabelService,
private readonly labCaseActivity: LabCaseActivityService,
private readonly labCaseAccess: LabCaseAccessService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -605,6 +607,12 @@ export class CasesService {
locale,
);
const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
const accessToken = lc.sentAt
? await this.labCaseAccess.ensureAccessToken(lc.id)
: null;
const shareUrl = accessToken
? this.labCaseAccess.buildShareUrl(accessToken, locale)
: null;
return {
id: lc.id,
@@ -612,6 +620,7 @@ export class CasesService {
dueDate: lc.dueDate?.toISOString() ?? null,
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
isImportant: lc.isImportant,
shareUrl,
clinic: lc.treatment.organization,
patient: lc.treatment.patient,
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,

View File

@@ -0,0 +1,89 @@
import { Body, Controller, Get, HttpStatus, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AppException, ErrorCode } from '../../common/errors';
import { LabCaseAccessService } from './lab-case-access.service';
import {
CreateLabCaseCommentDto,
SetCommentVisibilityDto,
} from '../lab-case-comments/dto/lab-case-comment.dto';
@ApiTags('lab-cases')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('lab-cases/access')
export class LabCaseAccessController {
constructor(private readonly accessService: LabCaseAccessService) {}
private requireOrganizationId(req: { user?: { organizationId?: string } }): string {
const organizationId = req.user?.organizationId;
if (!organizationId) {
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
}
return organizationId;
}
@Get(':token')
@ApiOperation({ summary: 'Resolve a lab case share link for the current user' })
resolve(@Param('token') token: string, @Req() req) {
return this.accessService.resolveByToken(
token,
req.user.id,
this.requireOrganizationId(req),
req.user.language,
);
}
@Get(':token/tasks')
@ApiOperation({ summary: 'List all tasks for a lab case share link' })
listTasks(@Param('token') token: string, @Req() req) {
return this.accessService.listTasksByToken(
token,
req.user.id,
this.requireOrganizationId(req),
req.user.language,
);
}
@Get(':token/comments')
@ApiOperation({ summary: 'List comments for a lab case share link' })
listComments(@Param('token') token: string, @Req() req) {
return this.accessService.listCommentsByToken(
token,
req.user.id,
this.requireOrganizationId(req),
);
}
@Post(':token/comments')
@ApiOperation({ summary: 'Add a comment via a lab case share link' })
addComment(
@Param('token') token: string,
@Body() dto: CreateLabCaseCommentDto,
@Req() req,
) {
return this.accessService.addCommentByToken(
token,
req.user.id,
this.requireOrganizationId(req),
dto,
);
}
@Patch(':token/comments/:commentId/visibility')
@ApiOperation({ summary: 'Toggle clinic visibility for a lab comment on a share link' })
setCommentVisibility(
@Param('token') token: string,
@Param('commentId') commentId: string,
@Body() dto: SetCommentVisibilityDto,
@Req() req,
) {
return this.accessService.setCommentVisibilityByToken(
token,
commentId,
req.user.id,
this.requireOrganizationId(req),
dto.visibleToClinic,
);
}
}

View File

@@ -0,0 +1,329 @@
import {
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CatalogEntityKind, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
buildLabCaseShareUrl,
generateLabCaseAccessToken,
} from '../../common/lab-case-access-token';
import { AppException, ErrorCode } from '../../common/errors';
import { hasEffectivePermission } from '../../common/membership-permissions';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
import { normalizeTaskTeeth } from './lab-case-task.util';
import { isLabCaseOverdue } from '../../common/lab-case-due-date';
import { TasksService } from '../tasks/tasks.service';
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
const accessCaseInclude = {
treatment: {
select: {
providerUserId: true,
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true } },
appointment: { select: { providerUserId: true } },
},
},
sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
tasks: { select: { status: true } },
} satisfies Prisma.LabCaseInclude;
type ResolvedAccess =
| {
kind: 'lab';
canEditTaskStatus: boolean;
canPostComments: boolean;
canToggleCommentVisibility: boolean;
}
| {
kind: 'clinic';
canEditTaskStatus: false;
canPostComments: boolean;
canToggleCommentVisibility: false;
}
| { kind: 'denied' };
@Injectable()
export class LabCaseAccessService {
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
private readonly tasksService: TasksService,
private readonly commentsService: LabCaseCommentsService,
) {}
async ensureAccessToken(labCaseId: string): Promise<string | null> {
const labCase = await this.prisma.labCase.findUnique({
where: { id: labCaseId },
select: { sentAt: true, accessToken: true },
});
if (!labCase?.sentAt) return null;
if (labCase.accessToken) return labCase.accessToken;
const accessToken = generateLabCaseAccessToken();
await this.prisma.labCase.update({
where: { id: labCaseId },
data: { accessToken },
});
return accessToken;
}
buildShareUrl(accessToken: string, locale?: string | null): string {
return buildLabCaseShareUrl(accessToken, locale ?? 'en');
}
async resolveByToken(
token: string,
actorUserId: string,
organizationId: string,
localeInput?: string | null,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind === 'denied') {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [
...new Set(
(
await this.prisma.labCaseTask.findMany({
where: { labCaseId: labCase.id },
select: { prosthesisTypeCode: true },
})
)
.map((t) => t.prosthesisTypeCode)
.filter(Boolean),
),
];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
const prosthesisGroups = await this.buildProsthesisGroups(labCase.id, prosthesisLabels);
return {
success: true,
data: {
labCaseId: labCase.id,
accessMode: access.kind,
canEditTaskStatus: access.canEditTaskStatus,
canPostComments: access.canPostComments,
canToggleCommentVisibility: access.canToggleCommentVisibility,
shareUrl: this.buildShareUrl(token, locale),
sentAt: labCase.sentAt?.toISOString() ?? null,
dueDate: labCase.dueDate?.toISOString() ?? null,
isOverdue: isLabCaseOverdue(labCase.dueDate, labCase.tasks),
isImportant: labCase.isImportant,
clinic: labCase.treatment.organization,
lab: labCase.sends[0]?.organization ?? null,
patient: labCase.treatment.patient,
prosthesisGroups,
},
};
}
async listTasksByToken(
token: string,
actorUserId: string,
organizationId: string,
localeInput?: string | null,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind === 'denied') {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
return this.tasksService.listTasksForLabCaseId(labCase.id, localeInput);
}
async listCommentsByToken(
token: string,
actorUserId: string,
organizationId: string,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind === 'denied') {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
if (access.kind === 'lab') {
return this.commentsService.listForLabViewer(labCase.id, organizationId, actorUserId);
}
return this.commentsService.listForClinicTreatmentCase(
labCase.id,
organizationId,
actorUserId,
);
}
async addCommentByToken(
token: string,
actorUserId: string,
organizationId: string,
dto: CreateLabCaseCommentDto,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind === 'denied' || !access.canPostComments) {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
if (access.kind === 'lab') {
return this.commentsService.addForLab(labCase.id, organizationId, actorUserId, dto);
}
return this.commentsService.addForClinicTreatmentCase(
labCase.id,
organizationId,
actorUserId,
dto,
);
}
async setCommentVisibilityByToken(
token: string,
commentId: string,
actorUserId: string,
organizationId: string,
visibleToClinic: boolean,
) {
const labCase = await this.findCaseByToken(token);
const access = await this.resolveAccess(labCase, actorUserId, organizationId);
if (access.kind !== 'lab' || !access.canToggleCommentVisibility) {
throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN);
}
return this.commentsService.setVisibility(
commentId,
organizationId,
actorUserId,
visibleToClinic,
);
}
private async findCaseByToken(token: string) {
const labCase = await this.prisma.labCase.findFirst({
where: { accessToken: token, sentAt: { not: null } },
include: accessCaseInclude,
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return labCase;
}
private async resolveAccess(
labCase: Prisma.LabCaseGetPayload<{ include: typeof accessCaseInclude }>,
actorUserId: string,
organizationId: string,
): Promise<ResolvedAccess> {
const clinicOrgId = labCase.treatment.organization.id;
const labOrgId =
labCase.destinationOrganizationId ?? labCase.sends[0]?.organizationId ?? null;
if (!labOrgId) {
return { kind: 'denied' };
}
if (organizationId === labOrgId) {
const membership = await this.getMembership(actorUserId, labOrgId);
if (!membership) return { kind: 'denied' };
if (
hasEffectivePermission(membership, 'TAB_TASKS_READ') ||
hasEffectivePermission(membership, 'TAB_TASKS_EDIT')
) {
const canEdit = hasEffectivePermission(membership, 'TAB_TASKS_EDIT');
return {
kind: 'lab',
canEditTaskStatus: canEdit,
canPostComments: canEdit,
canToggleCommentVisibility: canEdit,
};
}
return { kind: 'denied' };
}
if (organizationId === clinicOrgId) {
const membership = await this.getMembership(actorUserId, clinicOrgId);
if (!membership) return { kind: 'denied' };
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
return { kind: 'denied' };
}
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
return { kind: 'denied' };
}
return {
kind: 'clinic',
canEditTaskStatus: false,
canPostComments: true,
canToggleCommentVisibility: false,
};
}
return { kind: 'denied' };
}
private async buildProsthesisGroups(
labCaseId: string,
prosthesisLabels: Map<string, string>,
) {
const tasks = await this.prisma.labCaseTask.findMany({
where: { labCaseId },
select: { prosthesisTypeCode: true, teeth: true },
orderBy: [{ prosthesisTypeCode: 'asc' }],
});
const byCode = new Map<string, string[]>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const teeth = normalizeTaskTeeth(task.teeth);
const list = byCode.get(task.prosthesisTypeCode) ?? [];
list.push(...teeth);
byCode.set(task.prosthesisTypeCode, list);
}
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
prosthesisTypeCode,
prosthesisTypeLabel: prosthesisLabels.get(prosthesisTypeCode) ?? prosthesisTypeCode,
teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })),
}));
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
}
}

View File

@@ -34,6 +34,12 @@ export class LabCaseCommentsService {
return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
}
async listForLabViewer(caseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId);
const comments = await this.fetchComments(caseId);
return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
}
async addForLab(
caseId: string,
labOrganizationId: string,
@@ -219,6 +225,18 @@ export class LabCaseCommentsService {
caseId: string,
labOrganizationId: string,
actorUserId: string,
) {
await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId);
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
throw new ForbiddenException('You do not have access to task comments');
}
}
private async assertLabCanViewCase(
caseId: string,
labOrganizationId: string,
actorUserId: string,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
@@ -232,10 +250,20 @@ export class LabCaseCommentsService {
throw new NotFoundException('Case not found');
}
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
if (
!hasEffectivePermission(membership, 'TAB_TASKS_READ') &&
!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')
) {
throw new ForbiddenException('You do not have access to tasks');
}
}
private async getLabMembership(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: {
userId: actorUserId,
organizationId: labOrganizationId,
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
@@ -246,9 +274,7 @@ export class LabCaseCommentsService {
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
throw new ForbiddenException('You do not have access to task comments');
}
return membership;
}
private async assertClinicOwnsCase(

View File

@@ -8,5 +8,6 @@ import { TasksService } from './tasks.service';
imports: [CatalogModule, NotificationsModule],
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
})
export class TasksModule {}

View File

@@ -95,6 +95,58 @@ export class TasksService {
};
}
async listForLabCase(
labCaseId: string,
organizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanReadTasks(actorUserId, organizationId);
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
sends: { some: { organizationId } },
},
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return this.listTasksForLabCaseId(labCaseId, localeInput);
}
async listTasksForLabCaseId(labCaseId: string, localeInput?: string | null) {
const items = await this.prisma.labCaseTask.findMany({
where: { labCaseId },
include: taskListInclude,
orderBy: [
{ treatmentDetailId: 'asc' },
{ prosthesisTypeCode: 'asc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
],
});
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
return {
success: true,
data: {
items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)),
},
};
}
async locateTaskPage(
labOrganizationId: string,
actorUserId: string,

View File

@@ -8,6 +8,7 @@ import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { generateLabCaseAccessToken } from '../../common/lab-case-access-token';
import { PrismaService } from '../../../prisma/prisma.service';
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
@@ -754,7 +755,10 @@ export class TreatmentsService {
if (!labCase.sentAt) {
await tx.labCase.update({
where: { id: labCaseId },
data: { sentAt: now },
data: {
sentAt: now,
accessToken: generateLabCaseAccessToken(),
},
});
}