Skip to content

Commit 9973afb

Browse files
committed
updates
1 parent 92eb3bd commit 9973afb

87 files changed

Lines changed: 1548 additions & 77 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,157 @@
1-
import { getConnections, PgTestClient } from 'pgsql-test';
1+
import { getConnections, PgTestClient, seed } from 'pgsql-test';
22

3-
let db: PgTestClient;
43
let pg: PgTestClient;
4+
let db: PgTestClient;
55
let teardown: () => Promise<void>;
66

7+
let aliceId: string;
8+
let bobId: string;
9+
710
beforeAll(async () => {
8-
({ pg, db, teardown } = await getConnections());
11+
({ pg, db, teardown } = await getConnections({}, [seed.pgpm()]));
12+
13+
await pg.query(`GRANT authenticated TO app_user`);
14+
15+
const patients = await pg.query(`
16+
INSERT INTO patients.patients (first_name, last_name, date_of_birth)
17+
VALUES ('Alice', 'Anderson', '1980-01-01'),
18+
('Bob', 'Brown', '1985-05-05')
19+
RETURNING id
20+
`);
21+
aliceId = patients.rows[0].id;
22+
bobId = patients.rows[1].id;
923
});
1024

1125
afterAll(async () => {
1226
await teardown();
1327
});
1428

1529
beforeEach(async () => {
30+
await pg.beforeEach();
1631
await db.beforeEach();
1732
});
1833

1934
afterEach(async () => {
2035
await db.afterEach();
36+
await pg.afterEach();
2137
});
2238

23-
describe('first test', () => {
24-
it('should pass', async () => {
25-
const result = await pg.query('SELECT 1 as num');
26-
expect(result.rows[0].num).toBe(1);
39+
function asPatient(userId: string) {
40+
db.setContext({
41+
role: 'authenticated',
42+
'app.role': 'patient',
43+
'app.user_id': userId,
44+
});
45+
}
46+
47+
function asClinician() {
48+
db.setContext({
49+
role: 'authenticated',
50+
'app.role': 'clinician',
51+
'app.user_id': '',
52+
});
53+
}
54+
55+
describe('conditions RLS', () => {
56+
it('Alice sees only her own conditions', async () => {
57+
asClinician();
58+
await db.query(
59+
`INSERT INTO clinical.conditions (patient_id, description, status)
60+
VALUES ($1, 'Hypertension', 'active'),
61+
($2, 'Type 2 Diabetes', 'active')`,
62+
[aliceId, bobId],
63+
);
64+
65+
asPatient(aliceId);
66+
const r = await db.query(`SELECT description FROM clinical.conditions`);
67+
expect(r.rows).toHaveLength(1);
68+
expect(r.rows[0].description).toBe('Hypertension');
69+
});
70+
71+
it('Clinician-only writes: patient cannot add a condition to themselves', async () => {
72+
asPatient(aliceId);
73+
const point = 'patient_condition_denied';
74+
await db.savepoint(point);
75+
await expect(
76+
db.query(
77+
`INSERT INTO clinical.conditions (patient_id, description)
78+
VALUES ($1, 'Self-diagnosed fake condition')`,
79+
[aliceId],
80+
),
81+
).rejects.toThrow(/row-level security/i);
82+
await db.rollback(point);
2783
});
2884
});
2985

86+
describe('allergies RLS', () => {
87+
it("Alice can record her own allergies but cannot see Bob's", async () => {
88+
asPatient(aliceId);
89+
await db.query(
90+
`INSERT INTO clinical.allergies (patient_id, allergen, severity)
91+
VALUES ($1, 'Penicillin', 'severe')`,
92+
[aliceId],
93+
);
94+
95+
asClinician();
96+
await db.query(
97+
`INSERT INTO clinical.allergies (patient_id, allergen, severity)
98+
VALUES ($1, 'Shellfish', 'moderate')`,
99+
[bobId],
100+
);
101+
102+
asPatient(aliceId);
103+
const r = await db.query(`SELECT allergen FROM clinical.allergies`);
104+
expect(r.rows).toHaveLength(1);
105+
expect(r.rows[0].allergen).toBe('Penicillin');
106+
});
107+
108+
it('Alice CANNOT add an allergy for Bob (WITH CHECK enforces row ownership)', async () => {
109+
asPatient(aliceId);
110+
const point = 'cross_patient_allergy';
111+
await db.savepoint(point);
112+
await expect(
113+
db.query(
114+
`INSERT INTO clinical.allergies (patient_id, allergen, severity)
115+
VALUES ($1, 'Sneaky injection on Bob', 'moderate')`,
116+
[bobId],
117+
),
118+
).rejects.toThrow(/row-level security/i);
119+
await db.rollback(point);
120+
});
121+
});
122+
123+
describe('vitals RLS', () => {
124+
it('Clinicians record vitals; patients read their own only', async () => {
125+
asClinician();
126+
await db.query(
127+
`INSERT INTO clinical.vitals (patient_id, heart_rate_bpm, systolic_bp, diastolic_bp, temperature_c)
128+
VALUES ($1, 72, 120, 80, 36.7),
129+
($2, 88, 145, 92, 37.1)`,
130+
[aliceId, bobId],
131+
);
132+
133+
asPatient(aliceId);
134+
const aliceView = await db.query(`SELECT heart_rate_bpm FROM clinical.vitals`);
135+
expect(aliceView.rows).toHaveLength(1);
136+
expect(aliceView.rows[0].heart_rate_bpm).toBe(72);
137+
138+
asPatient(bobId);
139+
const bobView = await db.query(`SELECT heart_rate_bpm FROM clinical.vitals`);
140+
expect(bobView.rows).toHaveLength(1);
141+
expect(bobView.rows[0].heart_rate_bpm).toBe(88);
142+
});
143+
144+
it('Patients cannot record their own vitals', async () => {
145+
asPatient(aliceId);
146+
const point = 'patient_vitals_denied';
147+
await db.savepoint(point);
148+
await expect(
149+
db.query(
150+
`INSERT INTO clinical.vitals (patient_id, heart_rate_bpm)
151+
VALUES ($1, 200)`,
152+
[aliceId],
153+
),
154+
).rejects.toThrow(/row-level security/i);
155+
await db.rollback(point);
156+
});
157+
});

packages/clinical/clinical.control

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
comment = 'clinical extension'
33
default_version = '0.0.1'
44
module_pathname = '$libdir/clinical'
5-
requires = 'plpgsql'
5+
requires = 'plpgsql,scheduling'
66
relocatable = false
77
superuser = false
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Deploy schemas/clinical to pg
2+
3+
CREATE SCHEMA clinical;
4+
GRANT USAGE ON SCHEMA clinical TO authenticated;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Deploy schemas/clinical/policies/allergies_rls to pg
2+
-- requires: schemas/clinical/tables/allergies
3+
4+
ALTER TABLE clinical.allergies ENABLE ROW LEVEL SECURITY;
5+
ALTER TABLE clinical.allergies FORCE ROW LEVEL SECURITY;
6+
7+
CREATE POLICY allergies_select ON clinical.allergies
8+
FOR SELECT TO authenticated
9+
USING (
10+
app.is_clinician_or_admin()
11+
OR patient_id = app.current_user_id()
12+
);
13+
14+
CREATE POLICY allergies_modify ON clinical.allergies
15+
FOR ALL TO authenticated
16+
USING (
17+
app.is_clinician_or_admin()
18+
OR patient_id = app.current_user_id()
19+
)
20+
WITH CHECK (
21+
app.is_clinician_or_admin()
22+
OR patient_id = app.current_user_id()
23+
);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Deploy schemas/clinical/policies/conditions_rls to pg
2+
-- requires: schemas/clinical/tables/conditions
3+
4+
ALTER TABLE clinical.conditions ENABLE ROW LEVEL SECURITY;
5+
ALTER TABLE clinical.conditions FORCE ROW LEVEL SECURITY;
6+
7+
CREATE POLICY conditions_select ON clinical.conditions
8+
FOR SELECT TO authenticated
9+
USING (
10+
app.is_clinician_or_admin()
11+
OR patient_id = app.current_user_id()
12+
);
13+
14+
CREATE POLICY conditions_modify ON clinical.conditions
15+
FOR ALL TO authenticated
16+
USING (app.is_clinician_or_admin())
17+
WITH CHECK (app.is_clinician_or_admin());
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Deploy schemas/clinical/policies/vitals_rls to pg
2+
-- requires: schemas/clinical/tables/vitals
3+
4+
ALTER TABLE clinical.vitals ENABLE ROW LEVEL SECURITY;
5+
ALTER TABLE clinical.vitals FORCE ROW LEVEL SECURITY;
6+
7+
CREATE POLICY vitals_select ON clinical.vitals
8+
FOR SELECT TO authenticated
9+
USING (
10+
app.is_clinician_or_admin()
11+
OR patient_id = app.current_user_id()
12+
);
13+
14+
CREATE POLICY vitals_modify ON clinical.vitals
15+
FOR ALL TO authenticated
16+
USING (app.is_clinician_or_admin())
17+
WITH CHECK (app.is_clinician_or_admin());
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Deploy schemas/clinical/tables/allergies to pg
2+
-- requires: schemas/clinical
3+
4+
CREATE TABLE clinical.allergies (
5+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6+
patient_id uuid NOT NULL REFERENCES patients.patients(id) ON DELETE CASCADE,
7+
encounter_id uuid REFERENCES scheduling.encounters(id) ON DELETE SET NULL,
8+
allergen text NOT NULL,
9+
reaction text,
10+
severity text NOT NULL DEFAULT 'moderate'
11+
CHECK (severity IN ('mild', 'moderate', 'severe', 'life_threatening')),
12+
recorded_on date NOT NULL DEFAULT current_date,
13+
created_at timestamptz NOT NULL DEFAULT now()
14+
);
15+
16+
CREATE INDEX allergies_patient_id_idx ON clinical.allergies (patient_id);
17+
18+
GRANT SELECT, INSERT, UPDATE, DELETE ON clinical.allergies TO authenticated;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- Deploy schemas/clinical/tables/conditions to pg
2+
-- requires: schemas/clinical
3+
4+
CREATE TABLE clinical.conditions (
5+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6+
patient_id uuid NOT NULL REFERENCES patients.patients(id) ON DELETE CASCADE,
7+
encounter_id uuid REFERENCES scheduling.encounters(id) ON DELETE SET NULL,
8+
icd10_code text,
9+
description text NOT NULL,
10+
onset_date date,
11+
resolved_date date,
12+
status text NOT NULL DEFAULT 'active'
13+
CHECK (status IN ('active', 'resolved', 'inactive', 'suspected')),
14+
severity text CHECK (severity IN ('mild', 'moderate', 'severe')),
15+
created_at timestamptz NOT NULL DEFAULT now()
16+
);
17+
18+
CREATE INDEX conditions_patient_id_idx ON clinical.conditions (patient_id);
19+
20+
GRANT SELECT, INSERT, UPDATE, DELETE ON clinical.conditions TO authenticated;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Deploy schemas/clinical/tables/vitals to pg
2+
-- requires: schemas/clinical
3+
4+
CREATE TABLE clinical.vitals (
5+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6+
patient_id uuid NOT NULL REFERENCES patients.patients(id) ON DELETE CASCADE,
7+
encounter_id uuid REFERENCES scheduling.encounters(id) ON DELETE SET NULL,
8+
recorded_at timestamptz NOT NULL DEFAULT now(),
9+
heart_rate_bpm int CHECK (heart_rate_bpm > 0 AND heart_rate_bpm < 350),
10+
systolic_bp int CHECK (systolic_bp > 0 AND systolic_bp < 300),
11+
diastolic_bp int CHECK (diastolic_bp > 0 AND diastolic_bp < 200),
12+
respiratory_rate int CHECK (respiratory_rate > 0 AND respiratory_rate < 100),
13+
temperature_c numeric(4,1) CHECK (temperature_c > 25 AND temperature_c < 50),
14+
oxygen_saturation int CHECK (oxygen_saturation BETWEEN 0 AND 100),
15+
weight_kg numeric(6,2) CHECK (weight_kg > 0),
16+
height_cm numeric(5,1) CHECK (height_cm > 0),
17+
created_at timestamptz NOT NULL DEFAULT now()
18+
);
19+
20+
CREATE INDEX vitals_patient_id_idx ON clinical.vitals (patient_id, recorded_at DESC);
21+
22+
GRANT SELECT, INSERT, UPDATE, DELETE ON clinical.vitals TO authenticated;

packages/clinical/pgpm.plan

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
%syntax-version=1.0.0
22
%project=clinical
3-
%uri=clinical
3+
%uri=clinical
4+
5+
schemas/clinical 2026-04-23T05:27:15Z constructive <constructive@5b0c196eeb62> # add schemas/clinical
6+
schemas/clinical/tables/conditions [schemas/clinical] 2026-04-23T05:27:15Z constructive <constructive@5b0c196eeb62> # add schemas/clinical/tables/conditions
7+
schemas/clinical/tables/allergies [schemas/clinical] 2026-04-23T05:27:15Z constructive <constructive@5b0c196eeb62> # add schemas/clinical/tables/allergies
8+
schemas/clinical/tables/vitals [schemas/clinical] 2026-04-23T05:27:15Z constructive <constructive@5b0c196eeb62> # add schemas/clinical/tables/vitals
9+
schemas/clinical/policies/conditions_rls [schemas/clinical/tables/conditions] 2026-04-23T05:27:16Z constructive <constructive@5b0c196eeb62> # add schemas/clinical/policies/conditions_rls
10+
schemas/clinical/policies/allergies_rls [schemas/clinical/tables/allergies] 2026-04-23T05:27:16Z constructive <constructive@5b0c196eeb62> # add schemas/clinical/policies/allergies_rls
11+
schemas/clinical/policies/vitals_rls [schemas/clinical/tables/vitals] 2026-04-23T05:27:16Z constructive <constructive@5b0c196eeb62> # add schemas/clinical/policies/vitals_rls

0 commit comments

Comments
 (0)