Changes between Version 2 and Version 3 of AdvancedDatabaseDevelopment


Ignore:
Timestamp:
09/23/26 20:22:29 (8 hours ago)
Author:
236021
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • AdvancedDatabaseDevelopment

    v2 v3  
    66== Allergy–prescription safety enforcement ==
    77
    8 A patient should never be prescribed a medication that conflicts with one of their documented allergies. This requirement blocks the unsafe insert outright, at the database level before it can happen: a new **prescription_medical_records** row is rejected if the prescription is linked, via **allergy_prescription_restrictions** → **prescription_restriction** to any allergy already on that patient's medical record.
     8A patient should never be prescribed a medication that conflicts with one of their documented allergies. This requirement blocks the unsafe prescription outright, at the database level: a new or changed **prescription_medical_records** row is rejected if the prescription is linked, via **prescription_restriction** → **allergy_prescription_restrictions**, to an allergy the patient has on file, either in **patient_allergies** or in **medical_record_allergies** on any of the patient's medical records. The trigger only prevents new conflicts; prescriptions that were recorded before it existed are listed by the **v_prescription_allergy_conflicts** view so they can be reviewed.
    99
    1010=== Custom domains ===
    1111
    1212{{{
    13 CREATE DOMAIN non_negative_currency AS DECIMAL(12,2)
     13CREATE DOMAIN non_negative_cost AS DECIMAL(12,2)
    1414    CHECK (VALUE >= 0);
     15
     16ALTER TABLE procedures ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost;
     17ALTER TABLE lab_tests ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost;
     18ALTER TABLE billing ALTER COLUMN total_cost TYPE non_negative_cost USING total_cost::non_negative_cost;
     19
     20ALTER TABLE procedures DROP CONSTRAINT IF EXISTS procedures_cost_chk;
     21ALTER TABLE lab_tests DROP CONSTRAINT IF EXISTS lab_tests_cost_chk;
     22ALTER TABLE billing DROP CONSTRAINT IF EXISTS billing_cost_chk;
    1523}}}
    1624
     
    1826
    1927{{{
    20 CREATE TABLE IF NOT EXISTS prescription_allergy_conflicts_log (
    21     log_id BIGSERIAL PRIMARY KEY,
    22     record_id BIGINT NOT NULL REFERENCES medical_records(record_id),
    23     prescription_id BIGINT NOT NULL REFERENCES prescriptions(prescription_id),
    24     allergy_id BIGINT NOT NULL REFERENCES allergies(allergy_id),
    25     conflict_type TEXT CHECK (conflict_type IN ('ACTIVE', 'RESOLVED')),
    26     detected_date TIMESTAMP DEFAULT NOW(),
    27     resolution_notes TEXT,
    28     resolved_date TIMESTAMP
    29 );
    30 
    3128CREATE OR REPLACE FUNCTION t1_prescription_allergy_check()
    3229RETURNS TRIGGER
     
    4643    END IF;
    4744
    48     SELECT apr.allergy_id, a.name
     45    SELECT a.allergy_id, a.name
    4946    INTO v_conflict_allergy_id, v_allergy_name
    5047    FROM prescription_restriction pr_rest
    5148    JOIN allergy_prescription_restrictions apr ON apr.restriction_id = pr_rest.restriction_id
    52     JOIN allergies a ON apr.allergy_id = a.allergy_id
    53     JOIN medical_record_allergies mra ON a.allergy_id = mra.allergy_id
     49    JOIN allergies a ON a.allergy_id = apr.allergy_id
    5450    WHERE pr_rest.prescription_id = NEW.prescription_id
    55       AND mra.record_id = NEW.record_id
     51      AND (
     52            EXISTS (SELECT 1 FROM patient_allergies pa
     53                    WHERE pa.patient_id = v_patient_id
     54                      AND pa.allergy_id = a.allergy_id)
     55         OR EXISTS (SELECT 1 FROM medical_record_allergies mra
     56                    JOIN medical_records mr2 ON mr2.record_id = mra.record_id
     57                    WHERE mr2.patient_id = v_patient_id
     58                      AND mra.allergy_id = a.allergy_id)
     59          )
    5660    LIMIT 1;
    5761
    5862    IF v_conflict_allergy_id IS NOT NULL THEN
    59         RAISE EXCEPTION 'PRESCRIPTION_ALLERGY_CONFLICT: Prescription % conflicts with allergy % (%) in patient''s record %',
    60             NEW.prescription_id, v_conflict_allergy_id, v_allergy_name, NEW.record_id;
     63        RAISE EXCEPTION 'PRESCRIPTION_ALLERGY_CONFLICT: Prescription % conflicts with allergy % (%) of patient %',
     64            NEW.prescription_id, v_conflict_allergy_id, v_allergy_name, v_patient_id;
    6165    END IF;
    6266
     
    6771DROP TRIGGER IF EXISTS trg_prescription_allergy_check ON prescription_medical_records;
    6872CREATE TRIGGER trg_prescription_allergy_check
    69     BEFORE INSERT
     73    BEFORE INSERT OR UPDATE OF prescription_id, record_id
    7074    ON prescription_medical_records
    7175    FOR EACH ROW
     
    8892    a.allergy_severity,
    8993    apr.restriction_id,
    90     pr_rest.description AS restriction_description,
    91     CASE
    92         WHEN mra.record_id IS NOT NULL THEN 'ACTIVE_CONFLICT'
    93         ELSE 'ARCHIVED'
    94     END AS conflict_status
     94    pr_rest.description AS restriction_description
    9595FROM prescription_medical_records pmr
    9696JOIN medical_records mr ON pmr.record_id = mr.record_id
     
    100100JOIN allergy_prescription_restrictions apr ON pr_rest.restriction_id = apr.restriction_id
    101101JOIN allergies a ON apr.allergy_id = a.allergy_id
    102 LEFT JOIN medical_record_allergies mra ON mr.record_id = mra.record_id
    103     AND a.allergy_id = mra.allergy_id
    104 WHERE a.allergy_severity IN ('HIGH', 'CRITICAL')
    105 ORDER BY p.patient_id, a.allergy_severity DESC;
     102WHERE EXISTS (SELECT 1 FROM patient_allergies pa
     103              WHERE pa.patient_id = p.patient_id
     104                AND pa.allergy_id = a.allergy_id)
     105   OR EXISTS (SELECT 1 FROM medical_record_allergies mra
     106              JOIN medical_records mr2 ON mr2.record_id = mra.record_id
     107              WHERE mr2.patient_id = p.patient_id
     108                AND mra.allergy_id = a.allergy_id)
     109ORDER BY
     110    p.patient_id,
     111    CASE a.allergy_severity
     112        WHEN 'CRITICAL' THEN 1
     113        WHEN 'HIGH' THEN 2
     114        WHEN 'MEDIUM' THEN 3
     115        ELSE 4
     116    END;
    106117}}}
    107118
     
    110121== Appointment scheduling integrity ==
    111122
    112 Appointments must obey real scheduling constraints so no scheduling in the past, no doublebooking the same doctor or patient in an overlapping time, no marking an appointment `COMPLETED` before its scheduled time, and valid status transitions only (`SCHEDULED → IN_PROGRESS/COMPLETED/CANCELLED`, `IN_PROGRESS → COMPLETED/CANCELLED`, nothing further out of `COMPLETED`/`CANCELLED`/`NO_SHOW`). A background job autotransitions appointments that remain `SCHEDULED` if they are 45+ minutes past their time to `NO_SHOW`.
    113 
    114 === Schema change  ===
    115 
    116 {{{
    117 
     123Appointments must obey real scheduling constraints: no scheduling in the past (when an appointment is created or moved to a new date or time), no double-booking the same doctor or patient in an overlapping time, no marking an appointment `COMPLETED` before its scheduled time, and valid status transitions only (`SCHEDULED → IN_PROGRESS/COMPLETED/CANCELLED/NO_SHOW`, `IN_PROGRESS → COMPLETED/CANCELLED`, nothing further out of `COMPLETED`/`CANCELLED`/`NO_SHOW`). Each appointment is treated as a 30-minute slot, and only `SCHEDULED`, `IN_PROGRESS` and `COMPLETED` appointments occupy a slot. The **job_mark_no_show** procedure moves appointments that are still `SCHEDULED` 45+ minutes past their time to `NO_SHOW`; it is run with `CALL job_mark_no_show();`, either manually or from a scheduler such as pg_cron, and **v_overdue_appointments** shows the appointments it will pick up.
     124
     125=== Schema change ===
     126
     127{{{
    118128ALTER TABLE appointments DROP CONSTRAINT appointments_status_chk;
    119129ALTER TABLE appointments ADD CONSTRAINT appointments_status_chk
    120     CHECK (status IN ('SCHEDULED','COMPLETED','CANCELLED','IN_PROGRESS','NO_SHOW'));
     130    CHECK (status IN ('SCHEDULED', 'COMPLETED', 'CANCELLED', 'IN_PROGRESS', 'NO_SHOW'));
    121131}}}
    122132
     
    131141SELECT CASE
    132142           WHEN p_old = p_new THEN TRUE
    133            WHEN p_old = 'SCHEDULED'    AND p_new IN ('IN_PROGRESS', 'COMPLETED', 'CANCELLED') THEN TRUE
    134            WHEN p_old = 'IN_PROGRESS'  AND p_new IN ('COMPLETED', 'CANCELLED') THEN TRUE
     143           WHEN p_old = 'SCHEDULED'   AND p_new IN ('IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW') THEN TRUE
     144           WHEN p_old = 'IN_PROGRESS' AND p_new IN ('COMPLETED', 'CANCELLED') THEN TRUE
    135145           ELSE FALSE
    136            END;
     146       END;
    137147$$;
    138148
     
    152162
    153163    IF TG_OP = 'UPDATE' THEN
     164        IF (NEW.appointment_date, NEW.appointment_time) IS DISTINCT FROM (OLD.appointment_date, OLD.appointment_time)
     165           AND v_combined_datetime < NOW() THEN
     166            RAISE EXCEPTION 'Cannot move appointment % into the past (appointment_date=%, appointment_time=%)',
     167                NEW.appointment_id, NEW.appointment_date, NEW.appointment_time;
     168        END IF;
     169
    154170        IF NOT is_valid_appointment_transition(OLD.status, NEW.status) THEN
    155171            RAISE EXCEPTION 'Appointment status cannot transition from % to %',
    156172                OLD.status, NEW.status;
    157173        END IF;
    158 
    159         IF NEW.status = 'COMPLETED' AND v_combined_datetime > NOW() THEN
    160             RAISE EXCEPTION 'Cannot mark appointment COMPLETED before its scheduled time (scheduled for %)',
    161                 v_combined_datetime;
    162         END IF;
     174    END IF;
     175
     176    IF NEW.status = 'COMPLETED' AND v_combined_datetime > NOW() THEN
     177        RAISE EXCEPTION 'Cannot mark appointment COMPLETED before its scheduled time (scheduled for %)',
     178            v_combined_datetime;
    163179    END IF;
    164180
     
    197213          AND (TG_OP <> 'UPDATE' OR a.appointment_id <> NEW.appointment_id)
    198214    ) THEN
    199         RAISE EXCEPTION 'Doctor % has overlapping appointment at %', NEW.doctor_id, NEW.appointment_date;
     215        RAISE EXCEPTION 'Doctor % has an overlapping appointment at % %',
     216            NEW.doctor_id, NEW.appointment_date, NEW.appointment_time;
    200217    END IF;
    201218
     
    208225          AND (TG_OP <> 'UPDATE' OR a.appointment_id <> NEW.appointment_id)
    209226    ) THEN
    210         RAISE EXCEPTION 'Patient % has overlapping appointment at %', NEW.patient_id, NEW.appointment_date;
     227        RAISE EXCEPTION 'Patient % has an overlapping appointment at % %',
     228            NEW.patient_id, NEW.appointment_date, NEW.appointment_time;
    211229    END IF;
    212230
     
    259277== Medical record consistency ==
    260278
    261 **Diagnoses, procedures, and referrals** can be created independently but must stay consistent with the patient they actually serve as a mismatch like a diagnosis for one patient attached to a different patient's record, a doctor referring a patient to themselves, is both an **integrity problem** and a clinical **safety risk**.
     279**Diagnoses, procedures, and referrals** can be created independently but must stay consistent with the patient they actually serve. Linking a diagnosis to a different patient's medical record, linking a performed procedure to a diagnosis that belongs to another patient, or a doctor referring a patient to themselves is both an **integrity problem** and a clinical **safety risk**, so these are rejected on insert and on update. The **v_medical_record_overview** view summarises each medical record and flags diagnosis mismatches and self-referrals that were recorded before the triggers existed.
    262280
    263281=== Triggers ===
     
    294312DROP TRIGGER IF EXISTS trg_diagnosis_record_consistency ON diagnosis_medical_records;
    295313CREATE TRIGGER trg_diagnosis_record_consistency
    296     BEFORE INSERT ON diagnosis_medical_records
     314    BEFORE INSERT OR UPDATE ON diagnosis_medical_records
    297315    FOR EACH ROW EXECUTE FUNCTION t1_diagnosis_record_consistency();
    298316
     
    331349AS $$
    332350BEGIN
    333     IF NOT EXISTS (SELECT 1 FROM medical_records WHERE record_id = NEW.record_id) THEN
    334         RAISE EXCEPTION 'Medical record % not found', NEW.record_id;
    335     END IF;
    336 
    337351    IF NEW.from_doctor_id = NEW.to_doctor_id THEN
    338         RAISE EXCEPTION 'Doctor % cannot refer to themselves', NEW.from_doctor_id;
     352        RAISE EXCEPTION 'Doctor % cannot refer a patient to themselves', NEW.from_doctor_id;
    339353    END IF;
    340354
     
    345359DROP TRIGGER IF EXISTS trg_referral_consistency ON referrals;
    346360CREATE TRIGGER trg_referral_consistency
    347     BEFORE INSERT ON referrals
     361    BEFORE INSERT OR UPDATE ON referrals
    348362    FOR EACH ROW EXECUTE FUNCTION t3_referral_consistency();
    349363}}}
     
    357371    p.patient_id, p.first_name, p.last_name, p.embg,
    358372    COUNT(DISTINCT dmr.diagnosis_id) AS diagnosis_count,
    359     COUNT(DISTINCT CASE WHEN d.patient_id IS NOT NULL AND d.patient_id <> p.patient_id THEN dmr.diagnosis_id END) AS diagnosis_mismatches,
     373    COUNT(DISTINCT CASE WHEN d.patient_id <> p.patient_id THEN dmr.diagnosis_id END) AS diagnosis_mismatches,
    360374    COUNT(DISTINCT mrp.procedure_id) AS procedures_count,
    361375    COUNT(DISTINCT mrl.result_id) AS lab_results_count,
     
    369383    LEFT JOIN medical_record_procedures mrp ON mrp.record_id = mr.record_id
    370384    LEFT JOIN medical_record_lab_results mrl ON mrl.record_id = mr.record_id
    371     LEFT JOIN referrals ref ON mr.record_id = ref.record_id
    372     LEFT JOIN medical_record_allergies mra ON mr.record_id = mra.record_id
     385    LEFT JOIN referrals ref ON ref.record_id = mr.record_id
     386    LEFT JOIN medical_record_allergies mra ON mra.record_id = mr.record_id
    373387GROUP BY mr.record_id, p.patient_id, p.first_name, p.last_name, p.embg;
    374388}}}
     
    378392== Billing integrity ==
    379393
    380 Billing records must stay in sync with their line items and payment status must follow a valid state machine. **billing.total_cost** is automatically recalculated whenever a procedure or lab test is added to or removed from a bill, rather than relying on the application to keep it in sync manually. **payment_status** may only move `PENDING → PAID` or `PENDING → CANCELLED` but never backwards once finalized and every status change is written to an audit log.  Since **billing** originally had no "issued" date , a **created_at** column was added so outstanding `PENDING` bills can be tracked for follow-up.
     394Billing records must stay in sync with their line items, and payment status must follow a valid state machine. billing.total_cost is recalculated automatically whenever a procedure or lab test is added to or removed from a bill, as the sum of the list prices (`cost`) of its line items, rather than relying on the application to keep it in sync. Line items can only be changed while the bill is `PENDING`, so a `PAID` or `CANCELLED` bill can no longer change its amount. payment_status may only move `PENDING → PAID` or `PENDING → CANCELLED`, never backwards once finalized; when a bill becomes `PAID` without a payment_date, the current date is filled in. Every billing status change and every change to the calculated total caused by adding or removing a billing line item is recorded in billing_audit_log. The existing created_at column is used to track outstanding `PENDING` bills: **v_overdue_billings** lists bills that have been pending for more than 30 days (`CRITICAL` after 60 days), and job_billing_alerts reports how many need follow-up.
    381395
    382396=== Schema change ===
    383397
    384398{{{
    385 
    386 ALTER TABLE billing ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();
    387 
    388399CREATE TABLE IF NOT EXISTS billing_audit_log (
    389400    audit_id BIGSERIAL PRIMARY KEY,
     
    401412
    402413{{{
    403 
    404 
    405414CREATE OR REPLACE FUNCTION recalculate_billing_total(p_bill_id BIGINT, p_change_type TEXT)
    406415RETURNS VOID
     
    408417AS $$
    409418DECLARE
     419    v_old_total DECIMAL;
     420    v_status TEXT;
    410421    v_procedure_total DECIMAL;
    411422    v_lab_total DECIMAL;
    412423    v_new_total DECIMAL;
    413424BEGIN
     425    SELECT total_cost, payment_status
     426    INTO v_old_total, v_status
     427    FROM billing
     428    WHERE bill_id = p_bill_id;
     429
     430    IF v_status IS NULL THEN
     431        RAISE EXCEPTION 'Bill % not found', p_bill_id;
     432    END IF;
     433
     434    IF v_status <> 'PENDING' THEN
     435        RAISE EXCEPTION 'Bill % is % and its line items can no longer be changed', p_bill_id, v_status;
     436    END IF;
     437
    414438    SELECT COALESCE(SUM(p.cost), 0) INTO v_procedure_total
    415439    FROM billing_procedures bp
     
    426450    UPDATE billing SET total_cost = v_new_total WHERE bill_id = p_bill_id;
    427451
    428     INSERT INTO billing_audit_log (bill_id, new_amount, change_type)
    429     VALUES (p_bill_id, v_new_total, p_change_type);
     452    INSERT INTO billing_audit_log (bill_id, old_amount, new_amount, change_type)
     453    VALUES (p_bill_id, v_old_total, v_new_total, p_change_type);
    430454END;
    431455$$;
     
    440464           WHEN p_old = 'PENDING' AND p_new IN ('PAID', 'CANCELLED') THEN TRUE
    441465           ELSE FALSE
    442            END;
     466       END;
    443467$$;
    444468
     
    449473    v_overdue_count INT;
    450474BEGIN
    451     SELECT COUNT(*) INTO v_overdue_count FROM v_overdue_billings WHERE days_outstanding > 30;
     475    SELECT COUNT(*) INTO v_overdue_count FROM v_overdue_billings;
    452476    RAISE NOTICE 'Found % overdue billing records requiring follow-up', v_overdue_count;
    453477END;
     
    458482
    459483{{{
    460 
    461484CREATE OR REPLACE FUNCTION t1_billing_line_item_changed()
    462485RETURNS TRIGGER
     
    494517        RAISE EXCEPTION 'Cannot transition billing status from % to %',
    495518            OLD.payment_status, NEW.payment_status;
     519    END IF;
     520
     521    IF NEW.payment_status = 'PAID' AND NEW.payment_date IS NULL THEN
     522        NEW.payment_date := CURRENT_DATE;
    496523    END IF;
    497524
     
    523550    CASE
    524551        WHEN CURRENT_DATE - b.created_at::DATE > 60 THEN 'CRITICAL'
    525         WHEN CURRENT_DATE - b.created_at::DATE > 30 THEN 'OVERDUE'
    526         ELSE 'PENDING'
     552        ELSE 'OVERDUE'
    527553    END AS urgency
    528554FROM billing b
     
    530556JOIN patients p ON mr.patient_id = p.patient_id
    531557WHERE b.payment_status = 'PENDING'
    532   AND CURRENT_DATE - b.created_at::DATE >= 30
     558  AND CURRENT_DATE - b.created_at::DATE > 30
    533559ORDER BY days_outstanding DESC;
    534560}}}
     
    538564== Custom domains for EMBG and Phone number formats ==
    539565
    540 Custom domains replace one-off column checks with reusable types. **embg_format, email_format, and phone_number_format** are new or stricter than before; **non_negative_cost** consolidates a rule already repeated on three tables. non_negative_cost can be applied immediately, since it matches an existing rule. The other three need existing data checked first, since a stricter domain fails if any row doesn't already conform.
     566Custom domains replace one-off column checks with reusable types. **non_negative_cost** consolidates the `cost >= 0` rule that was repeated on **procedures**, **lab_tests** and **billing**; since it matches an existing rule it is applied immediately and the three old CHECK constraints are dropped. It is created in the Custom domains block of the first section on this page, because the column types must be changed before any view that uses these columns is created (such as **v_overdue_billings** and **mv_revenue_monthly**), and PostgreSQL cannot change the type of a column that a view depends on. **embg_format** (the same rule as the existing `patients_embg_format_chk`), **email_format** and **phone_number_format** are defined for reuse but not yet applied to any column: email_format is stricter than the existing checks (for example, it requires a domain ending such as `.com`, which the admin and lab technician checks do not), so existing data has to be checked before it is applied.
    541567
    542568=== Custom domains ===
     
    554580CREATE DOMAIN phone_number_format AS TEXT
    555581    CHECK (VALUE ~ '^\+?[\d\s\-().]{7,20}$');
    556 
    557 CREATE DOMAIN non_negative_cost AS DECIMAL(12,2)
    558     CHECK (VALUE >= 0);
    559 
    560 
    561 ALTER TABLE procedures ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost;
    562 ALTER TABLE lab_tests ALTER COLUMN cost TYPE non_negative_cost USING cost::non_negative_cost;
    563 ALTER TABLE billing ALTER COLUMN total_cost TYPE non_negative_cost USING total_cost::non_negative_cost;
    564582}}}
    565583
     
    568586== Revenue reporting (materialized view and background refresh job) ==
    569587
    570 Revenue reports need expensive joins across **billing, procedures, and lab tests**, so computing them fresh on every request is wasteful. This materialized view precomputes monthly revenue, split by procedure vs lab test. Procedure revenue is attributed down to department, since procedures carries a **doctor_id** directly. Lab revenue can't be: lab_tests has no doctor or department reference, and **billing_lab_tests** links only to the catalog **test_id**, not a specific performed instance so lab rows are reported to the clinic only.The refresh procedure was verified by manual invocation.
     588Revenue reports need expensive joins across **billing, procedures, and lab tests**, so computing them fresh on every request is wasteful. This materialized view precomputes monthly revenue from `PAID` bills, split by procedure vs lab test and grouped by the month of **payment_date**. Procedure revenue is attributed down to department, since **procedures** carries a **doctor_id** directly. Lab revenue can't be: **lab_tests** has no doctor or department reference, and **billing_lab_tests** links only to the catalog **test_id**, not a specific performed instance, so lab rows are reported for the clinic as a whole. The view is refreshed with `CALL medora_job_refresh_revenue_view();`, either manually or from a scheduler such as pg_cron; the refresh procedure was verified by manual invocation.
    571589
    572590=== Views ===
    573591
    574592{{{
     593DROP MATERIALIZED VIEW IF EXISTS mv_revenue_monthly CASCADE;
     594
    575595CREATE MATERIALIZED VIEW mv_revenue_monthly AS
    576596SELECT
     
    587607    JOIN departments dept ON dept.department_id = doc.department_id
    588608WHERE b.payment_status = 'PAID'
     609  AND b.payment_date IS NOT NULL
    589610GROUP BY DATE_TRUNC('month', b.payment_date), dept.department_id, dept.department_name
    590611
     
    602623    JOIN lab_tests lt ON lt.test_id = blt.test_id
    603624WHERE b.payment_status = 'PAID'
     625  AND b.payment_date IS NOT NULL
    604626GROUP BY DATE_TRUNC('month', b.payment_date);
    605627
     
    617639{{{
    618640CREATE OR REPLACE PROCEDURE medora_job_refresh_revenue_view()
    619     LANGUAGE plpgsql
     641LANGUAGE plpgsql
    620642AS $$
    621643BEGIN