lesli 5.1.12 → 5.1.13

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +3 -0
  3. data/app/assets/stylesheets/lesli/application.css +43 -3
  4. data/app/controllers/lesli/application_devise_controller.rb +9 -5
  5. data/app/controllers/lesli/application_lesli_controller.rb +4 -0
  6. data/app/controllers/lesli/items/attachments_controller.rb +200 -0
  7. data/app/controllers/lesli/items/discussions_controller.rb +94 -67
  8. data/app/controllers/lesli/items/tasks_controller.rb +132 -62
  9. data/app/helpers/lesli/turbo_helper.rb +0 -18
  10. data/app/interfaces/lesli/responder_interface.rb +58 -38
  11. data/app/mailers/lesli/application_lesli_mailer.rb +34 -8
  12. data/app/models/concerns/lesli/account_initializer.rb +11 -3
  13. data/app/models/concerns/lesli/account_logs.rb +5 -16
  14. data/app/models/concerns/lesli/items/activities.rb +29 -33
  15. data/app/models/concerns/lesli/items/attachments.rb +88 -0
  16. data/app/models/concerns/lesli/items/discussions.rb +25 -11
  17. data/app/models/concerns/lesli/items/ownership.rb +27 -0
  18. data/app/models/concerns/lesli/items/tasks.rb +26 -6
  19. data/app/models/concerns/lesli/user_extensions.rb +5 -9
  20. data/app/models/lesli/account.rb +10 -14
  21. data/app/models/lesli/items/activity.rb +25 -2
  22. data/app/models/lesli/items/attachment.rb +117 -0
  23. data/app/models/lesli/items/discussion.rb +6 -2
  24. data/app/models/lesli/items/task.rb +3 -1
  25. data/app/models/lesli/user.rb +12 -21
  26. data/app/views/lesli/abouts/welcome.html.erb +2 -5
  27. data/app/views/lesli/errors/not_found.html.erb +7 -21
  28. data/app/views/lesli/errors/unauthorized.html.erb +11 -27
  29. data/app/views/lesli/layouts/application-devise.html.erb +10 -9
  30. data/app/views/lesli/layouts/application-lesli.html.erb +1 -1
  31. data/app/views/lesli/partials/_application-lesli-assets.html.erb +3 -3
  32. data/app/views/lesli/partials/_application-lesli-header.html.erb +5 -9
  33. data/config/initializers/lesli.rb +4 -1
  34. data/config/locales/translations.en.yml +34 -0
  35. data/config/locales/translations.es.yml +34 -0
  36. data/db/seed/accounts.rb +5 -5
  37. data/db/seed/users.rb +8 -16
  38. data/db/seed/xyz.rb +38 -17
  39. data/lib/lesli/engine.rb +0 -4
  40. data/lib/lesli/router.rb +5 -2
  41. data/lib/lesli/version.rb +2 -2
  42. data/lib/migrate/items/activity_structure.rb +8 -8
  43. data/lib/migrate/items/attachment_structure.rb +9 -14
  44. data/lib/migrate/items/discussion_structure.rb +5 -5
  45. data/lib/migrate/items/task_structure.rb +2 -3
  46. data/lib/migrate/shared/account_structure.rb +1 -1
  47. data/lib/tasks/lesli_tasks.rake +2 -3
  48. data/readme.md +3 -3
  49. data/test/fixtures/lesli_accounts.yml +18 -0
  50. data/test/fixtures/lesli_users.yml +23 -0
  51. metadata +17 -25
  52. data/app/assets/stylesheets/lesli/application.tailwind.css +0 -33
  53. data/config/brakeman.yml +0 -44
  54. data/config/initializers/devise.rb +0 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1ca8550e901343c90ed0df6cdc090a8b29f87a54ee7134abebd136a849cffdf
4
- data.tar.gz: c10a1829fdb3e28275ac11cecbd7fdbebfbb1928822fc750670014193f421fdd
3
+ metadata.gz: e42093c64425af9e68a26e106d438eee2e69c11ead091011ad26eff30b940282
4
+ data.tar.gz: 5e56a9246828d3efe4e36354d7fe754a4556af9a0befd4ea9a6169614b94b521
5
5
  SHA512:
6
- metadata.gz: d3e7367ac43dfe1cc2261b19e3692d445a36dcb667ad4abe57ac78a9cb98b3f6bcbb66920bf45378d54d4c344be4aa1c4a262b68ae9480d16671d2ff52f85de7
7
- data.tar.gz: a07ba625e2e585085564c740629ce0635901a8604934836f3d670768854303c90e2aa731a11577ab7d3bdc0c5bebc3f90d3c665e3eb0a4da76dbeb3b214f6bb8
6
+ metadata.gz: a7f90e00184b49ffac1f366897ae89ddbd0dbe6e488aa4952d018f432a6e73f2399f9c8fd3f9b670dbb6d1bff2b6a1660f73a79eb146f5fb08ceed7e5efb2a5b
7
+ data.tar.gz: 69a092763f5a8e0c55c03bd7d68aa68b511cc8a5f6bd60aedfdef9fbc601501319f12555f3c95d908e354292d3285d3289c91c01e803d72ed09669775b2f7446
data/Rakefile CHANGED
@@ -5,3 +5,6 @@ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
5
5
  load "rails/tasks/engine.rake"
6
6
 
7
7
  require "bundler/gem_tasks"
8
+
9
+ desc "Run engine tests through the dummy Rails application"
10
+ task test: "app:test"
@@ -1,8 +1,9 @@
1
- @charset "UTF-8";
2
- /*
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ /*!
3
+
3
4
  Lesli
4
5
 
5
- Copyright (c) 2025, Lesli Technologies, S. A.
6
+ Copyright (c) 2026, Lesli Technologies, S. A.
6
7
 
7
8
  This program is free software: you can redistribute it and/or modify
8
9
  it under the terms of the GNU General Public License as published by
@@ -29,3 +30,42 @@ Building a better future, one line of code at a time.
29
30
  // · ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~
30
31
  // ·
31
32
  */
33
+ @layer theme, utilities;
34
+ @layer theme {
35
+ :root, :host {
36
+ --spacing: 0.25rem;
37
+ }
38
+ }
39
+ @layer utilities {
40
+ .grid {
41
+ display: grid;
42
+ }
43
+ .hidden {
44
+ display: none;
45
+ }
46
+ .grid-cols-1 {
47
+ grid-template-columns: repeat(1, minmax(0, 1fr));
48
+ }
49
+ .gap-5 {
50
+ gap: calc(var(--spacing) * 5);
51
+ }
52
+ .py-6 {
53
+ padding-block: calc(var(--spacing) * 6);
54
+ }
55
+ @media (width >= 40rem) {
56
+ .sm\:grid-cols-2 {
57
+ grid-template-columns: repeat(2, minmax(0, 1fr));
58
+ }
59
+ .sm\:gap-6 {
60
+ gap: calc(var(--spacing) * 6);
61
+ }
62
+ .sm\:py-8 {
63
+ padding-block: calc(var(--spacing) * 8);
64
+ }
65
+ }
66
+ @media (width >= 64rem) {
67
+ .lg\:grid-cols-4 {
68
+ grid-template-columns: repeat(4, minmax(0, 1fr));
69
+ }
70
+ }
71
+ }
@@ -1,8 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  =begin
2
4
 
3
5
  Lesli
4
6
 
5
- Copyright (c) 2025, Lesli Technologies, S. A.
7
+ Lesli
8
+
9
+ Copyright (c) 2026, Lesli Technologies, S. A.
6
10
 
7
11
  This program is free software: you can redistribute it and/or modify
8
12
  it under the terms of the GNU General Public License as published by
@@ -31,17 +35,17 @@ Building a better future, one line of code at a time.
31
35
  =end
32
36
 
33
37
  module Lesli
38
+ # Shared parent for authentication controllers rendered with the public
39
+ # Lesli application shell. LesliShield configures Devise to use this class.
34
40
  class ApplicationDeviseController < ApplicationController
35
41
  include Lesli::ResponderInterface
36
42
  include Lesli::RequesterInterface
37
43
  include LesliAudit::LoggerInterface if defined?(LesliAudit)
38
-
39
- layout "lesli/layouts/application-devise"
40
44
 
41
- #before_action :set_locale_public
45
+ layout "lesli/layouts/application-devise"
42
46
 
43
47
  def initialize
44
- @lesli = {
48
+ @lesli = {
45
49
  company: {
46
50
  name: Lesli.config.company[:name]
47
51
  }
@@ -40,6 +40,10 @@ module Lesli
40
40
  include LesliShield::AuthenticationInterface if defined?(LesliShield)
41
41
  include LesliShield::AuthorizationInterface if defined?(LesliShield)
42
42
 
43
+ # Controllers from isolated engines inherit this controller, but Rails
44
+ # does not automatically expose Lesli's helpers to their views.
45
+ helper Lesli::Engine.helpers
46
+
43
47
  protect_from_forgery with: :exception
44
48
 
45
49
  before_action :set_locale
@@ -0,0 +1,200 @@
1
+ =begin
2
+
3
+ Lesli
4
+
5
+ Copyright (c) 2026, Lesli Technologies, S. A.
6
+
7
+ This program is free software: you can redistribute it and/or modify
8
+ it under the terms of the GNU General Public License as published by
9
+ the Free Software Foundation, either version 3 of the License, or
10
+ (at your option) any later version.
11
+
12
+ This program is distributed in the hope that it will be useful,
13
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ GNU General Public License for more details.
16
+
17
+ You should have received a copy of the GNU General Public License
18
+ along with this program. If not, see http://www.gnu.org/licenses/.
19
+
20
+ Lesli · Ruby on Rails SaaS Development Framework.
21
+
22
+ Made with ♥ by LesliTech
23
+ Building a better future, one line of code at a time.
24
+
25
+ @contact hello@lesli.tech
26
+ @website https://www.lesli.tech
27
+ @license GPLv3 http://www.gnu.org/licenses/gpl-3.0.en.html
28
+
29
+ // · ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~
30
+ // ·
31
+ =end
32
+
33
+ module Lesli
34
+ module Items
35
+ class AttachmentsController < ApplicationLesliController
36
+ include ActiveStorage::SetCurrent
37
+
38
+ before_action :set_attachable, only: %i[index create]
39
+ before_action :set_attachment, only: %i[show destroy]
40
+
41
+ def index
42
+ attachments = @attachable.attachments.with_attached_file
43
+ respond_with_lesli(json: attachments.map { |attachment| attachment_payload(attachment) })
44
+ end
45
+
46
+ def show
47
+ return respond_with_not_found unless @attachment.file.attached?
48
+
49
+ redirect_to(
50
+ @attachment.file.url(
51
+ expires_in: 5.minutes,
52
+ disposition: :attachment,
53
+ filename: @attachment.file.filename
54
+ ),
55
+ allow_other_host: true
56
+ )
57
+ end
58
+
59
+ def create
60
+ attachment = @attachable.attachments.build(attachment_params)
61
+ attachment.user = current_user
62
+ attachment.account = @attachable.lesli_item_account
63
+
64
+ if attachment.save
65
+ refresh_attachments
66
+ respond_with_lesli(
67
+ turbo: [
68
+ stream_notification_success(
69
+ I18n.t("lesli.items.attachments.created")
70
+ ),
71
+ attachments_stream
72
+ ],
73
+ json: attachment_payload(attachment),
74
+ redirect: return_path
75
+ )
76
+ else
77
+ respond_with_lesli(
78
+ turbo: [
79
+ stream_notification_danger(
80
+ attachment.errors.full_messages.to_sentence
81
+ ),
82
+ attachments_stream(attachment: attachment)
83
+ ],
84
+ json: { errors: attachment.errors },
85
+ redirect: return_path,
86
+ status: :unprocessable_content
87
+ )
88
+ end
89
+ end
90
+
91
+ def destroy
92
+ attachable = @attachment.attachable
93
+
94
+ if @attachment.destroy
95
+ @attachable = attachable
96
+ refresh_attachments
97
+ respond_with_lesli(
98
+ turbo: [
99
+ stream_notification_success(
100
+ I18n.t("lesli.items.attachments.deleted")
101
+ ),
102
+ attachments_stream
103
+ ],
104
+ json: { message: I18n.t("lesli.items.attachments.deleted") },
105
+ redirect: return_path
106
+ )
107
+ else
108
+ respond_with_lesli(
109
+ turbo: stream_notification_danger(
110
+ @attachment.errors.full_messages.to_sentence
111
+ ),
112
+ json: { errors: @attachment.errors },
113
+ redirect: return_path,
114
+ status: :unprocessable_content
115
+ )
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def attachment_class
122
+ self.class.module_parent.const_get("Attachment")
123
+ end
124
+
125
+ def attachable_class
126
+ allowed_attachable_classes.find do |klass|
127
+ klass.name == params.require(:attachable_type)
128
+ end || raise(
129
+ ActiveRecord::RecordNotFound,
130
+ "Unauthorized attachable type"
131
+ )
132
+ end
133
+
134
+ def allowed_attachable_classes
135
+ []
136
+ end
137
+
138
+ def set_attachable
139
+ @attachable = attachable_class.find(params.require(:attachable_id))
140
+ ensure_attachable_access!(@attachable)
141
+ end
142
+
143
+ def set_attachment
144
+ @attachment = attachment_class
145
+ .with_attached_file
146
+ .find(params[:id])
147
+ ensure_attachable_access!(@attachment.attachable)
148
+ end
149
+
150
+ def ensure_attachable_access!(attachable)
151
+ allowed = attachable.respond_to?(:lesli_item_accessible_by?) &&
152
+ attachable.lesli_item_accessible_by?(current_user)
153
+
154
+ raise ActiveRecord::RecordNotFound unless allowed
155
+ end
156
+
157
+ def attachment_params
158
+ params.require(scope_key).permit(:file, :label, :category)
159
+ end
160
+
161
+ def scope_key
162
+ ActiveSupport::Inflector.underscore(attachment_class.name).tr("/", "_")
163
+ end
164
+
165
+ def attachment_payload(attachment)
166
+ {
167
+ id: attachment.id,
168
+ label: attachment.label,
169
+ category: attachment.category,
170
+ filename: attachment.file.filename.to_s,
171
+ content_type: attachment.file.blob.content_type,
172
+ byte_size: attachment.file.blob.byte_size,
173
+ created_at: attachment.created_at,
174
+ user: {
175
+ id: attachment.user_id,
176
+ name: attachment.user.name
177
+ }
178
+ }
179
+ end
180
+
181
+ def attachments_stream(attachment: nil)
182
+ turbo_stream.replace(
183
+ helpers.dom_id(@attachable, :attachments)
184
+ ) do
185
+ LesliView::Items::Attachments
186
+ .new(@attachable, attachment: attachment)
187
+ .render_in(view_context)
188
+ end
189
+ end
190
+
191
+ def refresh_attachments
192
+ @attachable.attachments.reset
193
+ end
194
+
195
+ def return_path
196
+ request.referer.presence || root_path
197
+ end
198
+ end
199
+ end
200
+ end
@@ -33,93 +33,120 @@ Building a better future, one line of code at a time.
33
33
  module Lesli
34
34
  module Items
35
35
  class DiscussionsController < ApplicationLesliController
36
- before_action :set_discussion, only: %i[show update]
37
- before_action :set_discussable, only: %i[index create]
38
-
39
- def index
40
- @discussions = discussion_class
41
- .where(account: current_user.account)
42
- .order(created_at: :desc)
43
- respond_with_lesli(json: @discussions)
36
+ before_action :set_discussable, only: :create
37
+
38
+ def create
39
+ comment = @discussable.add_comment!(
40
+ author: current_user,
41
+ message: discussion_params[:message]
42
+ )
43
+ refresh_discussions
44
+
45
+ message = I18n.t("lesli.items.discussions.created")
46
+ respond_with_lesli(
47
+ turbo: [
48
+ stream_notification_success(message),
49
+ discussions_stream
50
+ ],
51
+ json: {
52
+ message: message,
53
+ comment: comment_payload(comment)
54
+ },
55
+ status: :created
56
+ )
57
+ rescue ActiveRecord::RecordInvalid => error
58
+ respond_with_comment_errors(error.record)
44
59
  end
45
60
 
46
- def show
61
+ private
62
+
63
+ def discussable_class
64
+ allowed_discussable_classes.find do |klass|
65
+ klass.name == params.require(:discussable_type)
66
+ end || raise(
67
+ ActiveRecord::RecordNotFound,
68
+ "Unauthorized discussable type"
69
+ )
47
70
  end
48
71
 
49
- def create
50
- discussion = discussion_class.new(discussion_params)
51
-
52
- discussion.user = current_user
53
- discussion.account = current_user.account
54
- discussion.discussable = @discussable if @discussable
55
-
56
- if discussion.save
57
- respond_with_lesli(:turbo => [
58
- stream_notification_success("Discussion created"),
59
- turbo_stream.prepend("#{scope_key}-lesli-items-discussions-list") do
60
- LesliView::Items::Discussion.new(discussion).render_in(view_context)
61
- end
62
- ])
63
- else
64
- respond_with_lesli(
65
- :turbo => stream_notification_danger(discussion.errors.full_messages)
66
- )
67
- end
72
+ def allowed_discussable_classes
73
+ []
68
74
  end
69
75
 
70
- def update
71
- @discussion.done = true
72
- @discussion.save
73
- respond_with_lesli(:turbo => [
74
- stream_notification_success("Discussion updated #{@discussable}"),
75
- turbo_stream.replace(helpers.dom_id(@discussion, scope_key)) do
76
- LesliView::Items::Discussion.new(@discussion, scope_key).render_in(view_context)
77
- end
78
- ])
76
+ def set_discussable
77
+ @discussable = discussable_class.find(
78
+ params.require(:discussable_id)
79
+ )
80
+ ensure_discussable_access!(@discussable)
79
81
  end
80
82
 
81
- private
83
+ def ensure_discussable_access!(discussable)
84
+ allowed = discussable.respond_to?(:lesli_item_accessible_by?) &&
85
+ discussable.lesli_item_accessible_by?(current_user)
82
86
 
83
- # return a key to identify the engine where the tasks are running
84
- def scope_key = ActiveSupport::Inflector.underscore(discussion_class.name).tr("/", "_")
87
+ raise ActiveRecord::RecordNotFound unless allowed
88
+ end
85
89
 
86
- # return the discussion class including the engine where the tasks are running
87
- def discussion_class = self.class.module_parent.const_get("Discussion")
90
+ def discussion_class
91
+ @discussable.class.lesli_discussions_class
92
+ end
88
93
 
89
- # validate we invoke only models that implements Lesli::Item::Discussions concern
90
- def validate_discussable discussable
94
+ def scope_key
95
+ ActiveSupport::Inflector
96
+ .underscore(discussion_class.name)
97
+ .tr("/", "_")
98
+ end
91
99
 
92
- # 1. Attempt to resolve the class safely
93
- klass = discussable.safe_constantize
100
+ def discussion_params
101
+ params.require(scope_key.to_sym).permit(:message)
102
+ end
94
103
 
95
- # 2. VALIDATION: Check if the class is allowed to be discussable
96
- # We check if the class exists AND if it includes your specific discussable trait
97
- unless klass && klass.respond_to?(:is_lesli_taskable?) && klass.is_lesli_commentable?
98
- render json: { error: "Unauthorized discussable type" }, status: :forbidden and return
99
- end
104
+ def refresh_discussions
105
+ association_name =
106
+ @discussable.class.lesli_discussions_association_name
100
107
 
101
- klass
108
+ @discussable.public_send(association_name).reset
102
109
  end
103
110
 
104
- def set_discussion
105
- @discussion = discussion_class.where(account: current_user.account).find(params[:id])
111
+ def discussions_stream(comment: nil)
112
+ turbo_stream.replace(
113
+ helpers.dom_id(@discussable, :discussions)
114
+ ) do
115
+ LesliView::Items::Discussions
116
+ .new(@discussable, comment: comment)
117
+ .render_in(view_context)
118
+ end
106
119
  end
107
120
 
108
- # Permite filtrar/crear para un actionable específico:
109
- # params: actionable_type=LesliSupport::Ticket&actionable_id=123
110
- def set_discussable
111
- type = params[:discussable_type].presence
112
- id = params[:discussable_id].presence
113
- return if type.blank? || id.blank?
114
-
115
- discussable = validate_discussable(type)
116
-
117
- # 3. Execution: Now it is safe to query
118
- @discussable = discussable.where(account: current_user.account).find_by(id: id)
121
+ def comment_payload(comment)
122
+ {
123
+ id: comment.id,
124
+ message: comment.message,
125
+ discussable_type: comment.discussable_type,
126
+ discussable_id: comment.discussable_id,
127
+ author: {
128
+ id: comment.user_id,
129
+ name: comment.author.name
130
+ },
131
+ created_at: comment.created_at,
132
+ updated_at: comment.updated_at
133
+ }
119
134
  end
120
135
 
121
- def discussion_params
122
- params.require(scope_key.to_sym).permit(:message)
136
+ def respond_with_comment_errors(comment)
137
+ refresh_discussions
138
+ message = I18n.t("lesli.items.discussions.invalid")
139
+ respond_with_lesli(
140
+ turbo: [
141
+ stream_notification_danger(message),
142
+ discussions_stream(comment: comment)
143
+ ],
144
+ json: {
145
+ message: message,
146
+ errors: comment.errors
147
+ },
148
+ status: :unprocessable_content
149
+ )
123
150
  end
124
151
  end
125
152
  end