Skip to content
Open
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
108 changes: 108 additions & 0 deletions app/components/app_activity_log_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class AppActivityLogComponent < ViewComponent::Base
<%= event[:invalidated] ? tag.s(body) : body %>
</p></blockquote>
<% end %>

<% if (changes = event[:changes]).present? %>
<%= render AppDetailsComponent.new(open: true) do |details| %>
<% details.with_summary { pluralize(changes.size, "field") + " updated" } %>
<%= changes_summary_list(changes) %>
<% end %>
<% end %>
<% end %>
<% end %>
</div>
Expand All @@ -57,6 +64,7 @@ def all_events
gillick_assessment_events,
note_events,
notify_events,
patient_change_events,
patient_merge_events,
patient_specific_direction_events,
pre_screening_events,
Expand Down Expand Up @@ -270,6 +278,28 @@ def notify_events
end
end

def patient_change_events
patient_change_log_entries.map do |entry|
title =
case entry.source
when "manual_edit"
"Record updated manually"
when "cohort_import"
"Record updated after new details were imported in a cohort upload"
when "class_import"
"Record updated after new details were imported in a class upload"
else
raise "Unknown change source: #{entry.source}"
end
{
title:,
at: entry.created_at,
by: entry.user,
changes: entry.recorded_changes
}
end
end

def patient_merge_events
patient_merge_log_entries.map do |patient_merge_log_entry|
{
Expand Down Expand Up @@ -435,6 +465,8 @@ def attendance_events

private

delegate :format_nhs_number, :govuk_summary_list, to: :helpers

def include_programme_specific_events?
@programme_type.present? || @session.present?
end
Expand All @@ -446,6 +478,13 @@ def archive_reasons
@patient.archive_reasons.where(team: @team).includes(:created_by)
end

def patient_change_log_entries
return [] if include_programme_specific_events?

@patient_change_log_entries ||=
@patient.patient_change_log_entries.includes(:user)
end

def patient_merge_log_entries
return [] if include_programme_specific_events?

Expand Down Expand Up @@ -615,6 +654,75 @@ def vaccination_records
end
end

def changes_summary_list(changes)
rows =
PatientChangeLogEntry::TRACKED_ATTRIBUTES.filter_map do |attr|
next unless changes.key?(attr)

old_val, new_val = changes[attr]
{
key: {
text: PatientChangeLogEntry.label_for(attr)
},
value: {
text: change_value_html(attr, old_val, new_val)
}
}
end
govuk_summary_list(rows:)
end

def change_value_html(attr, old_val, new_val)
arrow =
tag.svg(
safe_join(
[
tag.title("changed to"),
tag.path(
d:
"m14.7 6.3 5 5c.2.2.3.4.3.7 0 .3-.1.5-.3.7l-5 5" \
"a1 1 0 0 1-1.4-1.4l3.3-3.3H5a1 1 0 0 1 0-2" \
"h11.6l-3.3-3.3a1 1 0 1 1 1.4-1.4Z"
)
]
),
class: "nhsuk-icon nhsuk-icon--arrow-right",
style: "vertical-align: middle",
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 24 24",
width: "16",
height: "16",
focusable: "false",
role: "img",
"aria-label": "changed to"
)

safe_join(
[
format_change_value(attr, old_val),
" ",
arrow,
" ",
tag.mark(format_change_value(attr, new_val), class: "app-highlight")
]
)
end

def format_change_value(attr, value)
return "Not provided" if value.nil? || value.to_s.empty?

case attr
when "date_of_birth"
Date.parse(value.to_s).to_fs(:long)
when "gender_code"
Patient.human_enum_name(:gender_code, value)
when "nhs_number"
format_nhs_number(value.to_s)
else
value.to_s
end
end

def expired_items_for(academic_year:, programmes:)
{
consents:,
Expand Down
7 changes: 6 additions & 1 deletion app/controllers/imports/issues_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ def set_form
apply_changes = params.dig(:import_duplicate_form, :apply_changes)

@form =
ImportDuplicateForm.new(current_team:, object: @record, apply_changes:)
ImportDuplicateForm.new(
current_team:,
current_user:,
object: @record,
apply_changes:
)
end

def set_type
Expand Down
5 changes: 5 additions & 0 deletions app/controllers/patients/edit_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ def update_nhs_number
@patient.invalidated_at = nil

if @patient.save
PatientChangeLogEntry.log_saved_changes!(
patient: @patient,
user: current_user,
source: :manual_edit
)
PatientUpdateFromPDSJob.perform_later(@patient)

redirect_to edit_patient_path(@patient)
Expand Down
19 changes: 16 additions & 3 deletions app/forms/import_duplicate_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class ImportDuplicateForm
include ActiveModel::Model

attr_accessor :current_team, :object, :apply_changes
attr_accessor :current_team, :current_user, :object, :apply_changes

validates :apply_changes, inclusion: { in: :apply_changes_options }

Expand Down Expand Up @@ -55,6 +55,14 @@ def apply_pending_changes!
object.patient.apply_pending_changes! if object.respond_to?(:patient)

object.apply_pending_changes!

if object.is_a?(Patient)
PatientChangeLogEntry.log_saved_changes!(
patient: object,
user: current_user,
source: patient_import_source(object)
)
end
end

def discard_pending_changes!
Expand All @@ -68,11 +76,11 @@ def keep_both_changes!
return unless can_apply?

object.apply_pending_changes_to_new_record!(
changeset: changeset_for_keep_both
changeset: changeset_for_pending_changes
)
end

def changeset_for_keep_both
def changeset_for_pending_changes
scope = object.changesets.includes(:import).order(:created_at)

completed_import_statuses = %w[
Expand All @@ -90,4 +98,9 @@ def changeset_for_keep_both
def reset_count!
TeamCachedCounts.new(current_team).reset_import_issues!
end

def patient_import_source(_patient)
import = changeset_for_pending_changes&.import
import.is_a?(ClassImport) ? :class_import : :cohort_import
end
end
4 changes: 4 additions & 0 deletions app/lib/patient_merger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ def call
patient_id: patient_to_keep.id
)

patient_to_destroy.patient_change_log_entries.update_all(
patient_id: patient_to_keep.id
)

patient_to_destroy.patient_merge_log_entries.update_all(
patient_id: patient_to_keep.id
)
Expand Down
1 change: 1 addition & 0 deletions app/models/concerns/patient_import_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def import_patients_and_parents(changesets, import)
patients_with_nhs_number_changes =
patients.select(&:nhs_number_previously_changed?)

PatientChangeLogEntry.log_import_changes!(patients: patients.to_a, import:)
Patient.import(patients.to_a, on_duplicate_key_update: :all)
link_records_to_import(import, Patient, patients)

Expand Down
1 change: 1 addition & 0 deletions app/models/patient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class Patient < ApplicationRecord
has_many :notify_log_entries
has_many :parent_relationships, -> { order(:created_at) }
has_many :patient_locations
has_many :patient_change_log_entries
has_many :patient_merge_log_entries
has_many :patient_programme_vaccinations_searches
has_many :patient_specific_directions
Expand Down
78 changes: 78 additions & 0 deletions app/models/patient_change_log_entry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

# == Schema Information
#
# Table name: patient_change_log_entries
#
# id :bigint not null, primary key
# recorded_changes :jsonb not null
# source :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# patient_id :bigint not null
# user_id :bigint
#
# Indexes
#
# index_patient_change_log_entries_on_patient_id (patient_id)
# index_patient_change_log_entries_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (patient_id => patients.id)
# fk_rails_... (user_id => users.id)
#
class PatientChangeLogEntry < ApplicationRecord
ATTRIBUTE_LABELS = {
"nhs_number" => "NHS number",
"given_name" => "First name",
"family_name" => "Last name",
"preferred_given_name" => "Preferred first name",
"preferred_family_name" => "Preferred last name",
"date_of_birth" => "Date of birth",
"gender_code" => "Gender",
"address_line_1" => "Address line 1",
"address_line_2" => "Address line 2",
"address_town" => "Town",
"address_postcode" => "Postcode"
}.freeze
private_constant :ATTRIBUTE_LABELS

TRACKED_ATTRIBUTES = ATTRIBUTE_LABELS.keys.freeze

def self.label_for(attribute)
ATTRIBUTE_LABELS[attribute]
end

belongs_to :patient
belongs_to :user, optional: true

enum :source, { manual_edit: 0, cohort_import: 1, class_import: 2 }

def self.log_saved_changes!(patient:, user:, source:)
recorded_changes = patient.saved_changes.slice(*TRACKED_ATTRIBUTES)
return if recorded_changes.empty?

create!(patient:, user:, source:, recorded_changes:)
end

def self.log_import_changes!(patients:, import:)
source = import.is_a?(CohortImport) ? :cohort_import : :class_import
user = import.uploaded_by

patients.each do |patient|
next if patient.id.blank?

recorded_changes =
patient
.changes
.slice(*TRACKED_ATTRIBUTES)
.reject do |_attr, (old_val, new_val)|
old_val.presence == new_val.presence
end
next if recorded_changes.empty?

create!(patient:, user:, source:, recorded_changes:)
end
end
end
13 changes: 13 additions & 0 deletions db/migrate/20260416130000_create_patient_change_log_entries.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class CreatePatientChangeLogEntries < ActiveRecord::Migration[7.2]
def change
create_table :patient_change_log_entries do |t|
t.references :patient, null: false, foreign_key: true
t.references :user, null: true, foreign_key: true
t.integer :source, null: false
t.jsonb :recorded_changes, null: false, default: {}
t.timestamps
end
end
end
15 changes: 14 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_04_07_121005) do
ActiveRecord::Schema[8.1].define(version: 2026_04_16_130001) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_trgm"
Expand Down Expand Up @@ -618,6 +618,17 @@
t.index ["email"], name: "index_parents_on_email"
end

create_table "patient_change_log_entries", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "patient_id", null: false
t.jsonb "recorded_changes", default: {}, null: false
t.integer "source", null: false
t.datetime "updated_at", null: false
t.bigint "user_id"
t.index ["patient_id"], name: "index_patient_change_log_entries_on_patient_id"
t.index ["user_id"], name: "index_patient_change_log_entries_on_user_id"
end

create_table "patient_changesets", force: :cascade do |t|
t.datetime "created_at", null: false
t.jsonb "data"
Expand Down Expand Up @@ -1156,6 +1167,8 @@
add_foreign_key "notify_log_entry_programmes", "notify_log_entries", on_delete: :cascade
add_foreign_key "parent_relationships", "parents"
add_foreign_key "parent_relationships", "patients"
add_foreign_key "patient_change_log_entries", "patients"
add_foreign_key "patient_change_log_entries", "users"
add_foreign_key "patient_changesets", "locations", column: "school_id"
add_foreign_key "patient_changesets", "patients"
add_foreign_key "patient_locations", "locations"
Expand Down
Loading