알림 백엔드: 컨트롤러 + Job + Mailer + 라우팅

ID: 9ecebc07-5bdb-4c0d-8bf4-8f1cf25f2420

높음 완료

## 목표
웹 푸시 구독 관리, 알림 발송 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 통과

첨부 이미지

이미지 추가 (Ctrl+V로 붙여넣기 또는 클릭)

JPEG, PNG, GIF, WebP / 최대 10MB

담당자: notification-backend
생성일: 2026년 03월 02일 04:40

활동 로그

  • 팀리드 상태 변경: 리뷰 → 완료

    2026년 03월 02일 05:13:04

  • N
    notification-backend 상태 변경: 할 일 → 리뷰

    2026년 03월 02일 05:07:11

  • N
    notification-backend 알림 백엔드 구현 완료

    2026년 03월 02일 05:07:11

    상세 보기

    ## 생성된 파일
    - `app/controllers/push_subscriptions_controller.rb` - 구독 등록/해제/VAPID키 반환
    - `app/services/notification_service.rb` - 웹 푸시 + 이메일 발송 통합 서비스
    - `app/jobs/notification_cron_job.rb` - 매 시간 알림 발송 체크 Job
    - `app/mailers/notification_mailer.rb` - QT 알림 이메일 Mailer
    - `app/views/notification_mailer/qt_reminder.html.erb` - 이메일 템플릿

    ## 수정된 파일
    - `Gemfile` - web-push gem (~> 3.0) 추가
    - `config/routes.rb` - /push/subscribe, /push/vapid_public_key, /push/unsubscribe 라우트 추가
    - `config/recurring.yml` - NotificationCronJob 매 시간 스케줄 추가
    - `app/controllers/settings_controller.rb` - settings_params에 :notification_methods 추가
    - `test/fixtures/user_settings.yml` - notification_enabled, notification_time, notification_methods, current_session 추가

    ## 테스트 파일
    - `test/controllers/push_subscriptions_controller_test.rb` (7 tests)
    - `test/services/notification_service_test.rb` (7 tests)
    - `test/jobs/notification_cron_job_test.rb` (3 tests)
    - `test/mailers/notification_mailer_test.rb` (1 test)

    ## 테스트 결과
    - 18/18 테스트 통과 (0 failures, 0 errors)
    - 전체 391 runs, 898 assertions, 0 failures
    - 기존 1개 에러(Admin::QtThemesControllerTest)는 제 변경과 무관

    ## 참고사항
    - web-push gem 설치 시 openssl native extension 빌드 필요 → Docker에 build-essential 설치 필요
    - QtContent 모델은 `bible_passage` 필드 사용 (지시서의 `bible_chapter`가 아님)
    - Dockerfile에 build-essential 추가 필요 (현재 런타임 컨테이너에 수동 설치함)

  • N
    notification-backend 알림 백엔드 구현 시작

    2026년 03월 02일 04:56:11

    상세 보기

    ## 구현 범위
    - PushSubscriptionsController (구독 등록/해제/VAPID키)
    - 라우팅 설정 (/push/*)
    - NotificationService (웹 푸시 + 이메일)
    - NotificationCronJob (매 시간 알림 체크)
    - NotificationMailer + 이메일 템플릿
    - SettingsController 파라미터 업데이트
    - Solid Queue recurring 스케줄