백로그
0할 일
0진행 중
0리뷰
0완료 (30일)
3알림 기반: Gem 추가 + PushSubscription 모델 + 마이그레이션
## 목표 알림 시스템의 DB 기반을 구축합니다. ## 작업 내용 ### 1. Gemfile에 web-push gem 추가 ```ruby gem "web-push" ``` - `bundle install` 실행 ### 2. PushSubscription 모델 + 마이그레이션 생성 ```bash bin/rails generate model PushSubscription \ user:references \ endpoint:string \ p256dh:string \ auth:string \ is_active:boolean \ browser_info:string \ device_info:string \ last_notification_sent:datetime ``` - 마이그레이션 파일 수정: - `id: :string` (UUID PK) - `user_id`는 `type: :string` (UUID FK) - `is_active` default: true - `endpoint` NOT NULL - `p256dh`, `auth` NOT NULL - unique index: `[:user_id, :endpoint]` - 레거시에서는 subscription을 JSONB로 저장했지만, SQLite3에서는 endpoint/p256dh/auth를 개별 칼럼으로 분리 ### 3. user_settings에 notification_methods 칼럼 추가 ```bash bin/rails generate migration AddNotificationMethodsToUserSettings notification_methods:string ``` - default: "push" (콤마 구분 문자열: "push,email,kakao") - SQLite3에서 배열 타입 미지원이므로 string으로 저장 ### 4. 모델 코드 **PushSubscription 모델:** ```ruby class PushSubscription < ApplicationRecord belongs_to :user validates :endpoint, presence: true, uniqueness: { scope: :user_id } validates :p256dh, presence: true validates :auth, presence: true scope :active, -> { where(is_active: true) } scope :by_device, ->(type) { where("device_info LIKE ?", "%#{type}%") if type.present? } end ``` **User 모델 업데이트:** - `has_many :push_subscriptions, dependent: :destroy` 추가 **UserSetting 모델 업데이트:** - notification_methods 관련 헬퍼 메서드 추가: ```ruby def notification_methods_array (notification_methods || "push").split(",") end def push_enabled? notification_methods_array.include?("push") end def email_enabled? notification_methods_array.include?("email") end def kakao_enabled? notification_methods_array.include?("kakao") end ``` ### 5. 마이그레이션 실행 ```bash bin/rails db:migrate ``` ### 6. 테스트 실행 ```bash bin/rails test ``` ## 완료 기준 - web-push gem 설치됨 - push_subscriptions 테이블 생성됨 (UUID PK) - user_settings에 notification_methods 칼럼 존재 - User has_many :push_subscriptions 관계 설정됨 - bin/rails test 통과
알림 백엔드: 컨트롤러 + Job + Mailer + 라우팅
## 목표 웹 푸시 구독 관리, 알림 발송 Job, 이메일 Mailer를 구현합니다. ## 선행 조건 - notification-foundation 에이전트가 PushSubscription 모델과 마이그레이션을 완료한 후 시작 ## 작업 내용 ### 1. PushSubscriptionsController 생성 파일: `app/controllers/push_subscriptions_controller.rb` ```ruby class PushSubscriptionsController < ApplicationController before_action :authenticate_user! # POST /push/subscribe - 구독 등록 def create subscription = current_user.push_subscriptions.find_or_initialize_by( endpoint: params[:endpoint] ) subscription.assign_attributes( p256dh: params[:p256dh], auth: params[:auth], browser_info: params[:browser_info], device_info: params[:device_info], is_active: true ) if subscription.save render json: { success: true, id: subscription.id } else render json: { success: false, errors: subscription.errors.full_messages }, status: :unprocessable_entity end end # GET /push/vapid_public_key - VAPID 공개키 반환 def vapid_public_key render json: { vapid_public_key: Rails.application.credentials.dig(:vapid, :public_key) || ENV["VAPID_PUBLIC_KEY"] } end # DELETE /push/unsubscribe - 구독 해제 def destroy subscription = current_user.push_subscriptions.find_by(endpoint: params[:endpoint]) if subscription subscription.update(is_active: false) render json: { success: true } else render json: { success: false }, status: :not_found end end end ``` ### 2. 라우팅 추가 파일: `config/routes.rb`에 추가: ```ruby # 푸시 알림 scope :push do post "subscribe", to: "push_subscriptions#create" get "vapid_public_key", to: "push_subscriptions#vapid_public_key" delete "unsubscribe", to: "push_subscriptions#destroy" end ``` ### 3. NotificationService 생성 파일: `app/services/notification_service.rb` - 웹 푸시 발송 로직 (web-push gem 사용) - 이메일 발송 로직 (Action Mailer) - 발송 방식 선택 로직 (push → email → kakao 순서) ```ruby class NotificationService def initialize(user, title:, message:, url: nil) @user = user @title = title @message = message @url = url || "/" end def send_all results = { push: nil, email: nil } setting = @user.user_setting methods = setting&.notification_methods_array || ["push"] results[:push] = send_web_push if methods.include?("push") results[:email] = send_email if methods.include?("email") results end private def send_web_push subscriptions = @user.push_subscriptions.active return { success: false, reason: "no_subscriptions" } if subscriptions.empty? vapid = { subject: "mailto:#{ENV.fetch('VAPID_EMAIL', 'admin@logbible.co.kr')}", public_key: ENV.fetch("VAPID_PUBLIC_KEY", Rails.application.credentials.dig(:vapid, :public_key)), private_key: ENV.fetch("VAPID_PRIVATE_KEY", Rails.application.credentials.dig(:vapid, :private_key)) } payload = { title: @title, body: @message, icon: "/icon-192x192.png", badge: "/badge-72x72.png", url: @url, actions: [{ action: "open", title: "열기" }] }.to_json sent = 0 failed = 0 subscriptions.find_each do |sub| begin WebPush.payload_send( message: payload, endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth, vapid: vapid ) sub.update(last_notification_sent: Time.current) sent += 1 rescue WebPush::ExpiredSubscription sub.update(is_active: false) failed += 1 rescue => e Rails.logger.error("Push failed for #{sub.id}: #{e.message}") failed += 1 end end { success: sent > 0, sent: sent, failed: failed } end def send_email NotificationMailer.qt_reminder(@user, @title, @message, @url).deliver_later { success: true } rescue => e Rails.logger.error("Email failed for #{@user.id}: #{e.message}") { success: false, reason: e.message } end end ``` ### 4. NotificationCronJob 생성 파일: `app/jobs/notification_cron_job.rb` ```ruby class NotificationCronJob < ApplicationJob queue_as :default def perform current_hour = Time.current.in_time_zone("Asia/Seoul").hour # notification_enabled이고, notification_time이 현재 시간인 사용자 조회 UserSetting.where(notification_enabled: true) .where("CAST(strftime('%H', notification_time) AS INTEGER) = ?", current_hour) .includes(user: [:push_subscriptions], current_session: { qt_theme: :qt_contents }) .find_each do |setting| next unless setting.current_session # 활성 세션 없으면 skip session = setting.current_session theme = session.qt_theme # 오늘 날짜 기준 day_number 계산 day_number = (Date.current - session.start_date.to_date).to_i + 1 content = theme.qt_contents.find_by(day_number: day_number) next unless content # 오늘 콘텐츠 없으면 skip title = "📖 QT 시간입니다!" message = "#{theme.title} - #{content.bible_chapter}" url = "/qt/today" NotificationService.new( setting.user, title: title, message: message, url: url ).send_all end end end ``` ### 5. Solid Queue 반복 스케줄 설정 파일: `config/recurring.yml` 생성: ```yaml production: notification_cron: class: NotificationCronJob schedule: every hour description: "매 시간 알림 발송 체크" ``` ### 6. NotificationMailer 생성 파일: `app/mailers/notification_mailer.rb` ```ruby class NotificationMailer < ApplicationMailer def qt_reminder(user, title, message, url) @user = user @title = title @message = message @url = url mail(to: @user.email, subject: title) end end ``` 뷰 파일: `app/views/notification_mailer/qt_reminder.html.erb` - 간단한 HTML 이메일 템플릿 (Tailwind 미사용, 인라인 CSS) - LogBible 로고, 제목, 메시지, CTA 버튼 ### 7. SettingsController 업데이트 - `settings_params`에 `notification_methods` 추가 ## 완료 기준 - POST /push/subscribe 동작 - GET /push/vapid_public_key 동작 - DELETE /push/unsubscribe 동작 - NotificationCronJob이 올바른 사용자에게 알림 발송 - NotificationMailer 이메일 발송 - config/recurring.yml 존재 - bin/rails test 통과
알림 프론트엔드: Service Worker + Settings UI + Stimulus
## 목표 브라우저 Service Worker, 알림 설정 UI, Stimulus 컨트롤러를 구현합니다. ## 선행 조건 - notification-foundation 에이전트가 모델/마이그레이션 완료 후 시작 - notification-backend 에이전트가 라우트(/push/subscribe, /push/vapid_public_key)를 설정한 후 시작 ## 작업 내용 ### 1. Service Worker 생성 파일: `public/service-worker.js` ```javascript self.addEventListener("push", function(event) { const data = event.data ? event.data.json() : {}; const title = data.title || "LogBible"; const options = { body: data.body || "새 알림이 있습니다.", icon: data.icon || "/icon-192x192.png", badge: data.badge || "/badge-72x72.png", data: { url: data.url || "/" }, actions: data.actions || [{ action: "open", title: "열기" }], requireInteraction: true }; event.waitUntil(self.registration.showNotification(title, options)); }); self.addEventListener("notificationclick", function(event) { event.notification.close(); const url = event.notification.data?.url || "/"; event.waitUntil( clients.matchAll({ type: "window", includeUncontrolled: true }).then(function(clientList) { for (const client of clientList) { if (client.url.includes(url) && "focus" in client) { return client.focus(); } } return clients.openWindow(url); }) ); }); ``` ### 2. Stimulus Push Notification 컨트롤러 생성 파일: `app/javascript/controllers/push_notification_controller.js` ```javascript import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["status", "toggleBtn"] async connect() { this.updateStatus(); } async togglePush() { if (!("serviceWorker" in navigator) || !("PushManager" in window)) { alert("이 브라우저는 푸시 알림을 지원하지 않습니다."); return; } const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (subscription) { // 이미 구독 중이면 해제 await this.unsubscribe(subscription); } else { // 구독 시작 await this.subscribe(registration); } this.updateStatus(); } async subscribe(registration) { try { const permission = await Notification.requestPermission(); if (permission !== "granted") { alert("알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요."); return; } // VAPID 키 가져오기 const response = await fetch("/push/vapid_public_key"); const { vapid_public_key } = await response.json(); const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(vapid_public_key) }); const sub = subscription.toJSON(); // 서버에 구독 등록 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; await fetch("/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, body: JSON.stringify({ endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth, browser_info: navigator.userAgent, device_info: /Mobile|Android/i.test(navigator.userAgent) ? "mobile" : "desktop" }) }); } catch (error) { console.error("Push subscription failed:", error); alert("푸시 알림 등록에 실패했습니다."); } } async unsubscribe(subscription) { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; await fetch("/push/unsubscribe", { method: "DELETE", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, body: JSON.stringify({ endpoint: subscription.endpoint }) }); await subscription.unsubscribe(); } async updateStatus() { if (!("serviceWorker" in navigator)) { if (this.hasStatusTarget) this.statusTarget.textContent = "미지원"; return; } const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (this.hasStatusTarget) { this.statusTarget.textContent = subscription ? "활성" : "비활성"; } if (this.hasToggleBtnTarget) { this.toggleBtnTarget.textContent = subscription ? "푸시 알림 해제" : "푸시 알림 등록"; } } urlBase64ToUint8Array(base64String) { const padding = "=".repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } } ``` ### 3. Service Worker 등록 (application.js 또는 별도) 파일: `app/javascript/controllers/service_worker_controller.js` ```javascript import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/service-worker.js") .then(reg => console.log("Service Worker registered:", reg.scope)) .catch(err => console.error("Service Worker registration failed:", err)); } } } ``` - application layout의 body에 `data-controller="service-worker"` 추가 ### 4. Settings UI 업데이트 파일: `app/views/settings/show.html.erb` 기존 알림 설정 섹션에 notification_methods 체크박스 추가: - 알림 활성화 토글 (notification_enabled) - 알림 시간 설정 (notification_time) - **알림 방식 선택** (notification_methods) - 체크박스: - [ ] 푸시 알림 (push) + 푸시 등록/해제 버튼 - [ ] 이메일 알림 (email) - [ ] 카카오톡 알림 (kakao) - 비활성화 표시 "준비 중" Tailwind CSS 사용, 반응형 디자인. 푸시 알림 섹션에 `data-controller="push-notification"` 연결. 레거시 UI 참고: 기존 settings/show.html.erb의 스타일을 따릅니다. ### 5. SettingsController 파라미터 업데이트 파일: `app/controllers/settings_controller.rb` - `settings_params`에 `notification_methods` 추가 ## 완료 기준 - public/service-worker.js 존재 - Stimulus push_notification_controller 동작 - Service Worker 자동 등록 - 설정 페이지에서 알림 방식 선택 가능 - 푸시 알림 등록/해제 버튼 동작 - notification_methods가 서버에 저장됨 - bin/rails test 통과