Skip to content
Merged
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
20 changes: 20 additions & 0 deletions migration/db/migrations/20251211000000001_add_history_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

CREATE TABLE IF NOT EXISTS "history" (
id TEXT NOT NULL PRIMARY KEY,
type TEXT NOT NULL,
resource_id TEXT NOT NULL,
action TEXT NOT NULL,
changed_fields TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX IF NOT EXISTS history_type_index ON "history" USING BTREE (type);
CREATE INDEX IF NOT EXISTS history_resource_id_index ON "history" USING BTREE (resource_id);

-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back

DROP TABLE IF EXISTS "history";
82 changes: 82 additions & 0 deletions spec/event_metadata_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,87 @@ module PlaceOS::Model
EventMetadata.by_tenant(tenant.id).to_a.first.permission.should eq EventMetadata::Permission::PRIVATE
end
end

describe "history recording" do
Spec.before_each do
History.clear
end

it "records history when metadata fields are updated" do
tenant = get_tenant
event_start = 5.minutes.from_now
event_end = 10.minutes.from_now

event = Generator.event_metadata(tenant.id, event_start, event_end)
event.save!

# Should not record history for initial save
History.all.to_a.size.should eq 0

# Update a field
event.setup_time = 300
event.save!

histories = History.all.to_a
histories.size.should eq 1
histories.first.type.should eq "event"
histories.first.resource_id.should eq event.event_id
histories.first.action.should eq "updated"
histories.first.changed_fields.should contain "setup_time"
end

it "records multiple changed fields" do
tenant = get_tenant
event_start = 5.minutes.from_now
event_end = 10.minutes.from_now

event = Generator.event_metadata(tenant.id, event_start, event_end)
event.save!

# Update multiple fields
event.setup_time = 300
event.breakdown_time = 600
event.cancelled = true
event.save!

histories = History.all.to_a
histories.size.should eq 1
histories.first.changed_fields.should contain "setup_time"
histories.first.changed_fields.should contain "breakdown_time"
histories.first.changed_fields.should contain "cancelled"
end

it "tracks ext_data changes at the field level" do
tenant = get_tenant
event_start = 5.minutes.from_now
event_end = 10.minutes.from_now

event = Generator.event_metadata(tenant.id, event_start, event_end)
event.save!

# Set ext_data
event.set_ext_data(JSON.parse(%({"catering": true, "notes": "test"})))
event.save!

histories = History.all.to_a
histories.size.should eq 1
histories.first.changed_fields.should contain "ext_data.catering"
histories.first.changed_fields.should contain "ext_data.notes"
end

it "does not record history when no fields changed" do
tenant = get_tenant
event_start = 5.minutes.from_now
event_end = 10.minutes.from_now

event = Generator.event_metadata(tenant.id, event_start, event_end)
event.save!

# Save without changes
event.save!

History.all.to_a.size.should eq 0
end
end
end
end
14 changes: 14 additions & 0 deletions spec/generator.cr
Original file line number Diff line number Diff line change
Expand Up @@ -741,5 +741,19 @@ module PlaceOS::Model
alert_dashboard_id: alert_dashboard_id
)
end

def self.history(
type : String = "zone",
resource_id : String = "zone-#{RANDOM.hex(4)}",
action : String = "update",
changed_fields : Array(String) = ["name", "description"],
)
History.new(
type: type,
resource_id: resource_id,
action: action,
changed_fields: changed_fields
)
end
end
end
89 changes: 89 additions & 0 deletions spec/history_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
require "./helper"

module PlaceOS::Model
describe History do
Spec.before_each do
History.clear
end

test_round_trip(History)

it "saves a History" do
history = Generator.history.save!

history.should_not be_nil
history.persisted?.should be_true
history.id.as(String).starts_with?("history-").should be_true

found = History.find!(history.id.as(String))
found.id.should eq history.id
found.type.should eq history.type
found.resource_id.should eq history.resource_id
found.action.should eq history.action
found.changed_fields.should eq history.changed_fields
end

it "requires type" do
history = History.new(
type: "",
resource_id: "zone-123",
action: "update"
)
history.valid?.should be_false
history.errors.first.field.should eq :type
end

it "requires resource_id" do
history = History.new(
type: "zone",
resource_id: "",
action: "update"
)
history.valid?.should be_false
history.errors.first.field.should eq :resource_id
end

it "defaults changed_fields to empty array" do
history = History.new(
type: "zone",
resource_id: "zone-123",
action: "create"
)
history.save!

history.changed_fields.should eq [] of String
end

it "saves action field" do
history = History.new(
type: "zone",
resource_id: "zone-123",
action: "update",
changed_fields: ["name"]
)
history.save!

found = History.find!(history.id.as(String))
found.action.should eq "update"
end

it "supports different action types" do
["create", "update", "delete"].each do |action|
history = History.new(
type: "zone",
resource_id: "zone-123",
action: action
)
history.save!
history.action.should eq action
end
end

it "sets timestamps on create" do
history = Generator.history.save!

history.created_at.should_not be_nil
history.updated_at.should_not be_nil
end
end
end
46 changes: 46 additions & 0 deletions src/placeos-models/event_metadata.cr
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,52 @@ module PlaceOS::Model
end
end

before_save :record_metadata_history

# ameba:disable Metrics/CyclomaticComplexity
protected def record_metadata_history
# Skip if this is a new record being created - event creation is tracked via notify_change
return unless persisted?

changed_fields = [] of String

# Check each trackable field
changed_fields << "system_id" if system_id_changed?
changed_fields << "event_start" if event_start_changed?
changed_fields << "event_end" if event_end_changed?
changed_fields << "cancelled" if cancelled_changed?
changed_fields << "setup_time" if setup_time_changed?
changed_fields << "breakdown_time" if breakdown_time_changed?
changed_fields << "setup_event_id" if setup_event_id_changed?
changed_fields << "breakdown_event_id" if breakdown_event_id_changed?
changed_fields << "permission" if permission_changed?

# For ext_data, peek one level to see which keys changed
if ext_data_changed?
current = ext_data.try(&.as_h?) || {} of String => JSON::Any
previous = ext_data_was.try(&.as_h?) || {} of String => JSON::Any

# Find keys that were added, removed, or modified
all_keys = (current.keys + previous.keys).uniq
all_keys.each do |key|
if current[key]? != previous[key]?
changed_fields << "ext_data.#{key}"
end
end
end

return if changed_fields.empty?

History.create!(
type: "event",
resource_id: event_id,
action: "updated",
changed_fields: changed_fields
)
rescue ex
Log.error(exception: ex) { "failed to record event metadata history for #{event_id}" }
end

def self.migrate_recurring_metadata(system_id : String, recurrance : PlaceCalendar::Event, parent_metadata : EventMetadata)
metadata = EventMetadata.new

Expand Down
21 changes: 21 additions & 0 deletions src/placeos-models/history.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
require "./base/model"

module PlaceOS::Model
class History < ModelBase
include PlaceOS::Model::Timestamps

table :history

attribute type : String, es_subfield: "keyword"
attribute resource_id : String, es_subfield: "keyword"
attribute action : String, es_subfield: "keyword"
attribute changed_fields : Array(String) = [] of String, es_type: "keyword"

# Validation
###############################################################################################

validates :type, presence: true
validates :resource_id, presence: true
validates :action, presence: true
end
end