Files
comrelay/frontend/public/sw.js
T
ttipo 9a79e169c8 feat: 알림 신뢰성·내용 보강 (SSE 재동기화·웹푸시·전직원 일정·파일첨부)
[신뢰성]
- SSE 재연결 시 fetchInitial 로 재동기화: 끊긴 사이 놓친 알림 복구 +
  access 토큰 자동 refresh 로 만료 토큰 재연결 무한루프 해소
- 서비스워커 pushsubscriptionchange 처리로 구독 회전 시 자동 재등록
- 읽은 알림 90일 retention 정리 배치(@Cron)
- 웹푸시 다기기 병렬 발송(allSettled) + TTL/urgency 지정
- 알림 문구 SSOT 상호참조 주석 + web-push 커버리지 테스트 추가

[내용]
- 전 직원(allHands) 일정도 전 사용자에게 알림(생성/수정/삭제)
- task.file_attached 신규 타입: 파일 첨부 시 지시자·담당자 알림
- task.started 재개(수정요청 후) 문구 '재개/시작' 구분
- task.due_changed 푸시에 날짜/해제 상세 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:31:25 +09:00

115 lines
3.8 KiB
JavaScript

/* eslint-disable */
// Relay 서비스워커 — 웹 푸시(Level2): 사이트/탭을 닫아도 OS 토스트를 띄운다.
// 푸시 페이로드는 백엔드 WebPushService 가 { title, body, url } 로 보낸다.
self.addEventListener('install', () => {
// 새 워커를 즉시 활성화
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
if (!event.data) return
let payload = {}
try {
payload = event.data.json()
} catch (e) {
payload = { title: 'Relay', body: '새 알림이 도착했습니다' }
}
const title = payload.title || 'Relay'
const body = payload.body || ''
const url = payload.url || null
event.waitUntil(
(async () => {
// 열린 클라이언트(탭)가 있으면 페이지의 SSE 가 이미 토스트를 처리 → 중복 표시 생략
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
})
if (clients.length > 0) return
await self.registration.showNotification(title, {
body,
icon: '/relay-icon-256.png',
badge: '/relay-icon-256.png',
data: { url },
})
})(),
)
})
// 브라우저가 푸시 구독을 자체 회전(만료·교체)시키면 발생 — 새 구독으로 재등록해
// 사용자가 앱을 다시 열지 않아도 푸시가 끊기지 않게 한다. best-effort(실패 시 앱 재진입의
// enablePush(true) 가 최신화). 백엔드 경로는 기본 프록시('/api') 기준.
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
(async () => {
try {
// 브라우저가 새 구독을 넘겨줬으면 그대로, 아니면 이전 구독의 VAPID 키로 재구독
let sub = event.newSubscription || null
if (!sub) {
const opts =
event.oldSubscription && event.oldSubscription.options
? event.oldSubscription.options
: null
const appServerKey = opts && opts.applicationServerKey
// 키가 없으면 재구독 불가 — 앱 재진입 때 복구되도록 조용히 종료
if (!appServerKey) return
sub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: appServerKey,
})
}
if (!sub) return
const json = sub.toJSON()
await fetch('/api/notifications/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
endpoint: sub.endpoint,
keys: {
p256dh: (json.keys && json.keys.p256dh) || '',
auth: (json.keys && json.keys.auth) || '',
},
}),
})
} catch (e) {
/* 재구독/재등록 실패 무시 — 앱 재진입 시 최신화 */
}
})(),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = (event.notification.data && event.notification.data.url) || '/'
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
})
// 이미 열린 창이 있으면 포커스 후 해당 경로로 이동
for (const client of clients) {
if ('focus' in client) {
await client.focus()
if ('navigate' in client && url) {
try {
await client.navigate(url)
} catch (e) {
/* 이동 실패 무시 */
}
}
return
}
}
// 열린 창이 없으면 새 창
if (self.clients.openWindow) await self.clients.openWindow(url)
})(),
)
})