Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
71 changes: 42 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,9 @@ export function NoteManager({
</div>
) : (
<div className="text-center text-sm font-medium text-gray-500">
{t("notes__select_create_thread")}
{canWrite
? t("notes__select_create_thread")
: t("notes__no_discussions")}
</div>
)}
</div>
Expand Down Expand Up @@ -748,14 +758,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 +780,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