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.
- checksums.yaml +7 -0
- data/README.md +48 -0
- data/config/routes.rb +3 -0
- data/lib/generators/keinaufwand/sync/indexes/indexes_generator.rb +25 -0
- data/lib/generators/keinaufwand/sync/install/install_generator.rb +35 -0
- data/lib/generators/keinaufwand/sync/install/templates/create_keinaufwand_assets.rb +19 -0
- data/lib/generators/keinaufwand/sync/install/templates/create_keinaufwand_sync_tables.rb +24 -0
- data/lib/generators/keinaufwand/sync/install/templates/create_keinaufwand_webhook_receipts.rb +16 -0
- data/lib/keinaufwand/asset.rb +14 -0
- data/lib/keinaufwand/assets_controller.rb +12 -0
- data/lib/keinaufwand/record.rb +32 -0
- data/lib/keinaufwand/sync.rb +30 -0
- data/lib/keinaufwand/sync_core/asset.rb +69 -0
- data/lib/keinaufwand/sync_core/configuration.rb +101 -0
- data/lib/keinaufwand/sync_core/engine.rb +7 -0
- data/lib/keinaufwand/sync_core/index_manager.rb +83 -0
- data/lib/keinaufwand/sync_core/railtie.rb +9 -0
- data/lib/keinaufwand/sync_core/record.rb +410 -0
- data/lib/keinaufwand/sync_core/sync_service.rb +86 -0
- data/lib/keinaufwand/sync_core/sync_state.rb +25 -0
- data/lib/keinaufwand/sync_core/tasks/keinaufwand_sync.rake +19 -0
- data/lib/keinaufwand/sync_core/version.rb +5 -0
- data/lib/keinaufwand/sync_core/webhook_processor.rb +119 -0
- data/lib/keinaufwand/sync_core/webhook_receipt.rb +13 -0
- data/lib/keinaufwand/sync_core/webhooks_controller.rb +34 -0
- data/lib/keinaufwand/sync_state.rb +5 -0
- data/lib/keinaufwand/webhook_receipt.rb +5 -0
- metadata +124 -0
|
@@ -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,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
|