nexo 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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +8 -0
  5. data/app/helpers/nexo/controller_helper.rb +10 -0
  6. data/app/jobs/nexo/api_clients.rb +25 -0
  7. data/app/jobs/nexo/base_job.rb +8 -0
  8. data/app/jobs/nexo/delete_remote_resource_job.rb +10 -0
  9. data/app/jobs/nexo/folder_sync_job.rb +25 -0
  10. data/app/jobs/nexo/sync_element_job.rb +91 -0
  11. data/app/jobs/nexo/synchronizable_changed_job.rb +25 -0
  12. data/app/jobs/nexo/update_remote_resource_job.rb +56 -0
  13. data/app/lib/nexo/active_record_google_token_store.rb +43 -0
  14. data/app/lib/nexo/api_client/api_response.rb +4 -0
  15. data/app/lib/nexo/api_client/calendar_service.rb +9 -0
  16. data/app/lib/nexo/api_client/google_auth_service.rb +89 -0
  17. data/app/lib/nexo/api_client/google_calendar_service.rb +113 -0
  18. data/app/lib/nexo/api_client/google_dummy_calendar_service.rb +31 -0
  19. data/app/lib/nexo/errors.rb +28 -0
  20. data/app/lib/nexo/event_receiver.rb +45 -0
  21. data/app/lib/nexo/folder_service.rb +67 -0
  22. data/app/lib/nexo/policy_service.rb +40 -0
  23. data/app/lib/nexo/service_builder.rb +30 -0
  24. data/app/models/concerns/nexo/calendar_event.rb +56 -0
  25. data/app/models/concerns/nexo/folder_policy.rb +24 -0
  26. data/app/models/concerns/nexo/synchronizable.rb +88 -0
  27. data/app/models/nexo/application_record.rb +5 -0
  28. data/app/models/nexo/client.rb +49 -0
  29. data/app/models/nexo/element.rb +74 -0
  30. data/app/models/nexo/element_version.rb +38 -0
  31. data/app/models/nexo/folder.rb +43 -0
  32. data/app/models/nexo/integration.rb +60 -0
  33. data/app/models/nexo/token.rb +29 -0
  34. data/config/routes.rb +2 -0
  35. data/db/migrate/20250505192315_create_nexo_clients.rb +13 -0
  36. data/db/migrate/20250505195429_create_nexo_integrations.rb +15 -0
  37. data/db/migrate/20250506125057_create_nexo_tokens.rb +12 -0
  38. data/db/migrate/20250512025423_create_nexo_folders.rb +13 -0
  39. data/db/migrate/20250512025950_create_nexo_elements.rb +16 -0
  40. data/db/migrate/20250512030530_create_nexo_element_versions.rb +13 -0
  41. data/db/migrate/20250519210346_create_good_jobs.rb +104 -0
  42. data/db/seeds.rb +3 -0
  43. data/lib/nexo/engine.rb +24 -0
  44. data/lib/nexo/version.rb +5 -0
  45. data/lib/nexo.rb +6 -0
  46. metadata +144 -0
@@ -0,0 +1,45 @@
1
+ module Nexo
2
+ # This callbacks must be called when synchronizables change locally, must not
3
+ # be called when the system is notified of an external element change
4
+ class EventReceiver
5
+ def synchronizable_created(synchronizable)
6
+ validate_synchronizable_state!(synchronizable)
7
+
8
+ synchronizable.initialize_values!
9
+
10
+ SynchronizableChangedJob.perform_later(synchronizable)
11
+ end
12
+
13
+ def synchronizable_updated(synchronizable)
14
+ if synchronizable.change_is_significative_to_sequence?
15
+ synchronizable.increment_sequence!
16
+ end
17
+
18
+ # Even if sequence remains the same the synchronizable may be removed
19
+ # from some Folder, so we have to always enqueue the job
20
+ SynchronizableChangedJob.perform_later(synchronizable)
21
+ end
22
+
23
+ def synchronizable_destroyed(synchronizable)
24
+ folder_service.destroy_elements(synchronizable, :synchronizable_destroyed)
25
+ end
26
+
27
+ def folder_policy_changed(folder_policy)
28
+ FolderSyncJob.perform_later(folder_policy.folder)
29
+ end
30
+
31
+ private
32
+
33
+ # :nocov: mocked
34
+ def folder_service
35
+ @folder_service ||= FolderService.new
36
+ end
37
+ # :nocov:
38
+
39
+ def validate_synchronizable_state!(synchronizable)
40
+ unless synchronizable.sequence.nil?
41
+ raise Errors::InvalidSynchronizableState, "sequence is present: #{synchronizable.sequence}"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,67 @@
1
+ module Nexo
2
+ # Handles the composition of folders
3
+ #
4
+ # Responsabilities:
5
+ # - Creation of Element's
6
+ # - Flagging Element's for deletion
7
+ # - Triggering the SyncElementJob
8
+ class FolderService
9
+ def find_element_and_sync(folder, synchronizable)
10
+ # TODO: handle conflicted synchronizable
11
+ element = find_element(folder, synchronizable)
12
+
13
+ if element.present?
14
+ sync_element(element)
15
+ else
16
+ create_and_sync_element(folder, synchronizable)
17
+ end
18
+ end
19
+
20
+ def destroy_elements(synchronizable, reason)
21
+ synchronizable.nexo_elements.each do |element|
22
+ element.flag_for_deletion!(reason)
23
+
24
+ SyncElementJob.perform_later(element)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def find_element(folder, synchronizable)
31
+ folder.find_element(synchronizable:)
32
+ end
33
+
34
+ def create_and_sync_element(folder, synchronizable)
35
+ must_be_included = folder.policy_match?(synchronizable)
36
+
37
+ if must_be_included
38
+ element = Element.create!(
39
+ synchronizable:,
40
+ folder:
41
+ )
42
+
43
+ if synchronizable.sequence.nil?
44
+ # TODO: whats the use of having this and also in EventReceiver?
45
+ synchronizable.initialize_values!
46
+ end
47
+
48
+ SyncElementJob.perform_later(element)
49
+ end
50
+ end
51
+
52
+ def sync_element(element)
53
+ synchronizable = element.synchronizable
54
+
55
+ if synchronizable.conflicted?
56
+ raise Nexo::Errors::ElementConflicted, element
57
+ end
58
+
59
+ # Check if Synchronizable still must be included in folder
60
+ if !element.policy_still_match?
61
+ element.flag_for_deletion!(:no_longer_included_in_folder)
62
+ end
63
+
64
+ SyncElementJob.perform_later(element)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,40 @@
1
+ module Nexo
2
+ class PolicyService
3
+ def initialize
4
+ @finders = []
5
+ end
6
+
7
+ @instance = new
8
+
9
+ private_class_method :new
10
+
11
+ def self.instance
12
+ @instance
13
+ end
14
+
15
+ def register_folder_policy_finder(&block)
16
+ @finders << block
17
+ end
18
+
19
+ attr_reader :finders
20
+
21
+ def match?(folder, synchronizable)
22
+ policies = policies_for(folder)
23
+ matching_policies = policies.select { |policy| policy.match?(synchronizable) }
24
+ if matching_policies.any?
25
+ aplicable_policy = matching_policies.sort_by { |policy| policy.priority }.last
26
+ aplicable_policy.sync_policy == :include
27
+ else
28
+ false
29
+ end
30
+ end
31
+
32
+ def policies_for(folder)
33
+ aux = @finders.map do |finder|
34
+ finder.call(folder)
35
+ end
36
+
37
+ aux.flatten.compact
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ module Nexo
2
+ # Centralizes the creation of API service objects
3
+ class ServiceBuilder
4
+ @instance = new
5
+
6
+ private_class_method :new
7
+
8
+ def self.instance
9
+ @instance
10
+ end
11
+
12
+ def build_protocol_service(folder)
13
+ service_klass_name = "#{folder.integration.client.service}_#{folder.protocol}_service".camelcase
14
+ build_service(service_klass_name, folder.integration)
15
+ end
16
+
17
+ def build_auth_service(integration)
18
+ service_klass_name = "#{integration.client.service}_auth_service".camelcase
19
+ build_service(service_klass_name, integration)
20
+ end
21
+
22
+ private
23
+
24
+ def build_service(klass_name, integration)
25
+ service_klass = Nexo.const_get(klass_name)
26
+
27
+ service_klass.new(integration)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,56 @@
1
+ module Nexo
2
+ module CalendarEvent
3
+ include Synchronizable
4
+
5
+ extend ActiveSupport::Concern
6
+
7
+ define_protocol(:nexo_calendar_event, %i[
8
+ date_from
9
+ date_to
10
+ time_from
11
+ time_to
12
+ summary
13
+ description
14
+ ])
15
+
16
+ def change_is_significative_to_sequence?
17
+ previous_changes.keys.map(&:to_sym).intersection(protocol_methods).any?
18
+ end
19
+
20
+ def datetime_from
21
+ build_date_time(date_from, time_from)
22
+ end
23
+
24
+ def datetime_to
25
+ build_date_time(date_to, time_to)
26
+ end
27
+
28
+ private
29
+
30
+ # @param [Date] date
31
+ # @param [Time] time
32
+ #
33
+ # @return [Date, DateTime]
34
+ def build_date_time(date, time = nil)
35
+ if time.present?
36
+ DateTime.new(
37
+ date.year,
38
+ date.month,
39
+ date.day,
40
+ time.hour,
41
+ time.min,
42
+ time.sec,
43
+ time.zone
44
+ )
45
+ else
46
+ date
47
+ end
48
+ end
49
+
50
+
51
+ # TODO: refactor https://api.rubyonrails.org/classes/ActiveSupport/Concern.html
52
+ included do
53
+ include Nexo::Synchronizable::Associations
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,24 @@
1
+ module Nexo
2
+ module FolderPolicy
3
+ extend ActiveSupport::Concern
4
+ # include ActiveModel::Model
5
+ include ActiveModel::Validations
6
+
7
+ # TODO: add belongs_to :folder ?
8
+
9
+ # validates_inclusion_of :sync_policy, in: %w( include exclude )
10
+ # TODO!: check if this is being used
11
+ included do
12
+ validates :sync_policy, inclusion: { in: %w[ bla ] }
13
+ validates :priority, numericality: true
14
+ end
15
+
16
+ def match?(synchronizable)
17
+ # :nocov: borderline
18
+ raise "must be implemented in subclass"
19
+ # :nocov:
20
+ end
21
+
22
+ attr_reader :priority, :sync_policy
23
+ end
24
+ end
@@ -0,0 +1,88 @@
1
+ module Nexo
2
+ module Synchronizable
3
+ extend ActiveSupport::Concern
4
+
5
+ def protocol_methods
6
+ # uuid (deprecated)
7
+ # hay un problema con generar un uuid por synchronizable y es que si se
8
+ # inserta en un google calendar y luego se elimina, luego ya no se
9
+ # puede volver a insertar con el mismo uuid, sería mejor que el id se
10
+ # genere automáticamente por google y guardarlo en Element
11
+ %i[
12
+ sequence
13
+ ]
14
+ end
15
+
16
+ module ClassMethods
17
+ def define_protocol(name, methods)
18
+ define_method(:protocols) do
19
+ if defined? super
20
+ # :nocov: TODO, not yet implemented
21
+ super << name
22
+ # :nocov:
23
+ else
24
+ [ name ]
25
+ end
26
+ end
27
+
28
+ define_method(:protocol_methods) do
29
+ if defined? super
30
+ [ super(), methods ].flatten
31
+ else
32
+ # :nocov: borderline
33
+ raise "protocol_methods should be defined"
34
+ # :nocov:
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def method_missing(method_name, *, &)
41
+ if method_name.in?(protocol_methods)
42
+ raise Errors::InterfaceMethodNotImplemented, method_name
43
+ end
44
+
45
+ super
46
+ end
47
+
48
+ # ----------------------------------------------------
49
+
50
+ module Associations
51
+ extend ActiveSupport::Concern
52
+ included do
53
+ has_many :nexo_elements, class_name: "Nexo::Element", as: :synchronizable
54
+ end
55
+ end
56
+
57
+ def conflicted?
58
+ nexo_elements.conflicted.any?
59
+ end
60
+
61
+ # :nocov: TODO, not yet implemented
62
+ def update_from!(element_version)
63
+ transaction do
64
+ # TODO: parse the element_version.payload
65
+ # and set the Synchronizable fields according to the Folder#protocol
66
+
67
+ new_sequence = increment_sequence!
68
+ element_version.update_sequence!(new_sequence)
69
+ end
70
+ end
71
+ # :nocov:
72
+
73
+ def initialize_values!
74
+ update!(sequence: 0)
75
+ end
76
+
77
+ def increment_sequence!
78
+ # This operation is performed directly in the database without the need
79
+ # to read the current value first. So, it is atomic at a database level,
80
+ # though the in-memory object consitency is subject to race conditions.
81
+ #
82
+ # increment(:sequence), without the bang, is not atomic
83
+ #
84
+ # https://api.rubyonrails.org/v7.2/classes/ActiveRecord/Persistence.html#method-i-increment-21
85
+ increment!(:sequence)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,5 @@
1
+ module Nexo
2
+ class ApplicationRecord < ::ApplicationRecord
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,49 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: nexo_clients
4
+ #
5
+ # id :integer not null, primary key
6
+ # service :integer
7
+ # secret :string
8
+ # tcp_status :integer
9
+ # brand_name :integer
10
+ # user_integrations_allowed :boolean
11
+ # created_at :datetime not null
12
+ # updated_at :datetime not null
13
+ #
14
+ require "google-apis-calendar_v3"
15
+ require "google-apis-oauth2_v2"
16
+
17
+ module Nexo
18
+ AVAILABLE_SCOPES = {
19
+ google: {
20
+ auth_email: Google::Apis::Oauth2V2::AUTH_USERINFO_EMAIL,
21
+ auth_calendar_app_created: Google::Apis::CalendarV3::AUTH_CALENDAR_APP_CREATED,
22
+ auth_calendar_calendarlist: Google::Apis::CalendarV3::AUTH_CALENDAR_CALENDARLIST
23
+ }
24
+ }
25
+
26
+ class Client < ApplicationRecord
27
+ encrypts :secret
28
+
29
+ enum :service, google: 0
30
+ enum :tcp_status, authorized: 0, disabled: 1, expired: 2
31
+
32
+ validates :service, :user_integrations_allowed,
33
+ :tcp_status, :secret, presence: true
34
+
35
+ serialize :secret, coder: JSON
36
+
37
+ def to_s
38
+ service.titleize
39
+ end
40
+
41
+ def available_scopes_for_select
42
+ service_scopes.keys
43
+ end
44
+
45
+ def service_scopes
46
+ AVAILABLE_SCOPES[service.to_sym]
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,74 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: nexo_elements
4
+ #
5
+ # id :integer not null, primary key
6
+ # folder_id :integer not null
7
+ # synchronizable_id :integer not null
8
+ # synchronizable_type :string not null
9
+ # uuid :string
10
+ # flag_deletion :boolean not null
11
+ # deletion_reason :integer
12
+ # conflicted :boolean default(FALSE), not null
13
+ # discarded_at :datetime
14
+ # created_at :datetime not null
15
+ # updated_at :datetime not null
16
+ #
17
+ module Nexo
18
+ class Element < ApplicationRecord
19
+ belongs_to :folder, class_name: "Nexo::Folder"
20
+ belongs_to :synchronizable, polymorphic: true
21
+ has_many :element_versions, dependent: :destroy, class_name: "Nexo::ElementVersion"
22
+
23
+ after_initialize do
24
+ # TODO: https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute
25
+ self.flag_deletion = false if flag_deletion.nil?
26
+ end
27
+
28
+ scope :kept, -> { where(discarded_at: nil) }
29
+
30
+ enum :deletion_reason, no_longer_included_in_folder: 0, synchronizable_destroyed: 1
31
+
32
+ scope :conflicted, -> { where(conflicted: true) }
33
+
34
+ def policy_still_match?
35
+ folder.policy_match?(synchronizable)
36
+ end
37
+
38
+ def last_synced_sequence
39
+ element_versions.pluck(:sequence).max || -1
40
+ end
41
+
42
+ def external_unsynced_change?
43
+ last_external_unsynced_version.present?
44
+ end
45
+
46
+ def last_external_unsynced_version
47
+ element_versions.where(sequence: nil).order(created_at: :desc).first
48
+ end
49
+
50
+ def flag_for_deletion!(deletion_reason)
51
+ update!(flag_deletion: true, deletion_reason:)
52
+ end
53
+
54
+ def flagged_for_deletion?
55
+ flag_deletion?
56
+ end
57
+
58
+ # :nocov: TODO, not yet being called
59
+ def flag_as_conflicted!
60
+ update!(conflicted: true)
61
+
62
+ # TODO: log "Conflicted Element: #{element.to_gid}"
63
+ end
64
+ # :nocov:
65
+
66
+ def discarded?
67
+ discarded_at.present?
68
+ end
69
+
70
+ def discard!
71
+ update!(discarded_at: Time.current)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,38 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: nexo_element_versions
4
+ #
5
+ # id :integer not null, primary key
6
+ # element_id :integer not null
7
+ # payload :string
8
+ # etag :string
9
+ # sequence :integer
10
+ # origin :integer not null
11
+ # created_at :datetime not null
12
+ # updated_at :datetime not null
13
+ #
14
+ module Nexo
15
+ # sequence
16
+ #
17
+ # cuando es null significa que el origin es external que debe ser
18
+ # sincronizado. si está presente, significa que fue sinzronizado
19
+ #
20
+ # etag
21
+ #
22
+ # id de versión remota, para evitar pisar datos remotos aún no fetcheados y
23
+ # para no volver a traer datos si no hubo ningún cambio desde la última vez
24
+ # que se fetchearon
25
+ #
26
+ # payload
27
+ #
28
+ # raw data from API
29
+ class ElementVersion < ApplicationRecord
30
+ belongs_to :element, class_name: "Nexo::Element"
31
+
32
+ enum :origin, internal: 0, external: 1
33
+
34
+ serialize :payload, coder: JSON
35
+
36
+ validates :payload, :etag, :origin, presence: true
37
+ end
38
+ end
@@ -0,0 +1,43 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: nexo_folders
4
+ #
5
+ # id :integer not null, primary key
6
+ # integration_id :integer not null
7
+ # protocol :integer not null
8
+ # external_identifier :string
9
+ # name :string
10
+ # description :string
11
+ # created_at :datetime not null
12
+ # updated_at :datetime not null
13
+ #
14
+ module Nexo
15
+ class Folder < ApplicationRecord
16
+ belongs_to :integration, class_name: "Nexo::Integration"
17
+ has_many :elements, class_name: "Nexo::Element"
18
+
19
+ enum :protocol, calendar: 0, dummy_calendar: 1
20
+
21
+ validates :protocol, :name, presence: true
22
+
23
+ # TODO!: find better name
24
+ # maybe policy_applies?
25
+ def policy_match?(synchronizable)
26
+ PolicyService.instance.match?(self, synchronizable)
27
+ end
28
+
29
+ def find_element(synchronizable:)
30
+ ary = elements.where(synchronizable:, discarded_at: nil).to_a
31
+
32
+ if ary.count > 1
33
+ raise Errors::MoreThanOneElementInFolderForSynchronizable
34
+ end
35
+
36
+ ary.first
37
+ end
38
+
39
+ def time_zone
40
+ Rails.application.config.time_zone
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,60 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: nexo_integrations
4
+ #
5
+ # id :integer not null, primary key
6
+ # user_id :integer not null
7
+ # client_id :integer not null
8
+ # name :string
9
+ # scope :string
10
+ # expires_at :datetime
11
+ # discarded_at :datetime
12
+ # created_at :datetime not null
13
+ # updated_at :datetime not null
14
+ #
15
+ module Nexo
16
+ class Integration < ApplicationRecord
17
+ include Discard::Model if defined? Discard::Model
18
+ include Hashid::Rails if defined? Hashid::Rails
19
+
20
+ serialize :scope, coder: JSON
21
+ belongs_to :user
22
+ belongs_to :client, class_name: "Nexo::Client"
23
+ has_many :tokens, class_name: "Nexo::Token"
24
+
25
+ validates :scope, presence: true
26
+
27
+ def external_api_scope
28
+ if scope.blank?
29
+ # :nocov: borderline
30
+ raise "scope must be present"
31
+ # :nocov:
32
+ end
33
+
34
+ scope.map { |permission| client.service_scopes[permission.to_sym] }
35
+ end
36
+
37
+ def expires_in
38
+ return unless credentials.present?
39
+
40
+ (credentials.expires_at - Time.current).to_i
41
+ end
42
+
43
+ def token_status
44
+ if credentials.present?
45
+ if credentials.expires_at > Time.current
46
+ :active_token
47
+ else
48
+ :expired_token
49
+ end
50
+ else
51
+ :no_token
52
+ end
53
+ end
54
+
55
+ def credentials
56
+ service = ServiceBuilder.instance.build_auth_service(self)
57
+ @credentials ||= service.get_credentials
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,29 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: nexo_tokens
4
+ #
5
+ # id :integer not null, primary key
6
+ # integration_id :integer not null
7
+ # secret :string
8
+ # tpt_status :integer not null
9
+ # environment :string not null
10
+ # created_at :datetime not null
11
+ # updated_at :datetime not null
12
+ #
13
+ module Nexo
14
+ class Token < ApplicationRecord
15
+ belongs_to :integration, class_name: "Nexo::Integration"
16
+
17
+ after_initialize do
18
+ # TODO: https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute
19
+ self.tpt_status = :active if tpt_status.nil?
20
+ self.environment = Rails.env if environment.nil?
21
+ end
22
+
23
+ encrypts :secret
24
+
25
+ enum :tpt_status, active: 0, revoked: 1, expired: 2
26
+
27
+ validates :secret, :tpt_status, :environment, presence: true
28
+ end
29
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Nexo::Engine.routes.draw do
2
+ end
@@ -0,0 +1,13 @@
1
+ class CreateNexoClients < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :nexo_clients do |t|
4
+ t.integer :service
5
+ t.string :secret
6
+ t.integer :tcp_status
7
+ t.integer :brand_name
8
+ t.boolean :user_integrations_allowed
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ class CreateNexoIntegrations < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :nexo_integrations do |t|
4
+ t.references :user, null: false, foreign_key: true
5
+ t.references :client, null: false, foreign_key: { to_table: :nexo_clients }
6
+ t.string :name
7
+ t.string :scope
8
+ t.datetime :expires_at
9
+
10
+ t.datetime :discarded_at
11
+
12
+ t.timestamps
13
+ end
14
+ end
15
+ end