claude-agent-framework 1.0.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.
- package/README.md +128 -0
- package/bin/claude-framework +3 -0
- package/framework/agents/design-lead.md +240 -0
- package/framework/agents/product-owner.md +179 -0
- package/framework/agents/tech-lead.md +226 -0
- package/framework/commands/ayuda.md +127 -0
- package/framework/commands/a/303/261adir.md +98 -0
- package/framework/commands/backup.md +397 -0
- package/framework/commands/cambiar.md +110 -0
- package/framework/commands/cloud.md +457 -0
- package/framework/commands/code.md +142 -0
- package/framework/commands/debug.md +334 -0
- package/framework/commands/deploy.md +383 -0
- package/framework/commands/deshacer.md +120 -0
- package/framework/commands/estado.md +218 -0
- package/framework/commands/explica.md +227 -0
- package/framework/commands/feature.md +120 -0
- package/framework/commands/git.md +427 -0
- package/framework/commands/historial.md +202 -0
- package/framework/commands/learn.md +408 -0
- package/framework/commands/movil.md +245 -0
- package/framework/commands/nuevo.md +118 -0
- package/framework/commands/plan.md +134 -0
- package/framework/commands/prd.md +113 -0
- package/framework/commands/probar.md +148 -0
- package/framework/commands/revisar.md +208 -0
- package/framework/commands/seeds.md +230 -0
- package/framework/commands/seguridad.md +226 -0
- package/framework/commands/tasks.md +157 -0
- package/framework/skills/architecture/algorithms.md +970 -0
- package/framework/skills/architecture/clean-code.md +1080 -0
- package/framework/skills/architecture/design-patterns.md +1984 -0
- package/framework/skills/architecture/functional-programming.md +972 -0
- package/framework/skills/architecture/solid.md +991 -0
- package/framework/skills/cloud/cloud-aws.md +848 -0
- package/framework/skills/cloud/cloud-azure.md +931 -0
- package/framework/skills/cloud/cloud-gcp.md +848 -0
- package/framework/skills/cloud/message-queues.md +1229 -0
- package/framework/skills/core/accessibility.md +401 -0
- package/framework/skills/core/api.md +474 -0
- package/framework/skills/core/authentication.md +306 -0
- package/framework/skills/core/authorization.md +388 -0
- package/framework/skills/core/background-jobs.md +341 -0
- package/framework/skills/core/caching.md +473 -0
- package/framework/skills/core/code-review.md +341 -0
- package/framework/skills/core/controllers.md +290 -0
- package/framework/skills/core/cua.md +285 -0
- package/framework/skills/core/documentation.md +472 -0
- package/framework/skills/core/file-uploads.md +351 -0
- package/framework/skills/core/hotwire-native.md +296 -0
- package/framework/skills/core/hotwire.md +278 -0
- package/framework/skills/core/i18n.md +334 -0
- package/framework/skills/core/imports-exports.md +750 -0
- package/framework/skills/core/infrastructure.md +337 -0
- package/framework/skills/core/models.md +228 -0
- package/framework/skills/core/notifications.md +672 -0
- package/framework/skills/core/payments.md +581 -0
- package/framework/skills/core/performance.md +361 -0
- package/framework/skills/core/rails-scaffold.md +131 -0
- package/framework/skills/core/search.md +518 -0
- package/framework/skills/core/security.md +565 -0
- package/framework/skills/core/seeds.md +307 -0
- package/framework/skills/core/seo.md +542 -0
- package/framework/skills/core/testing.md +393 -0
- package/framework/skills/core/views.md +260 -0
- package/framework/skills/core/websockets.md +564 -0
- package/framework/skills/data/advanced-sql.md +1204 -0
- package/framework/skills/data/nosql.md +1141 -0
- package/framework/skills/devops/containers-advanced.md +1237 -0
- package/framework/skills/devops/debugging.md +834 -0
- package/framework/skills/devops/git-workflow.md +752 -0
- package/framework/skills/devops/networking.md +932 -0
- package/framework/skills/devops/shell-scripting.md +1132 -0
- package/framework/sub-agents/architecture-patterns-agent.md +1450 -0
- package/framework/sub-agents/cloud-agent.md +677 -0
- package/framework/sub-agents/data.md +504 -0
- package/framework/sub-agents/debugging-agent.md +554 -0
- package/framework/sub-agents/devops.md +483 -0
- package/framework/sub-agents/docs.md +176 -0
- package/framework/sub-agents/frontend-dev.md +349 -0
- package/framework/sub-agents/git-workflow-agent.md +697 -0
- package/framework/sub-agents/integrations.md +630 -0
- package/framework/sub-agents/native-dev.md +434 -0
- package/framework/sub-agents/qa.md +138 -0
- package/framework/sub-agents/rails-dev.md +375 -0
- package/framework/sub-agents/security.md +526 -0
- package/framework/sub-agents/ui.md +437 -0
- package/framework/sub-agents/ux.md +284 -0
- package/framework/templates/api-spec.md +500 -0
- package/framework/templates/component-spec.md +248 -0
- package/framework/templates/feature.json +13 -0
- package/framework/templates/model-spec.md +318 -0
- package/framework/templates/prd-template.md +80 -0
- package/framework/templates/task-plan.md +122 -0
- package/framework/templates/task-user-story.md +52 -0
- package/framework/templates/technical-spec.md +260 -0
- package/framework/templates/user-story.md +95 -0
- package/package.json +42 -0
- package/project-templates/CLAUDE.md +42 -0
- package/project-templates/contexts/architecture.md +25 -0
- package/project-templates/contexts/conventions.md +46 -0
- package/project-templates/contexts/design-system.md +47 -0
- package/project-templates/contexts/requirements.md +38 -0
- package/project-templates/contexts/stack.md +30 -0
- package/project-templates/history/active/models.md +11 -0
- package/project-templates/history/changelog.md +15 -0
- package/project-templates/workspace/.gitkeep +0 -0
- package/src/cli.js +52 -0
- package/src/init.js +104 -0
- package/src/status.js +75 -0
- package/src/update.js +88 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
# Skill: Notifications
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Implementar sistema de notificaciones completo: email, in-app, y push notifications para aplicaciones Rails.
|
|
6
|
+
|
|
7
|
+
## Email Notifications (Action Mailer)
|
|
8
|
+
|
|
9
|
+
### Configuración
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# config/environments/production.rb
|
|
13
|
+
config.action_mailer.delivery_method = :smtp
|
|
14
|
+
config.action_mailer.smtp_settings = {
|
|
15
|
+
address: "smtp.example.com",
|
|
16
|
+
port: 587,
|
|
17
|
+
domain: "myapp.com",
|
|
18
|
+
user_name: Rails.application.credentials.dig(:smtp, :user_name),
|
|
19
|
+
password: Rails.application.credentials.dig(:smtp, :password),
|
|
20
|
+
authentication: "plain",
|
|
21
|
+
enable_starttls_auto: true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
config.action_mailer.default_url_options = { host: "myapp.com" }
|
|
25
|
+
config.action_mailer.perform_deliveries = true
|
|
26
|
+
|
|
27
|
+
# config/environments/development.rb
|
|
28
|
+
config.action_mailer.delivery_method = :letter_opener
|
|
29
|
+
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
|
30
|
+
|
|
31
|
+
# Gemfile (desarrollo)
|
|
32
|
+
group :development do
|
|
33
|
+
gem "letter_opener"
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Mailer base
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# app/mailers/application_mailer.rb
|
|
41
|
+
class ApplicationMailer < ActionMailer::Base
|
|
42
|
+
default from: "Mi App <noreply@myapp.com>"
|
|
43
|
+
layout "mailer"
|
|
44
|
+
|
|
45
|
+
# Helper para incluir assets
|
|
46
|
+
helper :application
|
|
47
|
+
|
|
48
|
+
# Callback para tracking
|
|
49
|
+
after_action :track_email_sent
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def track_email_sent
|
|
54
|
+
EmailLog.create!(
|
|
55
|
+
to: message.to&.join(", "),
|
|
56
|
+
subject: message.subject,
|
|
57
|
+
mailer: self.class.name,
|
|
58
|
+
action: action_name
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Mailer de ejemplo
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# app/mailers/user_mailer.rb
|
|
68
|
+
class UserMailer < ApplicationMailer
|
|
69
|
+
def welcome(user)
|
|
70
|
+
@user = user
|
|
71
|
+
@login_url = new_session_url
|
|
72
|
+
|
|
73
|
+
mail(
|
|
74
|
+
to: @user.email,
|
|
75
|
+
subject: t(".subject", name: @user.name)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def password_reset(user)
|
|
80
|
+
@user = user
|
|
81
|
+
@reset_url = edit_password_url(token: @user.password_reset_token)
|
|
82
|
+
|
|
83
|
+
mail(
|
|
84
|
+
to: @user.email,
|
|
85
|
+
subject: t(".subject")
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def notification(user, notification)
|
|
90
|
+
@user = user
|
|
91
|
+
@notification = notification
|
|
92
|
+
|
|
93
|
+
mail(
|
|
94
|
+
to: @user.email,
|
|
95
|
+
subject: @notification.title
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Templates
|
|
102
|
+
|
|
103
|
+
```erb
|
|
104
|
+
<%# app/views/user_mailer/welcome.html.erb %>
|
|
105
|
+
<h1><%= t(".greeting", name: @user.name) %></h1>
|
|
106
|
+
|
|
107
|
+
<p><%= t(".welcome_message") %></p>
|
|
108
|
+
|
|
109
|
+
<p>
|
|
110
|
+
<%= link_to t(".login_button"), @login_url, class: "button" %>
|
|
111
|
+
</p>
|
|
112
|
+
|
|
113
|
+
<p><%= t(".help_text") %></p>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```erb
|
|
117
|
+
<%# app/views/layouts/mailer.html.erb %>
|
|
118
|
+
<!DOCTYPE html>
|
|
119
|
+
<html>
|
|
120
|
+
<head>
|
|
121
|
+
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
|
122
|
+
<style>
|
|
123
|
+
body {
|
|
124
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
125
|
+
line-height: 1.6;
|
|
126
|
+
color: #333;
|
|
127
|
+
max-width: 600px;
|
|
128
|
+
margin: 0 auto;
|
|
129
|
+
padding: 20px;
|
|
130
|
+
}
|
|
131
|
+
.button {
|
|
132
|
+
display: inline-block;
|
|
133
|
+
padding: 12px 24px;
|
|
134
|
+
background-color: #3B82F6;
|
|
135
|
+
color: white;
|
|
136
|
+
text-decoration: none;
|
|
137
|
+
border-radius: 6px;
|
|
138
|
+
}
|
|
139
|
+
.footer {
|
|
140
|
+
margin-top: 40px;
|
|
141
|
+
padding-top: 20px;
|
|
142
|
+
border-top: 1px solid #eee;
|
|
143
|
+
font-size: 12px;
|
|
144
|
+
color: #666;
|
|
145
|
+
}
|
|
146
|
+
</style>
|
|
147
|
+
</head>
|
|
148
|
+
<body>
|
|
149
|
+
<%= yield %>
|
|
150
|
+
|
|
151
|
+
<div class="footer">
|
|
152
|
+
<p><%= t("mailer.footer.company_name") %></p>
|
|
153
|
+
<p><%= link_to t("mailer.footer.unsubscribe"), unsubscribe_url %></p>
|
|
154
|
+
</div>
|
|
155
|
+
</body>
|
|
156
|
+
</html>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Envío asíncrono
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
# Enviar en background (recomendado)
|
|
163
|
+
UserMailer.welcome(user).deliver_later
|
|
164
|
+
|
|
165
|
+
# Enviar con prioridad
|
|
166
|
+
UserMailer.password_reset(user).deliver_later(queue: :high_priority)
|
|
167
|
+
|
|
168
|
+
# Enviar con delay
|
|
169
|
+
UserMailer.reminder(user).deliver_later(wait: 1.hour)
|
|
170
|
+
|
|
171
|
+
# Enviar a hora específica
|
|
172
|
+
UserMailer.digest(user).deliver_later(wait_until: Date.tomorrow.noon)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## In-App Notifications
|
|
176
|
+
|
|
177
|
+
### Modelo de notificaciones
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# db/migrate/xxx_create_notifications.rb
|
|
181
|
+
class CreateNotifications < ActiveRecord::Migration[8.0]
|
|
182
|
+
def change
|
|
183
|
+
create_table :notifications do |t|
|
|
184
|
+
t.references :recipient, null: false, foreign_key: { to_table: :users }
|
|
185
|
+
t.references :actor, foreign_key: { to_table: :users }
|
|
186
|
+
t.references :notifiable, polymorphic: true
|
|
187
|
+
t.string :action, null: false
|
|
188
|
+
t.string :title, null: false
|
|
189
|
+
t.text :body
|
|
190
|
+
t.string :url
|
|
191
|
+
t.datetime :read_at
|
|
192
|
+
t.datetime :seen_at
|
|
193
|
+
|
|
194
|
+
t.timestamps
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
add_index :notifications, [:recipient_id, :read_at]
|
|
198
|
+
add_index :notifications, [:recipient_id, :created_at]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# app/models/notification.rb
|
|
203
|
+
class Notification < ApplicationRecord
|
|
204
|
+
belongs_to :recipient, class_name: "User"
|
|
205
|
+
belongs_to :actor, class_name: "User", optional: true
|
|
206
|
+
belongs_to :notifiable, polymorphic: true, optional: true
|
|
207
|
+
|
|
208
|
+
scope :unread, -> { where(read_at: nil) }
|
|
209
|
+
scope :unseen, -> { where(seen_at: nil) }
|
|
210
|
+
scope :recent, -> { order(created_at: :desc).limit(20) }
|
|
211
|
+
|
|
212
|
+
after_create_commit :broadcast_to_recipient
|
|
213
|
+
|
|
214
|
+
def read?
|
|
215
|
+
read_at.present?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def mark_as_read!
|
|
219
|
+
update!(read_at: Time.current) unless read?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def mark_as_seen!
|
|
223
|
+
update!(seen_at: Time.current) unless seen_at?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def broadcast_to_recipient
|
|
229
|
+
broadcast_prepend_to(
|
|
230
|
+
"notifications_#{recipient_id}",
|
|
231
|
+
target: "notifications",
|
|
232
|
+
partial: "notifications/notification",
|
|
233
|
+
locals: { notification: self }
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Actualizar contador
|
|
237
|
+
broadcast_replace_to(
|
|
238
|
+
"notifications_#{recipient_id}",
|
|
239
|
+
target: "notifications_count",
|
|
240
|
+
partial: "notifications/count",
|
|
241
|
+
locals: { count: recipient.notifications.unread.count }
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# app/models/user.rb
|
|
247
|
+
class User < ApplicationRecord
|
|
248
|
+
has_many :notifications, foreign_key: :recipient_id, dependent: :destroy
|
|
249
|
+
|
|
250
|
+
def notify!(action:, title:, body: nil, url: nil, actor: nil, notifiable: nil)
|
|
251
|
+
notifications.create!(
|
|
252
|
+
action: action,
|
|
253
|
+
title: title,
|
|
254
|
+
body: body,
|
|
255
|
+
url: url,
|
|
256
|
+
actor: actor,
|
|
257
|
+
notifiable: notifiable
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Service para crear notificaciones
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
# app/services/notification_service.rb
|
|
267
|
+
class NotificationService
|
|
268
|
+
class << self
|
|
269
|
+
def notify_new_comment(comment)
|
|
270
|
+
return if comment.user == comment.post.user
|
|
271
|
+
|
|
272
|
+
comment.post.user.notify!(
|
|
273
|
+
action: "new_comment",
|
|
274
|
+
title: I18n.t("notifications.new_comment.title", user: comment.user.name),
|
|
275
|
+
body: comment.body.truncate(100),
|
|
276
|
+
url: Rails.application.routes.url_helpers.post_path(comment.post, anchor: "comment_#{comment.id}"),
|
|
277
|
+
actor: comment.user,
|
|
278
|
+
notifiable: comment
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def notify_new_follower(follow)
|
|
283
|
+
follow.followed.notify!(
|
|
284
|
+
action: "new_follower",
|
|
285
|
+
title: I18n.t("notifications.new_follower.title", user: follow.follower.name),
|
|
286
|
+
url: Rails.application.routes.url_helpers.user_path(follow.follower),
|
|
287
|
+
actor: follow.follower,
|
|
288
|
+
notifiable: follow
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def notify_mention(mentionable, mentioned_user, mentioner)
|
|
293
|
+
mentioned_user.notify!(
|
|
294
|
+
action: "mention",
|
|
295
|
+
title: I18n.t("notifications.mention.title", user: mentioner.name),
|
|
296
|
+
body: mentionable.body.truncate(100),
|
|
297
|
+
url: polymorphic_url(mentionable),
|
|
298
|
+
actor: mentioner,
|
|
299
|
+
notifiable: mentionable
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
def self.polymorphic_url(record)
|
|
307
|
+
Rails.application.routes.url_helpers.polymorphic_path(record)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Controller
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
# app/controllers/notifications_controller.rb
|
|
316
|
+
class NotificationsController < ApplicationController
|
|
317
|
+
before_action :authenticate_user!
|
|
318
|
+
|
|
319
|
+
def index
|
|
320
|
+
@notifications = current_user.notifications.recent.includes(:actor, :notifiable)
|
|
321
|
+
|
|
322
|
+
# Marcar como vistas
|
|
323
|
+
current_user.notifications.unseen.update_all(seen_at: Time.current)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def mark_as_read
|
|
327
|
+
@notification = current_user.notifications.find(params[:id])
|
|
328
|
+
@notification.mark_as_read!
|
|
329
|
+
|
|
330
|
+
respond_to do |format|
|
|
331
|
+
format.html { redirect_to @notification.url || notifications_path }
|
|
332
|
+
format.turbo_stream
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def mark_all_as_read
|
|
337
|
+
current_user.notifications.unread.update_all(read_at: Time.current)
|
|
338
|
+
|
|
339
|
+
respond_to do |format|
|
|
340
|
+
format.html { redirect_to notifications_path, notice: t(".success") }
|
|
341
|
+
format.turbo_stream
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Vistas con Turbo
|
|
348
|
+
|
|
349
|
+
```erb
|
|
350
|
+
<%# app/views/notifications/index.html.erb %>
|
|
351
|
+
<div class="max-w-2xl mx-auto py-8">
|
|
352
|
+
<div class="flex justify-between items-center mb-6">
|
|
353
|
+
<h1 class="text-2xl font-bold"><%= t(".title") %></h1>
|
|
354
|
+
<% if @notifications.unread.any? %>
|
|
355
|
+
<%= button_to t(".mark_all_read"),
|
|
356
|
+
mark_all_as_read_notifications_path,
|
|
357
|
+
method: :post,
|
|
358
|
+
class: "text-blue-600 hover:text-blue-800" %>
|
|
359
|
+
<% end %>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<%= turbo_stream_from "notifications_#{current_user.id}" %>
|
|
363
|
+
|
|
364
|
+
<div id="notifications" class="space-y-2">
|
|
365
|
+
<%= render @notifications %>
|
|
366
|
+
|
|
367
|
+
<% if @notifications.empty? %>
|
|
368
|
+
<div class="text-center py-12 text-gray-500">
|
|
369
|
+
<%= t(".empty") %>
|
|
370
|
+
</div>
|
|
371
|
+
<% end %>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
```erb
|
|
377
|
+
<%# app/views/notifications/_notification.html.erb %>
|
|
378
|
+
<%= turbo_frame_tag dom_id(notification) do %>
|
|
379
|
+
<div class="flex items-start p-4 rounded-lg <%= notification.read? ? 'bg-white' : 'bg-blue-50' %>">
|
|
380
|
+
<% if notification.actor %>
|
|
381
|
+
<%= image_tag notification.actor.avatar_url,
|
|
382
|
+
class: "w-10 h-10 rounded-full mr-3",
|
|
383
|
+
alt: notification.actor.name %>
|
|
384
|
+
<% end %>
|
|
385
|
+
|
|
386
|
+
<div class="flex-1">
|
|
387
|
+
<p class="font-medium text-gray-900"><%= notification.title %></p>
|
|
388
|
+
<% if notification.body.present? %>
|
|
389
|
+
<p class="text-gray-600 text-sm mt-1"><%= notification.body %></p>
|
|
390
|
+
<% end %>
|
|
391
|
+
<p class="text-gray-400 text-xs mt-1">
|
|
392
|
+
<%= time_ago_in_words(notification.created_at) %>
|
|
393
|
+
</p>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<% unless notification.read? %>
|
|
397
|
+
<%= button_to mark_as_read_notification_path(notification),
|
|
398
|
+
method: :patch,
|
|
399
|
+
class: "text-gray-400 hover:text-gray-600",
|
|
400
|
+
title: t("notifications.mark_as_read") do %>
|
|
401
|
+
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
402
|
+
<circle cx="10" cy="10" r="4"/>
|
|
403
|
+
</svg>
|
|
404
|
+
<% end %>
|
|
405
|
+
<% end %>
|
|
406
|
+
</div>
|
|
407
|
+
<% end %>
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Dropdown de notificaciones (navbar)
|
|
411
|
+
|
|
412
|
+
```erb
|
|
413
|
+
<%# app/views/shared/_notifications_dropdown.html.erb %>
|
|
414
|
+
<div data-controller="dropdown" class="relative">
|
|
415
|
+
<button data-action="dropdown#toggle"
|
|
416
|
+
class="relative p-2 text-gray-600 hover:text-gray-900">
|
|
417
|
+
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
418
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
419
|
+
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
|
420
|
+
</svg>
|
|
421
|
+
|
|
422
|
+
<%# Contador %>
|
|
423
|
+
<span id="notifications_count">
|
|
424
|
+
<%= render "notifications/count", count: current_user.notifications.unread.count %>
|
|
425
|
+
</span>
|
|
426
|
+
</button>
|
|
427
|
+
|
|
428
|
+
<div data-dropdown-target="menu"
|
|
429
|
+
class="hidden absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg z-50">
|
|
430
|
+
<div class="p-4 border-b">
|
|
431
|
+
<h3 class="font-semibold"><%= t("notifications.title") %></h3>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<div id="notifications_preview" class="max-h-96 overflow-y-auto">
|
|
435
|
+
<%= turbo_stream_from "notifications_#{current_user.id}" %>
|
|
436
|
+
<%= render current_user.notifications.recent.limit(5) %>
|
|
437
|
+
</div>
|
|
438
|
+
|
|
439
|
+
<div class="p-3 border-t text-center">
|
|
440
|
+
<%= link_to t("notifications.view_all"), notifications_path,
|
|
441
|
+
class: "text-blue-600 hover:text-blue-800 text-sm" %>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
```erb
|
|
448
|
+
<%# app/views/notifications/_count.html.erb %>
|
|
449
|
+
<% if count > 0 %>
|
|
450
|
+
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs
|
|
451
|
+
rounded-full h-5 w-5 flex items-center justify-center">
|
|
452
|
+
<%= count > 99 ? "99+" : count %>
|
|
453
|
+
</span>
|
|
454
|
+
<% end %>
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
## Push Notifications (Web Push)
|
|
458
|
+
|
|
459
|
+
### Setup
|
|
460
|
+
|
|
461
|
+
```ruby
|
|
462
|
+
# Gemfile
|
|
463
|
+
gem "web-push"
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
```bash
|
|
467
|
+
# Generar VAPID keys
|
|
468
|
+
rails c
|
|
469
|
+
> vapid_key = WebPush.generate_key
|
|
470
|
+
> puts vapid_key.public_key
|
|
471
|
+
> puts vapid_key.private_key
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
```yaml
|
|
475
|
+
# config/credentials.yml.enc
|
|
476
|
+
web_push:
|
|
477
|
+
public_key: xxx
|
|
478
|
+
private_key: xxx
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Modelo para subscripciones
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
# db/migrate/xxx_create_push_subscriptions.rb
|
|
485
|
+
class CreatePushSubscriptions < ActiveRecord::Migration[8.0]
|
|
486
|
+
def change
|
|
487
|
+
create_table :push_subscriptions do |t|
|
|
488
|
+
t.references :user, null: false, foreign_key: true
|
|
489
|
+
t.string :endpoint, null: false
|
|
490
|
+
t.string :p256dh_key, null: false
|
|
491
|
+
t.string :auth_key, null: false
|
|
492
|
+
|
|
493
|
+
t.timestamps
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
add_index :push_subscriptions, :endpoint, unique: true
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# app/models/push_subscription.rb
|
|
501
|
+
class PushSubscription < ApplicationRecord
|
|
502
|
+
belongs_to :user
|
|
503
|
+
|
|
504
|
+
validates :endpoint, presence: true, uniqueness: true
|
|
505
|
+
validates :p256dh_key, :auth_key, presence: true
|
|
506
|
+
end
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### Service Worker
|
|
510
|
+
|
|
511
|
+
```javascript
|
|
512
|
+
// public/service-worker.js
|
|
513
|
+
self.addEventListener("push", (event) => {
|
|
514
|
+
const data = event.data.json()
|
|
515
|
+
|
|
516
|
+
const options = {
|
|
517
|
+
body: data.body,
|
|
518
|
+
icon: "/icon-192.png",
|
|
519
|
+
badge: "/badge.png",
|
|
520
|
+
data: { url: data.url },
|
|
521
|
+
actions: data.actions || []
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
event.waitUntil(
|
|
525
|
+
self.registration.showNotification(data.title, options)
|
|
526
|
+
)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
self.addEventListener("notificationclick", (event) => {
|
|
530
|
+
event.notification.close()
|
|
531
|
+
|
|
532
|
+
if (event.notification.data?.url) {
|
|
533
|
+
event.waitUntil(
|
|
534
|
+
clients.openWindow(event.notification.data.url)
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### JavaScript para subscription
|
|
541
|
+
|
|
542
|
+
```javascript
|
|
543
|
+
// app/javascript/controllers/push_notifications_controller.js
|
|
544
|
+
import { Controller } from "@hotwired/stimulus"
|
|
545
|
+
|
|
546
|
+
export default class extends Controller {
|
|
547
|
+
static values = { vapidPublicKey: String }
|
|
548
|
+
|
|
549
|
+
async connect() {
|
|
550
|
+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
551
|
+
console.log("Push notifications not supported")
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
this.registration = await navigator.serviceWorker.register("/service-worker.js")
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async subscribe() {
|
|
559
|
+
const permission = await Notification.requestPermission()
|
|
560
|
+
if (permission !== "granted") return
|
|
561
|
+
|
|
562
|
+
const subscription = await this.registration.pushManager.subscribe({
|
|
563
|
+
userVisibleOnly: true,
|
|
564
|
+
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKeyValue)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// Enviar al servidor
|
|
568
|
+
const response = await fetch("/push_subscriptions", {
|
|
569
|
+
method: "POST",
|
|
570
|
+
headers: {
|
|
571
|
+
"Content-Type": "application/json",
|
|
572
|
+
"X-CSRF-Token": document.querySelector("[name='csrf-token']").content
|
|
573
|
+
},
|
|
574
|
+
body: JSON.stringify({ subscription: subscription.toJSON() })
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
if (response.ok) {
|
|
578
|
+
this.element.textContent = "Notifications enabled"
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
urlBase64ToUint8Array(base64String) {
|
|
583
|
+
const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
|
|
584
|
+
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
|
|
585
|
+
const rawData = window.atob(base64)
|
|
586
|
+
const outputArray = new Uint8Array(rawData.length)
|
|
587
|
+
for (let i = 0; i < rawData.length; ++i) {
|
|
588
|
+
outputArray[i] = rawData.charCodeAt(i)
|
|
589
|
+
}
|
|
590
|
+
return outputArray
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Service para enviar push
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
# app/services/push_notification_service.rb
|
|
599
|
+
class PushNotificationService
|
|
600
|
+
def self.send_to_user(user, title:, body:, url: nil)
|
|
601
|
+
user.push_subscriptions.find_each do |subscription|
|
|
602
|
+
send_notification(subscription, title: title, body: body, url: url)
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def self.send_notification(subscription, title:, body:, url: nil)
|
|
607
|
+
message = {
|
|
608
|
+
title: title,
|
|
609
|
+
body: body,
|
|
610
|
+
url: url
|
|
611
|
+
}.to_json
|
|
612
|
+
|
|
613
|
+
WebPush.payload_send(
|
|
614
|
+
message: message,
|
|
615
|
+
endpoint: subscription.endpoint,
|
|
616
|
+
p256dh: subscription.p256dh_key,
|
|
617
|
+
auth: subscription.auth_key,
|
|
618
|
+
vapid: {
|
|
619
|
+
public_key: Rails.application.credentials.dig(:web_push, :public_key),
|
|
620
|
+
private_key: Rails.application.credentials.dig(:web_push, :private_key)
|
|
621
|
+
}
|
|
622
|
+
)
|
|
623
|
+
rescue WebPush::ExpiredSubscription
|
|
624
|
+
subscription.destroy
|
|
625
|
+
rescue WebPush::InvalidSubscription
|
|
626
|
+
subscription.destroy
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
## Preferencias de notificación
|
|
632
|
+
|
|
633
|
+
```ruby
|
|
634
|
+
# db/migrate/xxx_create_notification_preferences.rb
|
|
635
|
+
class CreateNotificationPreferences < ActiveRecord::Migration[8.0]
|
|
636
|
+
def change
|
|
637
|
+
create_table :notification_preferences do |t|
|
|
638
|
+
t.references :user, null: false, foreign_key: true
|
|
639
|
+
t.boolean :email_new_comment, default: true
|
|
640
|
+
t.boolean :email_new_follower, default: true
|
|
641
|
+
t.boolean :email_mentions, default: true
|
|
642
|
+
t.boolean :email_digest, default: true
|
|
643
|
+
t.boolean :push_new_comment, default: true
|
|
644
|
+
t.boolean :push_new_follower, default: false
|
|
645
|
+
t.boolean :push_mentions, default: true
|
|
646
|
+
|
|
647
|
+
t.timestamps
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# app/models/notification_preference.rb
|
|
653
|
+
class NotificationPreference < ApplicationRecord
|
|
654
|
+
belongs_to :user
|
|
655
|
+
|
|
656
|
+
def should_notify?(channel, action)
|
|
657
|
+
attribute = "#{channel}_#{action}"
|
|
658
|
+
respond_to?(attribute) && send(attribute)
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
## Checklist
|
|
664
|
+
|
|
665
|
+
- [ ] Action Mailer configurado
|
|
666
|
+
- [ ] Templates de email creados
|
|
667
|
+
- [ ] Modelo de notificaciones in-app
|
|
668
|
+
- [ ] Turbo Streams para tiempo real
|
|
669
|
+
- [ ] Push notifications (opcional)
|
|
670
|
+
- [ ] Preferencias de usuario
|
|
671
|
+
- [ ] Tests de mailers
|
|
672
|
+
- [ ] Emails enviados en background
|