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.
Files changed (111) hide show
  1. package/README.md +128 -0
  2. package/bin/claude-framework +3 -0
  3. package/framework/agents/design-lead.md +240 -0
  4. package/framework/agents/product-owner.md +179 -0
  5. package/framework/agents/tech-lead.md +226 -0
  6. package/framework/commands/ayuda.md +127 -0
  7. package/framework/commands/a/303/261adir.md +98 -0
  8. package/framework/commands/backup.md +397 -0
  9. package/framework/commands/cambiar.md +110 -0
  10. package/framework/commands/cloud.md +457 -0
  11. package/framework/commands/code.md +142 -0
  12. package/framework/commands/debug.md +334 -0
  13. package/framework/commands/deploy.md +383 -0
  14. package/framework/commands/deshacer.md +120 -0
  15. package/framework/commands/estado.md +218 -0
  16. package/framework/commands/explica.md +227 -0
  17. package/framework/commands/feature.md +120 -0
  18. package/framework/commands/git.md +427 -0
  19. package/framework/commands/historial.md +202 -0
  20. package/framework/commands/learn.md +408 -0
  21. package/framework/commands/movil.md +245 -0
  22. package/framework/commands/nuevo.md +118 -0
  23. package/framework/commands/plan.md +134 -0
  24. package/framework/commands/prd.md +113 -0
  25. package/framework/commands/probar.md +148 -0
  26. package/framework/commands/revisar.md +208 -0
  27. package/framework/commands/seeds.md +230 -0
  28. package/framework/commands/seguridad.md +226 -0
  29. package/framework/commands/tasks.md +157 -0
  30. package/framework/skills/architecture/algorithms.md +970 -0
  31. package/framework/skills/architecture/clean-code.md +1080 -0
  32. package/framework/skills/architecture/design-patterns.md +1984 -0
  33. package/framework/skills/architecture/functional-programming.md +972 -0
  34. package/framework/skills/architecture/solid.md +991 -0
  35. package/framework/skills/cloud/cloud-aws.md +848 -0
  36. package/framework/skills/cloud/cloud-azure.md +931 -0
  37. package/framework/skills/cloud/cloud-gcp.md +848 -0
  38. package/framework/skills/cloud/message-queues.md +1229 -0
  39. package/framework/skills/core/accessibility.md +401 -0
  40. package/framework/skills/core/api.md +474 -0
  41. package/framework/skills/core/authentication.md +306 -0
  42. package/framework/skills/core/authorization.md +388 -0
  43. package/framework/skills/core/background-jobs.md +341 -0
  44. package/framework/skills/core/caching.md +473 -0
  45. package/framework/skills/core/code-review.md +341 -0
  46. package/framework/skills/core/controllers.md +290 -0
  47. package/framework/skills/core/cua.md +285 -0
  48. package/framework/skills/core/documentation.md +472 -0
  49. package/framework/skills/core/file-uploads.md +351 -0
  50. package/framework/skills/core/hotwire-native.md +296 -0
  51. package/framework/skills/core/hotwire.md +278 -0
  52. package/framework/skills/core/i18n.md +334 -0
  53. package/framework/skills/core/imports-exports.md +750 -0
  54. package/framework/skills/core/infrastructure.md +337 -0
  55. package/framework/skills/core/models.md +228 -0
  56. package/framework/skills/core/notifications.md +672 -0
  57. package/framework/skills/core/payments.md +581 -0
  58. package/framework/skills/core/performance.md +361 -0
  59. package/framework/skills/core/rails-scaffold.md +131 -0
  60. package/framework/skills/core/search.md +518 -0
  61. package/framework/skills/core/security.md +565 -0
  62. package/framework/skills/core/seeds.md +307 -0
  63. package/framework/skills/core/seo.md +542 -0
  64. package/framework/skills/core/testing.md +393 -0
  65. package/framework/skills/core/views.md +260 -0
  66. package/framework/skills/core/websockets.md +564 -0
  67. package/framework/skills/data/advanced-sql.md +1204 -0
  68. package/framework/skills/data/nosql.md +1141 -0
  69. package/framework/skills/devops/containers-advanced.md +1237 -0
  70. package/framework/skills/devops/debugging.md +834 -0
  71. package/framework/skills/devops/git-workflow.md +752 -0
  72. package/framework/skills/devops/networking.md +932 -0
  73. package/framework/skills/devops/shell-scripting.md +1132 -0
  74. package/framework/sub-agents/architecture-patterns-agent.md +1450 -0
  75. package/framework/sub-agents/cloud-agent.md +677 -0
  76. package/framework/sub-agents/data.md +504 -0
  77. package/framework/sub-agents/debugging-agent.md +554 -0
  78. package/framework/sub-agents/devops.md +483 -0
  79. package/framework/sub-agents/docs.md +176 -0
  80. package/framework/sub-agents/frontend-dev.md +349 -0
  81. package/framework/sub-agents/git-workflow-agent.md +697 -0
  82. package/framework/sub-agents/integrations.md +630 -0
  83. package/framework/sub-agents/native-dev.md +434 -0
  84. package/framework/sub-agents/qa.md +138 -0
  85. package/framework/sub-agents/rails-dev.md +375 -0
  86. package/framework/sub-agents/security.md +526 -0
  87. package/framework/sub-agents/ui.md +437 -0
  88. package/framework/sub-agents/ux.md +284 -0
  89. package/framework/templates/api-spec.md +500 -0
  90. package/framework/templates/component-spec.md +248 -0
  91. package/framework/templates/feature.json +13 -0
  92. package/framework/templates/model-spec.md +318 -0
  93. package/framework/templates/prd-template.md +80 -0
  94. package/framework/templates/task-plan.md +122 -0
  95. package/framework/templates/task-user-story.md +52 -0
  96. package/framework/templates/technical-spec.md +260 -0
  97. package/framework/templates/user-story.md +95 -0
  98. package/package.json +42 -0
  99. package/project-templates/CLAUDE.md +42 -0
  100. package/project-templates/contexts/architecture.md +25 -0
  101. package/project-templates/contexts/conventions.md +46 -0
  102. package/project-templates/contexts/design-system.md +47 -0
  103. package/project-templates/contexts/requirements.md +38 -0
  104. package/project-templates/contexts/stack.md +30 -0
  105. package/project-templates/history/active/models.md +11 -0
  106. package/project-templates/history/changelog.md +15 -0
  107. package/project-templates/workspace/.gitkeep +0 -0
  108. package/src/cli.js +52 -0
  109. package/src/init.js +104 -0
  110. package/src/status.js +75 -0
  111. 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