motor-admin 0.3.17 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) 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/slack_conversations_controller.rb +11 -0
  14. data/app/controllers/motor/tags_controller.rb +1 -1
  15. data/app/controllers/motor/ui_controller.rb +9 -1
  16. data/app/controllers/motor/users_for_autocomplete_controller.rb +23 -0
  17. data/app/jobs/motor/alert_sending_job.rb +1 -1
  18. data/app/jobs/motor/notify_note_mentions_job.rb +9 -0
  19. data/app/jobs/motor/notify_reminder_job.rb +9 -0
  20. data/app/mailers/motor/alerts_mailer.rb +6 -29
  21. data/app/mailers/motor/application_mailer.rb +27 -1
  22. data/app/mailers/motor/notifications_mailer.rb +33 -0
  23. data/app/models/motor/note.rb +18 -0
  24. data/app/models/motor/note_tag.rb +7 -0
  25. data/app/models/motor/note_tag_tag.rb +8 -0
  26. data/app/models/motor/notification.rb +14 -0
  27. data/app/models/motor/reminder.rb +13 -0
  28. data/app/views/layouts/motor/application.html.erb +4 -1
  29. data/app/views/layouts/motor/mailer.html.erb +72 -0
  30. data/app/views/motor/alerts_mailer/alert_email.html.erb +52 -124
  31. data/app/views/motor/notifications_mailer/notify_mention_email.html.erb +28 -0
  32. data/app/views/motor/notifications_mailer/notify_reminder_email.html.erb +28 -0
  33. data/config/locales/el.yml +25 -0
  34. data/config/locales/en.yml +33 -2
  35. data/config/locales/es.yml +33 -2
  36. data/config/locales/pt.yml +33 -2
  37. data/config/routes.rb +9 -0
  38. data/lib/generators/motor/install_notes_generator.rb +22 -0
  39. data/lib/generators/motor/templates/install.rb +77 -0
  40. data/lib/generators/motor/templates/install_notes.rb +83 -0
  41. data/lib/generators/motor/upgrade_generator.rb +13 -6
  42. data/lib/motor/admin.rb +13 -1
  43. data/lib/motor/alerts/slack_sender.rb +74 -0
  44. data/lib/motor/alerts.rb +42 -0
  45. data/lib/motor/api_query/apply_scope.rb +1 -0
  46. data/lib/motor/build_schema/apply_permissions.rb +8 -0
  47. data/lib/motor/build_schema/defaults.rb +15 -1
  48. data/lib/motor/build_schema/load_from_rails.rb +4 -1
  49. data/lib/motor/build_schema.rb +7 -0
  50. data/lib/motor/configs/build_ui_app_tag.rb +73 -8
  51. data/lib/motor/configs.rb +3 -4
  52. data/lib/motor/notes/notify_mentions.rb +73 -0
  53. data/lib/motor/notes/notify_reminder.rb +49 -0
  54. data/lib/motor/notes/persist.rb +36 -0
  55. data/lib/motor/notes/reminders_scheduler.rb +39 -0
  56. data/lib/motor/notes/tags.rb +34 -0
  57. data/lib/motor/notes.rb +12 -0
  58. data/lib/motor/queries/run_query.rb +66 -3
  59. data/lib/motor/resources/fetch_configured_model.rb +19 -3
  60. data/lib/motor/resources.rb +1 -1
  61. data/lib/motor/slack/client.rb +62 -0
  62. data/lib/motor/slack.rb +16 -0
  63. data/lib/motor/version.rb +1 -1
  64. data/lib/motor.rb +19 -0
  65. data/ui/dist/{main-726aa7f6805676af4d21.css.gz → main-99bab2664944ee03d10f.css.gz} +0 -0
  66. data/ui/dist/main-99bab2664944ee03d10f.js.gz +0 -0
  67. data/ui/dist/manifest.json +5 -5
  68. metadata +36 -4
  69. 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'