motor-admin 0.3.16 → 0.4.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/app/channels/motor/application_cable/channel.rb +14 -0
  3. data/app/channels/motor/application_cable/connection.rb +27 -0
  4. data/app/channels/motor/notes_channel.rb +9 -0
  5. data/app/channels/motor/notifications_channel.rb +9 -0
  6. data/app/controllers/concerns/motor/current_user_method.rb +2 -0
  7. data/app/controllers/motor/note_tags_controller.rb +13 -0
  8. data/app/controllers/motor/notes_controller.rb +58 -0
  9. data/app/controllers/motor/notifications_controller.rb +33 -0
  10. data/app/controllers/motor/reminders_controller.rb +38 -0
  11. data/app/controllers/motor/run_queries_controller.rb +1 -1
  12. data/app/controllers/motor/send_alerts_controller.rb +2 -2
  13. data/app/controllers/motor/sessions_controller.rb +3 -0
  14. data/app/controllers/motor/slack_conversations_controller.rb +11 -0
  15. data/app/controllers/motor/tags_controller.rb +1 -1
  16. data/app/controllers/motor/ui_controller.rb +9 -1
  17. data/app/controllers/motor/users_for_autocomplete_controller.rb +23 -0
  18. data/app/jobs/motor/alert_sending_job.rb +1 -1
  19. data/app/jobs/motor/notify_note_mentions_job.rb +9 -0
  20. data/app/jobs/motor/notify_reminder_job.rb +9 -0
  21. data/app/mailers/motor/alerts_mailer.rb +6 -29
  22. data/app/mailers/motor/application_mailer.rb +27 -1
  23. data/app/mailers/motor/notifications_mailer.rb +33 -0
  24. data/app/models/motor/note.rb +18 -0
  25. data/app/models/motor/note_tag.rb +7 -0
  26. data/app/models/motor/note_tag_tag.rb +8 -0
  27. data/app/models/motor/notification.rb +14 -0
  28. data/app/models/motor/reminder.rb +13 -0
  29. data/app/views/layouts/motor/application.html.erb +4 -1
  30. data/app/views/layouts/motor/mailer.html.erb +72 -0
  31. data/app/views/motor/alerts_mailer/alert_email.html.erb +52 -124
  32. data/app/views/motor/notifications_mailer/notify_mention_email.html.erb +28 -0
  33. data/app/views/motor/notifications_mailer/notify_reminder_email.html.erb +28 -0
  34. data/config/locales/el.yml +25 -0
  35. data/config/locales/en.yml +33 -2
  36. data/config/locales/es.yml +33 -2
  37. data/config/locales/pt.yml +33 -2
  38. data/config/routes.rb +9 -0
  39. data/lib/generators/motor/install_notes_generator.rb +22 -0
  40. data/lib/generators/motor/templates/install.rb +77 -0
  41. data/lib/generators/motor/templates/install_notes.rb +83 -0
  42. data/lib/generators/motor/upgrade_generator.rb +13 -6
  43. data/lib/motor/admin.rb +13 -1
  44. data/lib/motor/alerts/slack_sender.rb +74 -0
  45. data/lib/motor/alerts.rb +42 -0
  46. data/lib/motor/api_query/apply_scope.rb +1 -0
  47. data/lib/motor/build_schema/apply_permissions.rb +8 -0
  48. data/lib/motor/build_schema/defaults.rb +15 -1
  49. data/lib/motor/build_schema/load_from_rails.rb +4 -1
  50. data/lib/motor/build_schema.rb +7 -0
  51. data/lib/motor/configs/build_ui_app_tag.rb +73 -8
  52. data/lib/motor/configs.rb +3 -4
  53. data/lib/motor/notes/notify_mentions.rb +73 -0
  54. data/lib/motor/notes/notify_reminder.rb +49 -0
  55. data/lib/motor/notes/persist.rb +36 -0
  56. data/lib/motor/notes/reminders_scheduler.rb +39 -0
  57. data/lib/motor/notes/tags.rb +34 -0
  58. data/lib/motor/notes.rb +12 -0
  59. data/lib/motor/queries/run_query.rb +66 -3
  60. data/lib/motor/resources/fetch_configured_model.rb +19 -3
  61. data/lib/motor/resources.rb +1 -1
  62. data/lib/motor/slack/client.rb +62 -0
  63. data/lib/motor/slack.rb +16 -0
  64. data/lib/motor/version.rb +1 -1
  65. data/lib/motor.rb +19 -0
  66. data/ui/dist/{main-726aa7f6805676af4d21.css.gz → main-99bab2664944ee03d10f.css.gz} +0 -0
  67. data/ui/dist/main-99bab2664944ee03d10f.js.gz +0 -0
  68. data/ui/dist/manifest.json +5 -5
  69. metadata +36 -4
  70. data/ui/dist/main-726aa7f6805676af4d21.js.gz +0 -0
@@ -15,22 +15,29 @@ module Motor
15
15
  source_root File.expand_path('templates', __dir__)
16
16
 
17
17
  def copy_migration
18
- has_api_actions = Motor::Resource.all.any? do |resource|
19
- resource.preferences[:actions]&.any? { |action| action[:action_type] == 'api' }
20
- end
21
-
22
18
  unless Motor::ApiConfig.table_exists?
23
19
  migration_template 'install_api_configs.rb', 'db/migrate/install_motor_api_configs.rb'
24
20
  end
25
21
 
26
- migration_template 'upgrade_motor_api_actions.rb', 'db/migrate/upgrade_motor_api_actions.rb' if has_api_actions
22
+ if with_api_actions?
23
+ migration_template 'upgrade_motor_api_actions.rb', 'db/migrate/upgrade_motor_api_actions.rb'
24
+ end
25
+
26
+ migration_template 'install_notes.rb', 'db/migrate/install_motor_notes.rb' unless Motor::Note.table_exists?
27
27
 
28
- if Motor::ApiConfig.table_exists? && !has_api_actions
28
+ if Motor::ApiConfig.table_exists? && !with_api_actions? && Motor::Note.table_exists?
29
29
  puts 'The latest Motor Admin features are already configured'
30
30
  else
31
31
  puts 'Run `rake db:migrate` to update DB schema'
32
32
  end
33
33
  end
34
+
35
+ def with_api_actions?
36
+ @with_api_actions ||=
37
+ Motor::Resource.all.any? do |resource|
38
+ resource.preferences[:actions]&.any? { |action| action[:action_type] == 'api' }
39
+ end
40
+ end
34
41
  end
35
42
  end
36
43
  end
data/lib/motor/admin.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Motor
4
4
  class Admin < ::Rails::Engine
5
+ config.custom_html = ''
6
+
5
7
  initializer 'motor.startup_message' do
6
8
  config.after_initialize do
7
9
  next unless Motor.server?
@@ -48,6 +50,7 @@ module Motor
48
50
  next unless Rails.env.production?
49
51
 
50
52
  Motor::Alerts::Scheduler::SCHEDULER_TASK.execute
53
+ Motor::Notes::RemindersScheduler::SCHEDULER_TASK.execute
51
54
  end
52
55
  end
53
56
 
@@ -78,6 +81,15 @@ module Motor
78
81
  end
79
82
  end
80
83
 
84
+ initializer 'action_cable.connection_class' do
85
+ config.after_initialize do
86
+ next if defined?(::ApplicationCable::Connection)
87
+ next unless defined?(::ActionCable)
88
+
89
+ ActionCable.server.config.connection_class = -> { Motor::ApplicationCable::Connection }
90
+ end
91
+ end
92
+
81
93
  initializer 'motor.active_storage.extensions' do
82
94
  config.after_initialize do
83
95
  next unless defined?(ActiveStorage::Engine)
@@ -107,7 +119,7 @@ module Motor
107
119
  raise
108
120
  end
109
121
 
110
- unless Motor::ApiConfig.table_exists?
122
+ if !Motor::ApiConfig.table_exists? || !Motor::Note.table_exists?
111
123
  puts
112
124
  puts ' => Run `rails g motor:upgrade && rake db:migrate`' \
113
125
  ' to perform data migration and enable the latest features'
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Alerts
5
+ module SlackSender
6
+ MARKDOWN_LINK_TRANSFORM_REGEXP = /\[(.+?)\]\((.+?)\)/.freeze
7
+ MARKDOWN_PATHS_TRANSFORM_REGEXP = /\n\s*\#{1,5}\s*(.*)\n?/.freeze
8
+ MARKDOWN_HEADING_TRANSFORM_REGEXP = /\n\s*\#{1,5}\s*(.*)\n?/.freeze
9
+ MARKDOWN_BOLD_TRANSFORM_REGEXP = /[*_]{2}/.freeze
10
+
11
+ DEFAULT_FILENAME = 'data.csv'
12
+
13
+ module_function
14
+
15
+ def call(alert, conversation_id, slack_user: nil)
16
+ query_result = load_data(alert, slack_user)
17
+
18
+ if alert.query.preferences[:visualization] == 'markdown'
19
+ text = [build_query_link(alert), render_markdown(alert, query_result)].join
20
+
21
+ Slack::Client.send_message(channel: conversation_id,
22
+ as_user: slack_user.present?,
23
+ text: text)
24
+ else
25
+ Slack::Client.send_message(channel: conversation_id,
26
+ as_user: slack_user.present?,
27
+ text: build_alert_message(alert))
28
+ Slack::Client.send_file(channels: conversation_id,
29
+ content: generate_csv(query_result),
30
+ filename: DEFAULT_FILENAME)
31
+ end
32
+ end
33
+
34
+ def build_alert_message(alert)
35
+ <<~MARKDOWN
36
+ #{build_query_link(alert)}
37
+ #{alert.description}
38
+ MARKDOWN
39
+ end
40
+
41
+ def build_query_link(alert)
42
+ link = Motor::Admin.routes.url_helpers.motor_ui_query_url(alert.query_id, host: Motor.app_host)
43
+
44
+ "*<#{link}|#{alert.name}>*"
45
+ end
46
+
47
+ def render_markdown(alert, query_result)
48
+ params = query_result.columns.pluck(:name).zip(query_result.data[0]).to_h.symbolize_keys
49
+
50
+ markdown =
51
+ ["\n", Mustache.render(alert.query.preferences[:visualization_options][:markdown], **params)].join
52
+
53
+ markdown.gsub(/\((.*)\)/, "(http://#{Motor.app_host}\\1)")
54
+ .gsub(MARKDOWN_LINK_TRANSFORM_REGEXP, '<\2|\1>')
55
+ .gsub(MARKDOWN_HEADING_TRANSFORM_REGEXP, "\n\n*\\1*")
56
+ .gsub(MARKDOWN_BOLD_TRANSFORM_REGEXP, '*')
57
+ end
58
+
59
+ def generate_csv(query_result)
60
+ rows = [query_result.columns.pluck(:name)] + query_result.data
61
+
62
+ rows.map(&:to_csv).join
63
+ end
64
+
65
+ def load_data(alert, slack_user = nil)
66
+ if slack_user
67
+ Queries::RunQuery.call(alert.query, variables_hash: { current_user_email: slack_user['email'] })
68
+ else
69
+ Queries::RunQuery.call(alert.query)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
data/lib/motor/alerts.rb CHANGED
@@ -2,9 +2,51 @@
2
2
 
3
3
  module Motor
4
4
  module Alerts
5
+ module_function
6
+
7
+ def send_alert(alert)
8
+ if alert.preferences[:adapter] == 'slack'
9
+ send_slack_alert(alert)
10
+ else
11
+ send_email_alert(alert)
12
+ end
13
+ end
14
+
15
+ def send_email_alert(alert)
16
+ alert.to_emails.split(',').each do |email|
17
+ if email.include?('@')
18
+ Motor::AlertsMailer.alert_email(alert, email: email).deliver_now!
19
+ else
20
+ send_query_email_alerts(alert, email.gsub(/[{}]/, ''))
21
+ end
22
+ end
23
+ end
24
+
25
+ def send_query_email_alerts(alert, column_name)
26
+ result = Queries::RunQuery.call(alert.query)
27
+
28
+ column_index = result.columns.find_index { |c| c[:name] == column_name }
29
+
30
+ emails = result.data.map { |row| row[column_index] }.uniq
31
+
32
+ emails.each do |email|
33
+ Motor::AlertsMailer.alert_email(alert, email: email).deliver_now!
34
+ end
35
+ end
36
+
37
+ def send_slack_alert(alert)
38
+ slack_users = Slack::Client.load_users(limit: Slack::ITEMS_LIMIT)['members']
39
+
40
+ alert.preferences[:slack_conversation_ids].each do |conversation_id|
41
+ user = slack_users.find { |u| u['id'] == conversation_id }
42
+
43
+ Motor::Alerts::SlackSender.call(alert, conversation_id, slack_user: user)
44
+ end
45
+ end
5
46
  end
6
47
  end
7
48
 
8
49
  require_relative './alerts/scheduler'
9
50
  require_relative './alerts/scheduled_alerts_cache'
10
51
  require_relative './alerts/persistance'
52
+ require_relative './alerts/slack_sender'
@@ -25,6 +25,7 @@ module Motor
25
25
  scope_configs = configs.preferences[:scopes].find { |s| s[:name] == scope }
26
26
 
27
27
  return rel unless scope_configs
28
+ return rel unless scope_configs[:preferences]
28
29
 
29
30
  rel = ApiQuery::Filter.call(rel, scope_configs[:preferences][:filter])
30
31
 
@@ -14,6 +14,7 @@ module Motor
14
14
  model[:associations] = filter_associations(model[:associations], ability)
15
15
  model[:columns] = filter_columns(klass, model[:columns], ability)
16
16
  model[:actions] = filter_actions(klass, model[:actions], ability)
17
+ model[:tabs] = filter_tabs(klass, model[:tabs], ability)
17
18
 
18
19
  model
19
20
  end.compact
@@ -27,6 +28,13 @@ module Motor
27
28
  end
28
29
  end
29
30
 
31
+ def filter_tabs(_model, tabs, ability)
32
+ tabs = tabs.reject { |t| t[:name] == 'audits' } unless ability.can?(:read, Motor::Audit)
33
+ tabs = tabs.reject { |t| t[:name] == 'notes' } unless ability.can?(:read, Motor::Note)
34
+
35
+ tabs
36
+ end
37
+
30
38
  def filter_columns(model, columns, ability)
31
39
  columns.map do |column|
32
40
  next unless ability.can?(:read, model, column[:name])
@@ -34,7 +34,6 @@ module Motor
34
34
  }
35
35
  ].freeze
36
36
  end
37
- # rubocop:enable Metrics/MethodLength
38
37
 
39
38
  def tabs
40
39
  [
@@ -44,9 +43,24 @@ module Motor
44
43
  tab_type: BuildSchema::DEFAULT_TYPE,
45
44
  preferences: {},
46
45
  visible: true
46
+ },
47
+ {
48
+ name: 'audits',
49
+ display_name: I18n.t('motor.audits'),
50
+ tab_type: BuildSchema::DEFAULT_TYPE,
51
+ preferences: {},
52
+ visible: true
53
+ },
54
+ {
55
+ name: 'notes',
56
+ display_name: I18n.t('motor.notes'),
57
+ tab_type: BuildSchema::DEFAULT_TYPE,
58
+ preferences: {},
59
+ visible: true
47
60
  }
48
61
  ].freeze
49
62
  end
63
+ # rubocop:enable Metrics/MethodLength
50
64
  end
51
65
  end
52
66
  end
@@ -79,7 +79,10 @@ module Motor
79
79
  searchable_columns: FindSearchableColumns.call(model),
80
80
  custom_sql: nil,
81
81
  visible: true,
82
- display_primary_key: true
82
+ display_primary_key: true,
83
+ preferences: {
84
+ display_as: 'table'
85
+ }
83
86
  }.with_indifferent_access
84
87
  end
85
88
  # rubocop:enable Metrics/MethodLength
@@ -102,6 +102,13 @@ module Motor
102
102
 
103
103
  ReorderSchema.call(schema, cache_keys)
104
104
  end
105
+
106
+ def for_model(model)
107
+ schema = Motor::BuildSchema::LoadFromRails.build_model_schema(model)
108
+ configs = Motor::Resource.find_by(name: schema[:name]).preferences
109
+
110
+ MergeSchemaConfigs.merge_model(schema, configs)
111
+ end
105
112
  end
106
113
  end
107
114
 
@@ -38,25 +38,25 @@ module Motor
38
38
  def build_data(cache_keys = {}, current_user = nil, current_ability = nil)
39
39
  configs_cache_key = cache_keys[:configs]
40
40
 
41
- {
42
- version: Motor::VERSION,
43
- current_user: current_user&.as_json(only: %i[id email]),
41
+ { version: Motor::VERSION,
42
+ current_user: serialize_current_user(current_user),
44
43
  current_rules: current_ability.serialized_rules,
45
44
  audits_count: Motor::Audit.count,
46
45
  i18n: i18n_data,
47
46
  base_path: Motor::Admin.routes.url_helpers.motor_path,
47
+ cabel_path: Motor::Admin.routes.url_helpers.try(:motor_cabel_path),
48
48
  admin_settings_path: Rails.application.routes.url_helpers.try(:admin_settings_general_path),
49
49
  schema: Motor::BuildSchema.call(cache_keys, current_ability),
50
- header_links: header_links_data_hash(configs_cache_key),
50
+ header_links: header_links_data_hash(current_user, current_ability, configs_cache_key),
51
51
  homepage_layout: homepage_layout_data_hash(configs_cache_key),
52
+ databases: database_names,
52
53
  queries: queries_data_hash(build_cache_key(cache_keys, :queries, current_user, current_ability),
53
54
  current_ability),
54
55
  dashboards: dashboards_data_hash(build_cache_key(cache_keys, :dashboards, current_user, current_ability),
55
56
  current_ability),
56
57
  alerts: alerts_data_hash(build_cache_key(cache_keys, :alerts, current_user, current_ability),
57
58
  current_ability),
58
- forms: forms_data_hash(build_cache_key(cache_keys, :forms, current_user, current_ability), current_ability)
59
- }
59
+ forms: forms_data_hash(build_cache_key(cache_keys, :forms, current_user, current_ability), current_ability) }
60
60
  end
61
61
  # rubocop:enable Metrics/AbcSize
62
62
 
@@ -64,15 +64,68 @@ module Motor
64
64
  I18n.t('motor', default: I18n.t('motor', locale: :en))
65
65
  end
66
66
 
67
+ def serialize_current_user(user)
68
+ return unless user
69
+
70
+ attrs = user.as_json(only: %i[id email first_name last_name])
71
+
72
+ attrs['role'] = user.role if user.respond_to?(:role)
73
+ attrs['role_names'] = user.role_names if user.respond_to?(:role_names)
74
+
75
+ attrs
76
+ end
77
+
67
78
  # @return [String]
68
79
  def build_cache_key(cache_keys, key, current_user, current_ability)
69
80
  "#{cache_keys[key].hash}#{current_user&.id}#{current_ability&.rules_hash}"
70
81
  end
71
82
 
72
- def header_links_data_hash(cache_key = nil)
83
+ def header_links_data_hash(current_user, current_ability, cache_key = nil)
73
84
  configs = Motor::Configs::LoadFromCache.load_configs(cache_key: cache_key)
74
85
 
75
- configs.find { |c| c.key == 'header.links' }&.value || []
86
+ links = configs.find { |c| c.key == 'header.links' }&.value || []
87
+ links = add_default_links(links)
88
+
89
+ return links unless current_user
90
+ return links if current_ability.can?(:manage, :all)
91
+
92
+ filter_links_for_user(current_user, links)
93
+ end
94
+
95
+ def add_default_links(links)
96
+ new_links = links.clone
97
+
98
+ unless links.find { |l| l['link_type'] == 'forms' }
99
+ new_links.unshift({ 'name' => I18n.t('motor.forms'), 'link_type' => 'forms' })
100
+ end
101
+
102
+ unless links.find { |l| l['link_type'] == 'reports' }
103
+ new_links.unshift({ 'name' => I18n.t('motor.reports'), 'link_type' => 'reports' })
104
+ end
105
+
106
+ new_links
107
+ end
108
+
109
+ def filter_links_for_user(current_user, links)
110
+ links.select do |link|
111
+ conditions = link['conditions']
112
+
113
+ next true if conditions.blank?
114
+
115
+ conditions.all? do |cond|
116
+ field_name =
117
+ if cond['field'] == 'role' && !current_user.respond_to?(:role)
118
+ :role_names
119
+ else
120
+ cond['field'].to_sym
121
+ end
122
+
123
+ next false unless field_name.in?(%i[email role role_names])
124
+ next false unless current_user.respond_to?(field_name)
125
+
126
+ Array.wrap(current_user.public_send(field_name)).intersection(Array.wrap(cond['value'])).present?
127
+ end
128
+ end
76
129
  end
77
130
 
78
131
  def homepage_layout_data_hash(cache_key = nil)
@@ -81,6 +134,18 @@ module Motor
81
134
  configs.find { |c| c.key == 'homepage.layout' }&.value || []
82
135
  end
83
136
 
137
+ def database_names
138
+ if defined?(Motor::EncryptedConfig)
139
+ Motor::DatabaseClasses.constants.map { |e| e.to_s.titleize }
140
+ elsif ActiveRecord::Base.configurations.try(:configurations)
141
+ ActiveRecord::Base.configurations.configurations
142
+ .select { |c| c.env_name == Rails.env }
143
+ .map { |c| c.try(:name) || c.try(:spec_name) }.compact
144
+ else
145
+ ['primary']
146
+ end
147
+ end
148
+
84
149
  def queries_data_hash(cache_key = nil, current_ability = nil)
85
150
  Motor::Configs::LoadFromCache.load_queries(cache_key: cache_key, current_ability: current_ability)
86
151
  .as_json(only: %i[id name updated_at],
data/lib/motor/configs.rb CHANGED
@@ -5,8 +5,7 @@ module Motor
5
5
  FILE_PATH = 'config/motor.yml'
6
6
  SYNC_API_PATH = '/motor_configs_sync'
7
7
  SYNC_ACCESS_KEY = ENV.fetch('MOTOR_SYNC_API_KEY', '')
8
- MEMFS_PATH = '/__enclose_io_memfs__/'
9
- PWD_FILE_NAME = 'motor-admin.yml'
8
+ WORKDIR_FILE_NAME = 'motor-admin.yml'
10
9
 
11
10
  module_function
12
11
 
@@ -22,8 +21,8 @@ module Motor
22
21
 
23
22
  # @return [String]
24
23
  def file_path
25
- if Rails.root.to_s.start_with?(MEMFS_PATH)
26
- [ENV['PWD'], PWD_FILE_NAME].join('/')
24
+ if defined?(MotorAdmin::Application)
25
+ [ENV['WORKDIR'], WORKDIR_FILE_NAME].compact.join('/')
27
26
  else
28
27
  Rails.root.join(FILE_PATH).to_s
29
28
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Notes
5
+ module NotifyMentions
6
+ EMAIL_REGEXP =
7
+ /[a-z0-9][.']?(?:(?:[a-z0-9_-]++[.'])*[a-z0-9_-]++)*@(?:[a-z0-9]++[.-])*[a-z0-9]++\.[a-z]{2,}/i.freeze
8
+ NOTIFICATION_DESCRIPTION_LIMIT = 100
9
+
10
+ module_function
11
+
12
+ def call(note, current_user)
13
+ users_class = fetch_users_class(current_user)
14
+
15
+ return unless users_class
16
+
17
+ emails = note.body.scan(EMAIL_REGEXP)
18
+ emails -= [current_user&.email]
19
+
20
+ users_class.where(email: emails).each do |user|
21
+ notification = find_or_build_notification(note, user, current_user)
22
+
23
+ next if notification.persisted?
24
+
25
+ notification.save!
26
+
27
+ Motor::NotificationsMailer.notify_mention_email(notification).deliver_later!
28
+ Motor::NotificationsChannel.broadcast_to(user, ['notify', notification.as_json(include: %i[record])])
29
+ end
30
+ end
31
+
32
+ def find_or_build_notification(note, user, current_user)
33
+ notification = note.notifications.find { |e| e.recipient_id == user.id && e.recipient_type == user.class.name }
34
+
35
+ return notification if notification
36
+
37
+ Motor::Notification.new(
38
+ title: build_mention_title(note),
39
+ description: build_mention_description(note, current_user),
40
+ recipient: user,
41
+ record: note
42
+ )
43
+ end
44
+
45
+ def build_mention_title(note)
46
+ configs = Motor::BuildSchema.for_model(note.record.class)
47
+
48
+ display_value = note.record.attributes[configs['display_column']]
49
+
50
+ I18n.t('new_mention_for',
51
+ resource: ["#{configs['display_name'].singularize} ##{note.record[note.record.class.primary_key]}",
52
+ display_value].join(' '),
53
+ scope: :motor)
54
+ end
55
+
56
+ def build_mention_description(note, current_user)
57
+ I18n.t('user_mentioned_you_with_note',
58
+ user: current_user&.email || 'Anonymous',
59
+ note: note.body.truncate(NOTIFICATION_DESCRIPTION_LIMIT),
60
+ scope: :motor)
61
+ end
62
+
63
+ def fetch_users_class(current_user)
64
+ return current_user.class if current_user
65
+ return Motor::AdminUser if defined?(Motor::AdminUser)
66
+ return AdminUser if defined?(AdminUser)
67
+ return User if defined?(User)
68
+
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Notes
5
+ module NotifyReminder
6
+ NOTIFICATION_DESCRIPTION_LIMIT = 100
7
+
8
+ module_function
9
+
10
+ def call(reminder)
11
+ notification = find_or_build_notification(reminder)
12
+
13
+ return if notification.persisted?
14
+
15
+ notification.save!
16
+
17
+ Motor::NotificationsMailer.notify_reminder_email(notification).deliver_later!
18
+ Motor::NotificationsChannel.broadcast_to(reminder.recipient,
19
+ ['notify', notification.as_json(include: %i[record])])
20
+ end
21
+
22
+ def find_or_build_notification(reminder)
23
+ notification = reminder.notifications.take
24
+
25
+ return notification if notification
26
+
27
+ note = reminder.record
28
+
29
+ Motor::Notification.new(
30
+ title: build_notification_title(note),
31
+ description: note.body.truncate(NOTIFICATION_DESCRIPTION_LIMIT),
32
+ recipient: reminder.recipient,
33
+ record: reminder
34
+ )
35
+ end
36
+
37
+ def build_notification_title(note)
38
+ configs = Motor::BuildSchema.for_model(note.record.class)
39
+
40
+ display_value = note.record.attributes[configs['display_column']]
41
+
42
+ I18n.t('new_reminder_for',
43
+ resource: ["#{configs['display_name'].singularize} ##{note.record[note.record.class.primary_key]}",
44
+ display_value].join(' '),
45
+ scope: :motor)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Notes
5
+ module Persist
6
+ TAG_REGEXP = /#\w+?\b/.freeze
7
+
8
+ module_function
9
+
10
+ def call(note, current_user)
11
+ note.author ||= current_user
12
+
13
+ tags = parse_tag_names(note)
14
+
15
+ Motor::Notes::Tags.assign_tags(note, tags)
16
+
17
+ note.save!
18
+
19
+ broadcast_note(note)
20
+
21
+ note
22
+ end
23
+
24
+ def broadcast_note(note)
25
+ Motor::NotesChannel.broadcast_to(
26
+ note.values_at(:record_type, :record_id).join(':'),
27
+ ['update', note.as_json(include: %i[author tags reminders])]
28
+ )
29
+ end
30
+
31
+ def parse_tag_names(note)
32
+ note.body.scan(TAG_REGEXP).map { |tag| tag[1..] }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Notes
5
+ module RemindersScheduler
6
+ SCHEDULER_INTERVAL = 1.minute
7
+ CHECK_BEHIND_DURATION = 6.hours
8
+
9
+ SCHEDULER_TASK = Concurrent::TimerTask.new(
10
+ execution_interval: SCHEDULER_INTERVAL
11
+ ) { Motor::Notes::RemindersScheduler.call }
12
+
13
+ REMINDER_NOTIFICATIONS_JOIN_SQL = <<~SQL.squish
14
+ LEFT JOIN motor_notifications
15
+ ON motor_notifications.record_id = motor_reminders.id
16
+ AND motor_notifications.record_type = 'Motor::Reminder'
17
+ SQL
18
+
19
+ module_function
20
+
21
+ def call
22
+ ActiveRecord::Base.logger.silence do
23
+ load_reminders.each do |reminder|
24
+ ::Motor::NotifyReminderJob.perform_later(reminder)
25
+ rescue StandardError => e
26
+ Rails.logger.error(e)
27
+ end
28
+ end
29
+ end
30
+
31
+ def load_reminders
32
+ ::Motor::Reminder
33
+ .joins(REMINDER_NOTIFICATIONS_JOIN_SQL)
34
+ .where(scheduled_at: CHECK_BEHIND_DURATION.ago..Time.current.end_of_minute)
35
+ .where(motor_notifications: { id: nil })
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Notes
5
+ module Tags
6
+ module_function
7
+
8
+ def assign_tags(note, tags)
9
+ return note if tags.blank?
10
+
11
+ tags.each do |tag_name|
12
+ next if note.note_tag_tags.find { |tt| tt.tag.name.casecmp(tag_name).zero? }
13
+
14
+ tag = NoteTag.find_or_initialize_by(name: tag_name)
15
+
16
+ note.note_tag_tags.new(tag: tag)
17
+ end
18
+
19
+ remove_missing_tags(note, tags) if note.persisted?
20
+
21
+ note
22
+ end
23
+
24
+ def remove_missing_tags(note, tags)
25
+ downcase_tags = tags.map(&:downcase)
26
+ tags_to_remove = note.tags.reject { |tt| tt.name.downcase.in?(downcase_tags) }
27
+
28
+ note.tags -= tags_to_remove
29
+
30
+ note
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Motor
4
+ module Notes
5
+ end
6
+ end
7
+
8
+ require_relative './notes/tags'
9
+ require_relative './notes/persist'
10
+ require_relative './notes/notify_mentions'
11
+ require_relative './notes/notify_reminder'
12
+ require_relative './notes/reminders_scheduler'