Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/common/Permissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const PERMISSION_SUBMIT_PATIENT_QUESTIONNAIRE =
export const PERMISSION_CREATE_ENCOUNTER = "can_create_encounter";
export const PERMISSION_LIST_ENCOUNTERS = "can_list_encounter";
export const PERMISSION_WRITE_ENCOUNTER = "can_write_encounter";
export const PERMISSION_WRITE_ENCOUNTER_CLINICAL_DATA =
"can_write_encounter_clinical_data";
export const PERMISSION_READ_ENCOUNTER = "can_read_encounter";
export const PERMISSION_READ_ENCOUNTER_CLINICAL_DATA =
"can_read_encounter_clinical_data";
Expand Down Expand Up @@ -138,6 +140,8 @@ export interface Permissions {
canListEncounters: boolean;
/** Permission slug: "can_write_encounter" */
canWriteEncounter: boolean;
/** Permission slug: "can_write_encounter_clinical_data" */
canWriteEncounterClinicalData: boolean;
/** Permission slug: "can_read_encounter" */
canReadEncounter: boolean;
/** Permission slug: "can_read_encounter_clinical_data" */
Expand Down Expand Up @@ -310,6 +314,10 @@ export function getPermissions(
canCreateEncounter: hasPermission(PERMISSION_CREATE_ENCOUNTER, permissions),
canListEncounters: hasPermission(PERMISSION_LIST_ENCOUNTERS, permissions),
canWriteEncounter: hasPermission(PERMISSION_WRITE_ENCOUNTER, permissions),
canWriteEncounterClinicalData: hasPermission(
PERMISSION_WRITE_ENCOUNTER_CLINICAL_DATA,
permissions,
),
canReadEncounter: hasPermission(PERMISSION_READ_ENCOUNTER, permissions),
canReadEncounterClinicalData: hasPermission(
PERMISSION_READ_ENCOUNTER_CLINICAL_DATA,
Expand Down
73 changes: 44 additions & 29 deletions src/components/Notes/NoteManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,9 @@ export function NoteManager({
}, [messagesData]);

const handleCreateThread = (title: string) => {
if (!canWrite) {
return;
}
if (title.trim()) {
if (
threadsData?.results.some((thread) => thread.title === title.trim())
Expand All @@ -470,7 +473,10 @@ export function NoteManager({
e.preventDefault();
e.stopPropagation();
const canSend =
newMessage.trim() && selectedThread && !createMessageMutation.isPending;
canWrite &&
newMessage.trim() &&
selectedThread &&
!createMessageMutation.isPending;
if (canSend) {
createMessageMutation.mutate({ message: newMessage.trim() });
}
Expand Down Expand Up @@ -551,18 +557,20 @@ export function NoteManager({
{t("notes__all_discussions")}
</h3>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowNewThreadDialog(true);
setIsThreadsExpanded(false);
}}
className="h-8 hidden lg:block"
>
<MessageSquarePlus className="size-4 mr-2" />
{t("notes__new")}
</Button>
{canWrite && (
<Button
variant="outline"
size="sm"
onClick={() => {
setShowNewThreadDialog(true);
setIsThreadsExpanded(false);
}}
className="h-8 hidden lg:block"
>
<MessageSquarePlus className="size-4 mr-2" />
{t("notes__new")}
</Button>
)}
</div>
</div>

Expand Down Expand Up @@ -623,7 +631,11 @@ export function NoteManager({
</div>
) : (
<div className="text-center text-sm font-medium text-gray-500">
{t("notes__select_create_thread")}
{t(
canWrite
? "notes__select_create_thread"
: "notes__no_discussions",
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</div>
)}
</div>
Expand Down Expand Up @@ -748,14 +760,15 @@ export function NoteManager({
<p className="text-sm text-gray-500 mb-6 max-w-sm">
{t("notes__welcome_description")}
</p>
<Button
onClick={() => setShowNewThreadDialog(true)}
className="shadow-lg"
disabled={!canWrite}
>
<MessageSquarePlus className="size-5 mr-2" />
{t("notes__start_new_discussion")}
</Button>
{canWrite && (
<Button
onClick={() => setShowNewThreadDialog(true)}
className="shadow-lg"
>
<MessageSquarePlus className="size-5 mr-2" />
{t("notes__start_new_discussion")}
</Button>
)}
</div>
)}
</div>
Expand All @@ -769,13 +782,15 @@ export function NoteManager({
canWrite={canWrite}
/>

<NewThreadDialog
isOpen={showNewThreadDialog}
onClose={() => setShowNewThreadDialog(false)}
onCreate={handleCreateThread}
isCreating={createThreadMutation.isPending}
threadsUnused={threads}
/>
{canWrite && (
<NewThreadDialog
isOpen={showNewThreadDialog}
onClose={() => setShowNewThreadDialog(false)}
onCreate={handleCreateThread}
isCreating={createThreadMutation.isPending}
threadsUnused={threads}
/>
)}
</div>
);
}
14 changes: 12 additions & 2 deletions src/components/Patient/PatientDetailsTab/PatientNotes.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { NoteManager } from "@/components/Notes/NoteManager";

import { getPermissions } from "@/common/Permissions";

import { usePermissions } from "@/context/PermissionContext";

import { PatientProps } from ".";

export const PatientNotesTab = (props: PatientProps) => {
const { hasPermission } = usePermissions();
const { canViewClinicalData, canWritePatient } = getPermissions(
hasPermission,
props.patientData.permissions,
);

return (
<div className="w-full flex flex-col h-[calc(100vh-18rem)] border border-r mt-1 md:mt-4 rounded-lg overflow-hidden">
<NoteManager
canAccess={true}
canWrite={true}
canAccess={canViewClinicalData}
canWrite={canViewClinicalData && canWritePatient}
patientId={props.patientData.id}
encounterId={undefined}
hideEncounterNotes={true}
Expand Down
1 change: 1 addition & 0 deletions src/components/Patient/PatientDetailsTab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function getTabs(
return { ...tab, visible: canListEncounters || canViewPatients };
case "files":
return { ...tab, visible: canReadEncounter || canViewClinicalData };
case "notes":
case "clinical_history":
return { ...tab, visible: canViewClinicalData };
case "updates":
Expand Down
20 changes: 16 additions & 4 deletions src/pages/Encounters/tabs/notes.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import { NoteManager } from "@/components/Notes/NoteManager";

import { getPermissions } from "@/common/Permissions";

import { usePermissions } from "@/context/PermissionContext";
import { useEncounter } from "@/pages/Encounters/utils/EncounterProvider";

// Main component
export const EncounterNotesTab = () => {
const {
selectedEncounterId: encounterId,
selectedEncounter,
canWriteSelectedEncounter,
canReadSelectedEncounter,
canReadClinicalData,
patientId,
} = useEncounter();
const { hasPermission } = usePermissions();
const { canWriteEncounterClinicalData } = getPermissions(
hasPermission,
selectedEncounter?.permissions ?? [],
);

const canAccess = canReadClinicalData;
const canWrite =
canAccess && canWriteSelectedEncounter && canWriteEncounterClinicalData;

return (
<div>
<NoteManager
canAccess={canReadSelectedEncounter}
canWrite={canWriteSelectedEncounter}
canAccess={canAccess}
canWrite={canWrite}
encounterId={encounterId}
patientId={patientId}
/>
Expand Down
176 changes: 176 additions & 0 deletions tests/facility/patient/notes/notesPermission.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { expect, type Page, test } from "@playwright/test";
import { getEncounterId } from "tests/support/encounterId";
import { getFacilityId } from "tests/support/facilityId";
import { getPatientId } from "tests/support/patientId";

/**
* Patient notes: view = can_view_clinical_data, write = can_write_patient.
* Encounter notes: view = clinical-data read, write = can_write_encounter_clinical_data.
*
* Write without view hides the Notes tab. View without write keeps the tab
* (and existing threads) but must not show create or send controls.
*
* Object permissions are stubbed on the patient/encounter GET payloads so the
* cases do not depend on a dedicated role. Nurse is used because admin is a
* superuser and bypasses object-level checks.
*/

test.use({
storageState: "tests/.auth/nurse.json",
viewport: { width: 1536, height: 900 },
});

const PATIENT_DETAIL = /\/api\/v1\/patient\/[0-9a-fA-F-]{36}\/?(\?|$)/;
const ENCOUNTER_DETAIL = /\/api\/v1\/encounter\/[0-9a-fA-F-]{36}\/?(\?|$)/;

function withoutPermission(permissions: string[], slug: string) {
return permissions.filter((permission) => permission !== slug);
}

async function stubObjectPermissions(
page: Page,
options: {
patient?: (permissions: string[]) => string[];
encounter?: (permissions: string[]) => string[];
},
) {
if (options.patient) {
await page.route(PATIENT_DETAIL, async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
const response = await route.fetch();
const json = await response.json();
if (Array.isArray(json.permissions)) {
json.permissions = options.patient!(json.permissions);
}
await route.fulfill({ response, json });
});
}

if (options.encounter) {
await page.route(ENCOUNTER_DETAIL, async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
const response = await route.fetch();
const json = await response.json();
if (Array.isArray(json.permissions)) {
json.permissions = options.encounter!(json.permissions);
}
await route.fulfill({ response, json });
});
}
}

function notesUrls() {
const facilityId = getFacilityId();
const patientId = getPatientId();
const encounterId = getEncounterId();
return {
encounter: `/facility/${facilityId}/patient/${patientId}/encounter/${encounterId}/notes`,
patient: `/facility/${facilityId}/patient/${patientId}/notes`,
};
}

async function expectCreateNotesHidden(page: Page) {
await expect(
page.getByRole("button", { name: "New", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Start New Discussion" }),
).toHaveCount(0);
await expect(page.getByPlaceholder("Type your message...")).toHaveCount(0);
}

async function expectCreateNotesAvailable(page: Page) {
const newButton = page.getByRole("button", { name: "New", exact: true });
const startButton = page.getByRole("button", {
name: "Start New Discussion",
});
await expect(newButton.or(startButton).first()).toBeVisible();
}

async function waitForNotesSurface(page: Page) {
await expect(
page
.getByRole("heading", { name: "Discussions" })
.or(page.getByRole("heading", { name: "Welcome to Discussions" })),
).toBeVisible();
}

test.describe("Notes create-button permissions", () => {
test("nurse with write access can create notes on encounter and patient pages", async ({
page,
}) => {
const urls = notesUrls();

await page.goto(urls.encounter);
await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute(
"data-state",
"active",
);
await waitForNotesSurface(page);
await expectCreateNotesAvailable(page);

await page.goto(urls.patient);
await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute(
"aria-selected",
"true",
);
await waitForNotesSurface(page);
await expectCreateNotesAvailable(page);
});

test("hides create actions when the user can view but not write", async ({
page,
}) => {
await stubObjectPermissions(page, {
patient: (permissions) =>
withoutPermission(permissions, "can_write_patient"),
encounter: (permissions) =>
withoutPermission(permissions, "can_write_encounter_clinical_data"),
});

const urls = notesUrls();

await page.goto(urls.encounter);
await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute(
"data-state",
"active",
);
await waitForNotesSurface(page);
await expectCreateNotesHidden(page);

await page.goto(urls.patient);
await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute(
"aria-selected",
"true",
);
await waitForNotesSurface(page);
await expectCreateNotesHidden(page);
});

test("hides the Notes tab when the user can write but not view", async ({
page,
}) => {
await stubObjectPermissions(page, {
patient: (permissions) =>
withoutPermission(permissions, "can_view_clinical_data"),
encounter: (permissions) =>
withoutPermission(permissions, "can_read_encounter_clinical_data"),
});

const urls = notesUrls();

await page.goto(urls.encounter);
await expect(page.getByRole("tab", { name: "Notes" })).toHaveCount(0);
await expectCreateNotesHidden(page);

await page.goto(urls.patient);
await expect(page.getByRole("tab", { name: "Notes" })).toHaveCount(0);
await expectCreateNotesHidden(page);
});
});
Loading