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>
This commit is contained in:
@@ -41,6 +41,49 @@ self.addEventListener('push', (event) => {
|
||||
)
|
||||
})
|
||||
|
||||
// 브라우저가 푸시 구독을 자체 회전(만료·교체)시키면 발생 — 새 구독으로 재등록해
|
||||
// 사용자가 앱을 다시 열지 않아도 푸시가 끊기지 않게 한다. 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) || '/'
|
||||
|
||||
@@ -49,6 +49,10 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
|
||||
}
|
||||
|
||||
// API 알림 → 화면 뷰(type+payload → 톤/문구/이동경로). 사용자 입력은 escapeHtml 후 신뢰 태그만 조립
|
||||
//
|
||||
// SSOT 주의: 푸시(사이트 닫힘) 표시 문구는 백엔드 web-push.service.ts 의 notificationContent() 가
|
||||
// 별도로 조립한다(인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **양쪽 모두** case 를 넣을 것.
|
||||
// (백엔드 커버리지는 web-push.service.spec.ts 가 검증)
|
||||
function toView(n: ApiNotification): NotificationView {
|
||||
const p = n.payload
|
||||
const actor = escapeHtml(pstr(p, 'actorName') || '누군가')
|
||||
@@ -77,7 +81,8 @@ function toView(n: ApiNotification): NotificationView {
|
||||
break
|
||||
case 'task.started':
|
||||
tone = 'blue'
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 시작했습니다`
|
||||
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 ${p.resumed === true ? '재개' : '시작'}했습니다`
|
||||
break
|
||||
case 'task.due_changed': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
@@ -113,6 +118,14 @@ function toView(n: ApiNotification): NotificationView {
|
||||
tone = ''
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>에 ${p.isReply === true ? '답글' : '댓글'}을 남겼습니다`
|
||||
break
|
||||
case 'task.file_attached': {
|
||||
const fileName = escapeHtml(pstr(p, 'fileName'))
|
||||
tone = ''
|
||||
html = fileName
|
||||
? `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일 <b>${fileName}</b>을(를) 첨부했습니다`
|
||||
: `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일을 첨부했습니다`
|
||||
break
|
||||
}
|
||||
case 'member.invited':
|
||||
tone = 'accent'
|
||||
to = projectId ? `/projects/${projectId}` : null
|
||||
@@ -204,20 +217,40 @@ export const useNotificationStore = defineStore('notification', () => {
|
||||
es.close()
|
||||
es = null
|
||||
}
|
||||
if (wantConnected && !reconnectTimer) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (wantConnected) openStream()
|
||||
}, RECONNECT_DELAY)
|
||||
}
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 재연결 예약(중복 방지) — 지연 후 '재동기화 → 스트림 오픈' 순으로 복구한다.
|
||||
// 재동기화(fetchInitial)는 axios 인터셉터를 타므로,
|
||||
// 1) 끊긴 사이 놓친 알림을 수신함에 복구하고(미읽음 수도 서버값으로 보정)
|
||||
// 2) access 토큰이 만료됐다면 자동 refresh 되어 쿠키가 갱신된다
|
||||
// → 만료된 토큰으로 SSE 를 여는 무한 실패 루프를 방지.
|
||||
function scheduleReconnect(): void {
|
||||
if (!wantConnected || reconnectTimer) return
|
||||
reconnectTimer = setTimeout(async () => {
|
||||
reconnectTimer = null
|
||||
if (!wantConnected) return
|
||||
try {
|
||||
await fetchInitial()
|
||||
} catch {
|
||||
// 재동기화 실패(세션 만료/네트워크 지속) — 스트림은 열지 않고 다음 주기 재시도
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
if (wantConnected) openStream()
|
||||
}, RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
// 연결 시작(멱등) — 최초 1회 목록 로드 + 스트림 오픈. 로그인 화면 진입마다 호출돼도 안전
|
||||
async function connect(): Promise<void> {
|
||||
if (wantConnected) return
|
||||
wantConnected = true
|
||||
await fetchInitial()
|
||||
try {
|
||||
await fetchInitial()
|
||||
} catch {
|
||||
// 초기 로드 실패해도 스트림은 열어 둔다 — 실패 시 onerror→재연결이 복구
|
||||
}
|
||||
openStream()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export type NotificationType =
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.removed'
|
||||
| 'task.commented'
|
||||
| 'task.file_attached'
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
|
||||
Reference in New Issue
Block a user