9a79e169c8
[신뢰성] - 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>
115 lines
3.8 KiB
JavaScript
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)
|
|
})(),
|
|
)
|
|
})
|