keinaufwand-sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ module Keinaufwand
2
+ module Sync
3
+ class Record < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ self.table_name = "keinaufwand_records"
6
+ self.primary_key = [:id, :type]
7
+
8
+ class_attribute :keinaufwand_schema, instance_writer: false
9
+
10
+ def self.configure_keinaufwand_schema!(schema)
11
+ self.keinaufwand_schema = schema
12
+ descendants.each(&:boot_keinaufwand_dynamics!) if schema && respond_to?(:descendants)
13
+ end
14
+
15
+ def self.where_present(*attributes)
16
+ attributes.reduce(all) do |scope, attribute|
17
+ expression = json_value(attribute)
18
+ scope.where("#{expression} IS NOT NULL").where("#{expression} != ?", "")
19
+ end
20
+ end
21
+
22
+ def self.find(*ids)
23
+ return super if ids.empty?
24
+
25
+ ids = ids.flatten
26
+ return where(id: ids).index_by(&:id).values_at(*ids.map(&:to_i)) if ids.many?
27
+
28
+ find_by!(id: ids.first)
29
+ end
30
+
31
+ def self.exists?(conditions = :none)
32
+ if composite_primary_key? && conditions != :none && !conditions.is_a?(Hash) && !conditions.is_a?(Array)
33
+ return super(id: conditions)
34
+ end
35
+
36
+ super
37
+ end
38
+
39
+ def self.json_value(attribute)
40
+ table = connection.quote_table_name(table_name)
41
+ column = connection.quote_column_name("data")
42
+ json_value_expression("#{table}.#{column}", attribute)
43
+ end
44
+
45
+ def self.json_value_expression(column, attribute)
46
+ if connection.adapter_name.match?(/postgres/i)
47
+ expression = "#{column} #>> #{connection.quote("{#{attribute.to_s.split(".").join(",")}}")}"
48
+ postgres_cast(expression, attribute)
49
+ else
50
+ expression = "json_extract(#{column}, #{connection.quote("$.#{attribute}")})"
51
+ sqlite_cast(expression, attribute)
52
+ end
53
+ end
54
+
55
+ def self.json_attribute?(attribute)
56
+ !column_names.include?(attribute.to_s) && schema_attribute?(attribute)
57
+ end
58
+
59
+ def self.schema
60
+ keinaufwand_schema || load_keinaufwand_schema
61
+ end
62
+
63
+ def self.load_keinaufwand_schema
64
+ version = Keinaufwand::Api.configuration.version || "2026-07-02"
65
+ self.keinaufwand_schema = Keinaufwand::Api::Schema.load(Keinaufwand::Api.configuration.schema_path(version))
66
+ end
67
+
68
+ def self.model_schema
69
+ schema&.for(name.demodulize)
70
+ end
71
+
72
+ def self.schema_attributes
73
+ model_schema&.fetch("attributes", {}) || {}
74
+ end
75
+
76
+ def self.schema_associations
77
+ model_schema&.fetch("associations", {}) || {}
78
+ end
79
+
80
+ def self.schema_attribute?(attribute)
81
+ attribute = attribute.to_s
82
+ schema_attributes.key?(attribute) || schema_attributes.key?(attribute.split(".").first)
83
+ end
84
+
85
+ def self.schema_attribute_type(attribute)
86
+ attribute = attribute.to_s
87
+ (schema_attributes[attribute].presence || schema_attributes[attribute.split(".").first]).to_s
88
+ end
89
+
90
+ def self.inherited(subclass)
91
+ super
92
+ subclass.boot_keinaufwand_dynamics!
93
+ end
94
+
95
+ def self.boot_keinaufwand_dynamics!
96
+ return if abstract_class? || name.blank? || name == "Keinaufwand::Record"
97
+
98
+ schema_attributes.each_key do |attribute|
99
+ define_dynamic_attribute(attribute)
100
+ end
101
+
102
+ schema_associations.each do |association, config|
103
+ define_dynamic_association(association, config)
104
+ end
105
+ rescue Errno::ENOENT
106
+ nil
107
+ end
108
+
109
+ def self.define_dynamic_attribute(attribute)
110
+ attribute = attribute.to_s
111
+ return if attribute == "id"
112
+
113
+ define_method(attribute) do |variant = :large|
114
+ value = read_keinaufwand_value(attribute)
115
+ if asset_attribute?(attribute, value)
116
+ value = derivative_value(value, variant)
117
+ value = local_asset_url(value) if respond_to?(:local_asset_url)
118
+ end
119
+ value = localize_asset_urls(value) if attribute == "body" && respond_to?(:localize_asset_urls)
120
+ cast_keinaufwand_value(attribute, value)
121
+ end
122
+
123
+ define_method("#{attribute}?") do
124
+ value = public_send(attribute)
125
+ value == true || value.present?
126
+ end
127
+ end
128
+
129
+ def self.define_dynamic_association(association, config)
130
+ association = association.to_s
131
+ config = config.with_indifferent_access
132
+ type = config[:type].to_s
133
+ target_class_name = config[:class_name] || association.singularize.classify
134
+
135
+ define_dynamic_foreign_key(association, type) if %w[belongs_to has_one].include?(type)
136
+ define_dynamic_ids(association) if type == "has_and_belongs_to_many"
137
+
138
+ define_method(association) do
139
+ return instance_variable_get("@#{association}") if instance_variable_defined?("@#{association}")
140
+
141
+ target_class = target_class_name.safe_constantize
142
+ result = target_class ? load_keinaufwand_association(association, type, target_class, config) : nil
143
+ instance_variable_set("@#{association}", result)
144
+ end
145
+
146
+ define_method("#{association}=") do |value|
147
+ instance_variable_set("@#{association}", value)
148
+ end
149
+ end
150
+
151
+ def self.define_dynamic_foreign_key(association, type)
152
+ key = "#{association}_id"
153
+ return if schema_attribute?(key)
154
+
155
+ define_method(key) do
156
+ read_keinaufwand_value(key)
157
+ end
158
+ end
159
+
160
+ def self.define_dynamic_ids(association)
161
+ key = "#{association.singularize}_ids"
162
+ return if schema_attribute?(key)
163
+
164
+ define_method(key) do
165
+ Array(read_keinaufwand_value(key))
166
+ end
167
+ end
168
+
169
+ def self.postgres_cast(expression, attribute)
170
+ case schema_attribute_type(attribute)
171
+ when "integer" then "(#{expression})::integer"
172
+ when "float", "decimal" then "(#{expression})::numeric"
173
+ when "date" then "(#{expression})::date"
174
+ when "datetime", "time" then "(#{expression})::timestamp"
175
+ when "boolean" then "(#{expression})::boolean"
176
+ else expression
177
+ end
178
+ end
179
+
180
+ def self.sqlite_cast(expression, attribute)
181
+ case schema_attribute_type(attribute)
182
+ when "integer" then "CAST(#{expression} AS INTEGER)"
183
+ when "float", "decimal" then "CAST(#{expression} AS REAL)"
184
+ else expression
185
+ end
186
+ end
187
+
188
+ def self.json_hash_conditions?(conditions)
189
+ conditions.is_a?(Hash) && conditions.any? { |key, _| json_attribute?(key) }
190
+ end
191
+
192
+ def self.split_json_conditions(conditions)
193
+ conditions.partition { |key, _| json_attribute?(key) }.map(&:to_h)
194
+ end
195
+
196
+ def self.apply_json_conditions(relation, conditions)
197
+ conditions.reduce(relation) do |scope, (attribute, value)|
198
+ scope.where(json_condition_sql(attribute, value), *json_condition_binds(value))
199
+ end
200
+ end
201
+
202
+ def self.json_order_argument?(argument)
203
+ case argument
204
+ when Symbol
205
+ json_attribute?(argument)
206
+ when Hash
207
+ argument.keys.all? { |key| json_attribute?(key) }
208
+ else
209
+ false
210
+ end
211
+ end
212
+
213
+ def self.json_order_sql(argument)
214
+ case argument
215
+ when Symbol
216
+ json_value(argument)
217
+ when Hash
218
+ argument.map do |attribute, direction|
219
+ direction = direction.to_s.downcase == "desc" ? "DESC" : "ASC"
220
+ "#{json_value(attribute)} #{direction}"
221
+ end
222
+ end
223
+ end
224
+
225
+ def self.json_condition_sql(attribute, value)
226
+ expression = json_value(attribute)
227
+
228
+ case value
229
+ when Array
230
+ return "1=0" if value.empty?
231
+ return "(#{expression} IN (?) OR #{expression} IS NULL)" if value.include?(nil)
232
+
233
+ "#{expression} IN (?)"
234
+ when Range
235
+ return "#{expression} >= ?" if value.end.nil?
236
+ return "#{expression} #{value.exclude_end? ? '<' : '<='} ?" if value.begin.nil?
237
+ return "#{expression} >= ? AND #{expression} < ?" if value.exclude_end?
238
+
239
+ "#{expression} BETWEEN ? AND ?"
240
+ when nil
241
+ "#{expression} IS NULL"
242
+ else
243
+ "#{expression} = ?"
244
+ end
245
+ end
246
+
247
+ def self.json_condition_binds(value)
248
+ case value
249
+ when Array
250
+ return [] if value.empty?
251
+
252
+ [value.compact.map { |item| json_bind_value(item) }]
253
+ when Range
254
+ return [json_bind_value(value.begin)] if value.end.nil?
255
+ return [json_bind_value(value.end)] if value.begin.nil?
256
+
257
+ [json_bind_value(value.begin), json_bind_value(value.end)]
258
+ when nil
259
+ []
260
+ else
261
+ [json_bind_value(value)]
262
+ end
263
+ end
264
+
265
+ def self.json_bind_value(value)
266
+ case value
267
+ when Time, ActiveSupport::TimeWithZone
268
+ value.iso8601
269
+ when Date
270
+ value.iso8601
271
+ else
272
+ value
273
+ end
274
+ end
275
+
276
+ def id
277
+ read_attribute(:id)
278
+ end
279
+
280
+ def id=(value)
281
+ if value.is_a?(Array)
282
+ write_keinaufwand_column("id", value.first)
283
+ write_keinaufwand_column("type", value.second)
284
+ else
285
+ write_keinaufwand_column("id", value)
286
+ write_keinaufwand_column("type", self.class.name) if self[:type].blank? && self.class.name.present?
287
+ end
288
+ end
289
+
290
+ def primary_key_values_present?
291
+ Array(self.class.primary_key).all? { |column| @attributes.fetch_value(column).present? }
292
+ end
293
+
294
+ def write_keinaufwand_column(attribute, value)
295
+ @attributes.write_from_user(attribute, value)
296
+ end
297
+
298
+ def remember_transaction_record_state
299
+ super
300
+ return unless self.class.composite_primary_key? && @_start_transaction_state
301
+
302
+ @_start_transaction_state[:id] = self.class.primary_key.map { |column| @attributes.fetch_value(column) }
303
+ end
304
+
305
+ def read_keinaufwand_value(attribute)
306
+ data[attribute.to_s] if data.is_a?(Hash)
307
+ end
308
+
309
+ def load_keinaufwand_association(association, type, target_class, config)
310
+ inline_value = read_keinaufwand_value(association)
311
+
312
+ case type
313
+ when "belongs_to", "has_one"
314
+ target_id = read_keinaufwand_value("#{association}_id")
315
+ local = target_class.find_by(id: target_id) if target_id.present?
316
+ local || build_inline_keinaufwand_records(inline_value, target_class)
317
+ when "has_many"
318
+ foreign_key = config[:foreign_key] || "#{self.class.name.demodulize.underscore}_id"
319
+ local = target_class.where(foreign_key => id)
320
+ local.exists? ? local : build_inline_keinaufwand_records(inline_value, target_class)
321
+ when "has_and_belongs_to_many"
322
+ target_ids = Array(read_keinaufwand_value("#{association.singularize}_ids"))
323
+ target_class.where(id: target_ids)
324
+ else
325
+ build_inline_keinaufwand_records(inline_value, target_class)
326
+ end
327
+ end
328
+
329
+ def build_inline_keinaufwand_records(value, target_class)
330
+ if value.is_a?(Array)
331
+ value.map { |attributes| build_inline_keinaufwand_record(attributes, target_class) }.compact
332
+ elsif value.is_a?(Hash)
333
+ build_inline_keinaufwand_record(value, target_class)
334
+ end
335
+ end
336
+
337
+ def build_inline_keinaufwand_record(attributes, target_class)
338
+ return unless attributes.is_a?(Hash)
339
+
340
+ target_class.new(id: attributes["id"] || attributes[:id], type: target_class.name, data: attributes.except("id", :id))
341
+ end
342
+
343
+ def cast_keinaufwand_value(attribute, value)
344
+ case self.class.schema_attribute_type(attribute)
345
+ when "datetime", "time" then parse_keinaufwand_time(value) || value
346
+ when "date" then parse_keinaufwand_date(value) || value
347
+ when "integer" then value.to_i if value.present?
348
+ when "float" then value.to_f if value.present?
349
+ when "decimal" then value.to_d if value.present?
350
+ when "boolean" then value == true || value.to_s == "true"
351
+ else value
352
+ end
353
+ end
354
+
355
+ def parse_keinaufwand_time(value)
356
+ return value if value.is_a?(Time) || defined?(ActiveSupport::TimeWithZone) && value.is_a?(ActiveSupport::TimeWithZone)
357
+ return if value.blank?
358
+
359
+ Time.zone ? Time.zone.parse(value.to_s) : Time.parse(value.to_s)
360
+ rescue ArgumentError
361
+ nil
362
+ end
363
+
364
+ def parse_keinaufwand_date(value)
365
+ return value if value.is_a?(Date)
366
+ return if value.blank?
367
+
368
+ Date.parse(value.to_s)
369
+ rescue ArgumentError
370
+ nil
371
+ end
372
+
373
+ def asset_attribute?(attribute, value)
374
+ attribute.end_with?("_url") && value.is_a?(Hash)
375
+ end
376
+
377
+ def derivative_value(value, size)
378
+ return value unless value.is_a?(Hash)
379
+
380
+ value[size.to_s] || value[size.to_sym] || value.values.compact.first
381
+ end
382
+ end
383
+
384
+ module RelationMethods
385
+ def where(*args)
386
+ return super unless keinaufwand_record_relation? && args.first.is_a?(Hash) && klass.json_hash_conditions?(args.first)
387
+
388
+ json_conditions, column_conditions = klass.split_json_conditions(args.first)
389
+ relation = column_conditions.any? ? super(column_conditions, *args[1..]) : self
390
+ klass.apply_json_conditions(relation, json_conditions)
391
+ end
392
+
393
+ def order(*args)
394
+ return super unless keinaufwand_record_relation? && args.any? && args.all? { |arg| klass.json_order_argument?(arg) }
395
+
396
+ super(Arel.sql(args.flat_map { |arg| klass.json_order_sql(arg) }.join(", ")))
397
+ end
398
+
399
+ private
400
+
401
+ def keinaufwand_record_relation?
402
+ klass <= Keinaufwand::Sync::Record
403
+ rescue StandardError
404
+ false
405
+ end
406
+ end
407
+ end
408
+ end
409
+
410
+ ActiveRecord::Relation.prepend(Keinaufwand::Sync::RelationMethods)
@@ -0,0 +1,86 @@
1
+ module Keinaufwand
2
+ module Sync
3
+ class SyncService
4
+ DEFAULT_RESOURCES = %w[
5
+ Location Product Session EventDetail Event Post InstructorGroup
6
+ ].freeze
7
+
8
+ def initialize(client:, record_class:, sync_state_class:, resources: DEFAULT_RESOURCES, asset_class: nil, receipt_class: nil)
9
+ @client = client
10
+ @record_class = record_class
11
+ @sync_state_class = sync_state_class
12
+ @resources = resources
13
+ @asset_class = asset_class
14
+ @receipt_class = receipt_class
15
+ end
16
+
17
+ def perform
18
+ @resources.each { |resource| sync_resource(resource) }
19
+ end
20
+
21
+ private
22
+
23
+ def sync_resource(resource)
24
+ cursor = @sync_state_class.cursor_for(resource)
25
+ params = { limit: 100 }
26
+ params[:updated_since] = (cursor - 10.seconds).iso8601 if cursor
27
+
28
+ page = @client.public_send("retrieve_#{resource.pluralize.underscore}", params)
29
+ latest = nil
30
+
31
+ while page && !page.empty?
32
+ page.each do |record|
33
+ sync_assets(record.raw_attributes) if @asset_class && record.raw_attributes[:_deleted] != true
34
+ end
35
+
36
+ @record_class.transaction do
37
+ rows = page.filter_map do |record|
38
+ latest = latest_timestamp(latest, record.server_updated_at)
39
+ next if stale?(resource, record)
40
+
41
+ if record.raw_attributes[:_deleted] == true
42
+ delete_record(resource, record.id)
43
+ next
44
+ end
45
+
46
+ { id: record.id, type: resource, data: record.raw_attributes.except(:id), created_at: Time.current, updated_at: Time.current }
47
+ end
48
+
49
+ @record_class.upsert_all(rows, unique_by: %i[id type], update_only: %w[data updated_at]) if rows.any?
50
+ end
51
+ page = page.next
52
+ end
53
+
54
+ @sync_state_class.update_cursor!(resource, latest) if latest
55
+ end
56
+
57
+ def delete_record(resource, id)
58
+ @record_class.where(type: resource, id: id).delete_all
59
+ end
60
+
61
+ def stale?(resource, record)
62
+ return false unless record.server_updated_at
63
+
64
+ stored = @record_class.where(type: resource, id: record.id).pick(:data)
65
+ timestamp = stored&.fetch("updated_at", nil)
66
+ timestamps = [
67
+ (Time.iso8601(timestamp) if timestamp.present?),
68
+ @receipt_class&.latest_source_updated_at(resource, record.id)
69
+ ].compact
70
+ timestamps.any? { |value| value > record.server_updated_at }
71
+ end
72
+
73
+ def latest_timestamp(current, candidate)
74
+ candidate && (!current || candidate > current) ? candidate : current
75
+ end
76
+
77
+ def sync_assets(attributes)
78
+ @asset_class.urls_from(attributes).uniq.each do |url|
79
+ @asset_class.sync_url(url)
80
+ rescue StandardError => error
81
+ warn "Skipping Keinaufwand asset #{url}: #{error.message}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,25 @@
1
+ module Keinaufwand
2
+ module Sync
3
+ class SyncState < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ self.table_name = "keinaufwand_sync_states"
6
+ self.primary_key = :resource_class
7
+
8
+ def self.inherited(subclass)
9
+ super
10
+ subclass.table_name = table_name
11
+ subclass.primary_key = primary_key
12
+ end
13
+
14
+ def self.cursor_for(resource_class)
15
+ find_by(resource_class: resource_class)&.last_synced_at
16
+ end
17
+
18
+ def self.update_cursor!(resource_class, timestamp)
19
+ state = find_or_initialize_by(resource_class: resource_class)
20
+ state.last_synced_at = timestamp
21
+ state.save!
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ namespace :keinaufwand do
2
+ desc "Sync Keinaufwand API records into local storage"
3
+ task sync: :environment do
4
+ Keinaufwand::Sync.sync!
5
+ end
6
+
7
+ desc "Clear local Keinaufwand records and run a fresh sync"
8
+ task resync: :environment do
9
+ Keinaufwand::Sync.resync!
10
+ end
11
+
12
+ namespace :indices do
13
+ desc "Generate a migration for Keinaufwand JSON indexes from config/keinaufwand.yml"
14
+ task generate: :environment do
15
+ require "rails/generators"
16
+ Rails::Generators.invoke("keinaufwand:sync:indexes", [], destination_root: Rails.root.to_s)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ module Keinaufwand
2
+ module Sync
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,119 @@
1
+ module Keinaufwand
2
+ module Sync
3
+ class WebhookProcessor
4
+ class InvalidPayload < StandardError; end
5
+
6
+ def initialize(payload:, record_class:, receipt_class:, resources:, asset_class: nil)
7
+ @payload = payload.with_indifferent_access
8
+ @record_class = record_class
9
+ @receipt_class = receipt_class
10
+ @resources = resources.map(&:to_s)
11
+ @asset_class = asset_class
12
+ end
13
+
14
+ def perform
15
+ validate_payload!
16
+ return :duplicate if @receipt_class.exists?(event_id: event_id)
17
+ return record_receipt!(:ignored) unless resource_type && @resources.include?(resource_type)
18
+ return record_receipt!(:stale) if stale?
19
+
20
+ result = @record_class.transaction do
21
+ deleted? ? delete_record : upsert_record
22
+ record_receipt!(deleted? ? :destroyed : :synced)
23
+ end
24
+ sync_assets unless deleted?
25
+ result
26
+ rescue ActiveRecord::RecordNotUnique
27
+ :duplicate
28
+ end
29
+
30
+ private
31
+
32
+ def validate_payload!
33
+ raise InvalidPayload, "event_id is required" if event_id.blank?
34
+ raise InvalidPayload, "event_type is required" if event_type.blank?
35
+ raise InvalidPayload, "api_version is required" if api_version.blank?
36
+ raise InvalidPayload, "data must be an object" unless data.is_a?(Hash)
37
+ raise InvalidPayload, "data.id is required" if resource_id.blank?
38
+ raise InvalidPayload, "unsupported API version #{api_version}" unless schema.version == api_version
39
+ end
40
+
41
+ def schema
42
+ @schema ||= Keinaufwand::Api::Schema.load(Keinaufwand::Api.configuration.schema_path(api_version))
43
+ end
44
+
45
+ def resource_type
46
+ @resource_type ||= schema.model_names.find do |model_name|
47
+ schema.for(model_name).fetch(:resource_name).singularize == event_type.split(".").first
48
+ end
49
+ end
50
+
51
+ def stale?
52
+ timestamps = [
53
+ @receipt_class.latest_source_updated_at(resource_type, resource_id),
54
+ existing_record_updated_at
55
+ ].compact
56
+ source_updated_at && timestamps.any? { |timestamp| timestamp > source_updated_at }
57
+ end
58
+
59
+ def existing_record_updated_at
60
+ value = local_record&.data&.fetch("updated_at", nil)
61
+ Time.iso8601(value) if value.present?
62
+ rescue ArgumentError
63
+ nil
64
+ end
65
+
66
+ def upsert_record
67
+ attributes = data.except(:id, :_deleted).to_h
68
+ record = local_record || @record_class.new(id: resource_id, type: resource_type)
69
+ record.data = attributes
70
+ record.save!
71
+ end
72
+
73
+ def delete_record
74
+ local_record&.delete
75
+ end
76
+
77
+ def record_receipt!(result)
78
+ @receipt_class.create!(
79
+ event_id: event_id,
80
+ event_type: event_type,
81
+ api_version: api_version,
82
+ resource_type: resource_type || event_type.split(".").first.classify,
83
+ resource_id: resource_id,
84
+ source_updated_at: source_updated_at,
85
+ result: result.to_s,
86
+ processed_at: Time.current
87
+ )
88
+ result
89
+ end
90
+
91
+ def sync_assets
92
+ return unless @asset_class
93
+
94
+ @asset_class.urls_from(data).uniq.each do |url|
95
+ @asset_class.sync_url(url)
96
+ rescue StandardError => error
97
+ warn "Skipping Keinaufwand asset #{url}: #{error.message}"
98
+ end
99
+ end
100
+
101
+ def local_record
102
+ @local_record ||= @record_class.where(type: resource_type).find_by(id: resource_id)
103
+ end
104
+
105
+ def event_id = @payload[:event_id]
106
+ def event_type = @payload[:event_type].to_s
107
+ def api_version = @payload[:api_version].to_s
108
+ def data = @payload[:data]
109
+ def resource_id = data[:id]
110
+ def deleted? = data[:_deleted] == true
111
+
112
+ def source_updated_at
113
+ @source_updated_at ||= Time.iso8601(data[:updated_at].to_s) if data[:updated_at].present?
114
+ rescue ArgumentError
115
+ nil
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,13 @@
1
+ module Keinaufwand
2
+ module Sync
3
+ class WebhookReceipt < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ self.table_name = "keinaufwand_webhook_receipts"
6
+ self.primary_key = :event_id
7
+
8
+ def self.latest_source_updated_at(resource_type, resource_id)
9
+ where(resource_type: resource_type, resource_id: resource_id).maximum(:source_updated_at)
10
+ end
11
+ end
12
+ end
13
+ end