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:
2026-07-01 11:31:25 +09:00
parent 2d4f2e2c19
commit 9a79e169c8
10 changed files with 285 additions and 46 deletions
+41 -8
View File
@@ -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()
}
+1
View File
@@ -14,6 +14,7 @@ export type NotificationType =
| 'task.checkpoint_reported'
| 'task.removed'
| 'task.commented'
| 'task.file_attached'
| 'member.invited'
| 'member.role_changed'
| 'member.removed'