feat: PC 데스크톱 알림(웹 푸시) 추가 — 인앱/SSE에 OS 토스트 연동

카카오톡처럼 PC에 OS 토스트가 뜨도록 Web Notifications(Level1) +
Web Push/서비스워커(Level2)를 기존 인앱·SSE 알림 위에 얹는다.
중복 방지: 페이지가 열려 있으면 SSE 로컬 알림(포커스면 인앱만),
닫혀 있으면 서비스워커 푸시가 담당한다.

- 백엔드: push_subscriptions 엔티티 + WebPushService(VAPID 설정·
  type→{title,body,url} 포매터·만료 구독 정리), notify()가 SSE와
  함께 푸시 발송, push/subscribe·unsubscribe 엔드포인트, 마이그레이션,
  web-push 의존성
- 프론트: public/sw.js(push/클릭), usePush 컴포저블(권한·구독, 결과
  안내), notification.store 로컬 토스트, AppShell 'PC 알림 켜기' 버튼
- env/compose: VAPID 키 주입(공개키→프론트, 비밀키→백엔드)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:49:33 +09:00
parent 26cad39857
commit 8d00d71072
23 changed files with 847 additions and 18 deletions
+2
View File
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
readonly VITE_AI_TIMEOUT_MS: string
/** 브라우저가 접속할 LiveKit WebSocket URL (예: ws://localhost:7880) */
readonly VITE_LIVEKIT_URL: string
/** 웹 푸시(VAPID) 공개키 — 푸시 구독에 사용. 미설정 시 푸시 비활성 */
readonly VITE_VAPID_PUBLIC_KEY: string
}
interface ImportMeta {
+71
View File
@@ -0,0 +1,71 @@
/* 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 },
})
})(),
)
})
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)
})(),
)
})
+14 -1
View File
@@ -31,5 +31,18 @@ export function useNotification() {
)) as unknown as UnreadCountResult
}
return { list, markRead, markAllRead }
// 웹 푸시 구독 등록(브라우저 PushSubscription)
async function subscribePush(body: {
endpoint: string
keys: { p256dh: string; auth: string }
}): Promise<void> {
await api.post('/notifications/push/subscribe', body)
}
// 웹 푸시 구독 해제
async function unsubscribePush(endpoint: string): Promise<void> {
await api.post('/notifications/push/unsubscribe', { endpoint })
}
return { list, markRead, markAllRead, subscribePush, unsubscribePush }
}
+114
View File
@@ -0,0 +1,114 @@
import { ref } from 'vue'
import { useNotification } from '@/composables/useNotification'
// VAPID 공개키(base64url) → Uint8Array (applicationServerKey 형식)
// ArrayBuffer 백킹으로 명시(BufferSource 타입 충족)
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(base64)
const arr = new Uint8Array(new ArrayBuffer(raw.length))
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
return arr
}
// 푸시 켜기 결과 — UI 가 사유별 안내를 띄울 수 있도록 상태로 반환
export type PushResult =
| 'subscribed' // 구독 성공
| 'denied' // 브라우저가 알림을 차단함
| 'dismissed' // 사용자가 권한 팝업을 닫음(미결정 유지)
| 'unsupported' // 미지원/비보안 컨텍스트
| 'no-key' // 서버 VAPID 공개키 미설정
| 'error' // 그 외 실패(SW/구독)
// 웹 푸시(Level2) 구독 관리 — 권한 요청 + PushManager 구독 + 백엔드 등록/해제.
export function usePush() {
const api = useNotification()
const supported =
typeof navigator !== 'undefined' &&
'serviceWorker' in navigator &&
typeof window !== 'undefined' &&
'PushManager' in window &&
typeof Notification !== 'undefined'
// 현재 권한 상태(UI 토글용)
const permission = ref<NotificationPermission>(
supported ? Notification.permission : 'denied',
)
// 서비스워커 등록 확보 — 명시적 register 로 .ready 무한대기를 피한다.
// 활성화 전이면 최대 5초만 기다린다(그래도 없으면 그대로 진행 — subscribe 가 처리).
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
if (!supported) return null
try {
const reg = await navigator.serviceWorker.register('/sw.js')
if (!reg.active) {
await Promise.race([
navigator.serviceWorker.ready,
new Promise((resolve) => setTimeout(resolve, 5000)),
])
}
return reg
} catch {
return null
}
}
// 권한 요청(필요 시) + 푸시 구독 + 백엔드 등록. 결과 상태를 반환(UI 안내용).
// silent=true 면 이미 granted 인 경우에만 진행(권한 팝업 띄우지 않음 — 자동 재구독용).
async function enablePush(silent = false): Promise<PushResult> {
if (!supported) return 'unsupported'
const key = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (!key) return 'no-key'
let perm = Notification.permission
if (perm !== 'granted') {
if (silent) return 'denied'
perm = await Notification.requestPermission()
}
permission.value = perm
if (perm === 'denied') return 'denied'
if (perm !== 'granted') return 'dismissed'
const reg = await getRegistration()
if (!reg) return 'error'
try {
let sub = await reg.pushManager.getSubscription()
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
})
}
const json = sub.toJSON()
await api.subscribePush({
endpoint: sub.endpoint,
keys: {
p256dh: json.keys?.p256dh ?? '',
auth: json.keys?.auth ?? '',
},
})
return 'subscribed'
} catch {
return 'error'
}
}
// 구독 해제(로그아웃 등) — 백엔드 삭제 + 브라우저 구독 해지
async function disablePush(): Promise<void> {
if (!supported) return
const reg = await getRegistration()
if (!reg) return
try {
const sub = await reg.pushManager.getSubscription()
if (sub) {
await api.unsubscribePush(sub.endpoint)
await sub.unsubscribe()
}
} catch {
// 해제 실패 무시
}
}
return { supported, permission, enablePush, disablePush }
}
+106 -10
View File
@@ -6,6 +6,8 @@ import { storeToRefs } from 'pinia'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
import { useNotificationStore } from '@/stores/notification.store'
import { usePush } from '@/composables/usePush'
import { useDialog } from '@/composables/useDialog'
import UserAvatar from '@/components/UserAvatar.vue'
import RelayMark from '@/components/RelayMark.vue'
import AgentChatPanel from '@/components/AgentChatPanel.vue'
@@ -52,6 +54,45 @@ const {
const notiOpen = ref(false)
// --- PC 데스크톱 알림(웹 푸시) ---
const dialog = useDialog()
const { supported: pushSupported, permission: pushPermission, enablePush, disablePush } =
usePush()
// 종 드롭다운에서 "PC 알림 켜기" 노출 조건(지원 + 아직 허용 전)
const canEnablePush = computed(
() => pushSupported && pushPermission.value !== 'granted',
)
async function onEnablePush() {
const result = await enablePush()
if (result === 'subscribed') {
notiOpen.value = false
void dialog.alert('이 브라우저에서 PC 알림을 받습니다.', {
title: 'PC 알림 켜짐',
})
} else if (result === 'denied') {
void dialog.alert(
'브라우저에서 이 사이트의 알림이 차단되어 있습니다. 주소창 왼쪽 자물쇠(ⓘ) 아이콘 → 알림을 “허용”으로 바꾼 뒤 다시 시도해 주세요.',
{ title: 'PC 알림 차단됨', variant: 'danger' },
)
} else if (result === 'unsupported') {
void dialog.alert(
'이 브라우저(또는 보안 컨텍스트)에서는 PC 알림을 지원하지 않습니다. localhost 또는 https 환경의 최신 Chrome/Edge 에서 이용해 주세요.',
{ title: 'PC 알림 미지원', variant: 'danger' },
)
} else if (result === 'no-key') {
void dialog.alert(
'서버에 푸시 키가 설정되어 있지 않습니다. 관리자에게 문의해 주세요.',
{ title: 'PC 알림', variant: 'danger' },
)
} else if (result === 'error') {
void dialog.alert(
'PC 알림 설정에 실패했습니다. 잠시 후 다시 시도해 주세요.',
{ title: 'PC 알림', variant: 'danger' },
)
}
// 'dismissed'(권한 팝업 닫음)는 조용히 무시
}
// 화면 전환 시 모바일 드로어 닫기
watch(
() => route.path,
@@ -70,11 +111,16 @@ async function onNotiClick(v: NotificationView) {
// AppShell 은 인증 이후 화면에서만 마운트되므로 여기서 스트림 연결(멱등)
// (store 싱글톤이 연결을 보유 → 화면 전환에도 끊기지 않음. 해제는 로그아웃 시에만)
onMounted(() => {
if (authStore.isLoggedIn) void notiStore.connect()
if (authStore.isLoggedIn) {
void notiStore.connect()
// 이미 권한을 허용한 사용자면 조용히 재구독(권한 팝업 없이) — 구독 최신화
void enablePush(true)
}
})
// 로그아웃 → 알림 스트림 해제 후 로그인 화면으로
// 로그아웃 → 푸시 구독·알림 스트림 해제 후 로그인 화면으로
async function onLogout() {
await disablePush()
notiStore.disconnect()
await authStore.logout()
await router.push('/login')
@@ -392,14 +438,36 @@ async function onLogout() {
>
<div class="noti-head">
<span class="noti-title">알림</span>
<button
v-if="unreadCount > 0"
type="button"
class="noti-readall"
@click="notiStore.markAllRead()"
>
모두 읽음
</button>
<span class="noti-head-actions">
<button
v-if="canEnablePush"
type="button"
class="noti-pushbtn"
title="이 브라우저에서 PC 알림 받기"
@click="onEnablePush"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
PC 알림 켜기
</button>
<button
v-if="unreadCount > 0"
type="button"
class="noti-readall"
@click="notiStore.markAllRead()"
>
모두 읽음
</button>
</span>
</div>
<div class="noti-list">
<div
@@ -795,6 +863,11 @@ async function onLogout() {
font-weight: 700;
color: var(--text);
}
.noti-head-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.noti-readall {
border: none;
background: transparent;
@@ -809,6 +882,29 @@ async function onLogout() {
.noti-readall:hover {
background: var(--accent-weak);
}
/* PC 알림 켜기 — 권한 허용 전에만 노출 */
.noti-pushbtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: none;
background: transparent;
color: var(--text-2);
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
padding: 0.125rem 0.3125rem;
border-radius: var(--radius-sm);
}
.noti-pushbtn svg {
width: 0.8125rem;
height: 0.8125rem;
}
.noti-pushbtn:hover {
background: var(--accent-weak);
color: var(--accent);
}
.noti-list {
max-height: 24rem;
overflow-y: auto;
+9
View File
@@ -13,3 +13,12 @@ app.use(createPinia())
app.use(router)
app.mount('#app')
// 웹 푸시용 서비스워커 등록 — 푸시 수신/클릭 처리(Level2). 실패해도 앱에 영향 없음.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('/sw.js').catch(() => {
// 등록 실패(미지원/비보안 컨텍스트) 무시 — 인앱/SSE 알림은 정상 동작
})
})
}
+46 -1
View File
@@ -1,10 +1,45 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import router from '@/router'
import { useNotification } from '@/composables/useNotification'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import { escapeHtml, relativeTimeKo, taskDueInfo } from '@/shared/utils/format'
import type { ApiNotification, NotificationView } from '@/types/notification'
// Level1 — 페이지가 열려 있을 때(특히 백그라운드 탭/최소화) OS 토스트.
// 포커스 상태면 인앱 종 알림만(active 사용자 방해 금지). 닫혀 있으면 서비스워커 푸시가 담당.
function showLocalNotification(view: NotificationView): void {
if (typeof Notification === 'undefined') return
if (Notification.permission !== 'granted') return
if (typeof document !== 'undefined' && document.visibilityState === 'visible')
return
try {
const notif = new Notification('Relay', {
body: view.text,
tag: view.id, // 동일 알림 중복 토스트 방지
icon: '/relay-icon-256.png',
})
notif.onclick = () => {
window.focus()
if (view.to) void router.push(view.to)
notif.close()
}
} catch {
// 알림 생성 실패(권한/환경) 무시
}
}
// 조립된 html → 태그/엔티티 제거 평문(OS 토스트 본문용)
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}
// payload(자유형)에서 문자열/숫자 값 안전 추출
function pstr(p: Record<string, unknown>, k: string): string {
const v = p[k]
@@ -113,7 +148,15 @@ function toView(n: ApiNotification): NotificationView {
html = `<b>${actor}</b>님이 참석 일정 <b>${eventTitle}</b>을(를) 삭제했습니다`
break
}
return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to }
return {
id: n.id,
read: n.read,
tone,
html,
text: stripHtml(html),
time: relativeTimeKo(n.createdAt),
to,
}
}
// SSE 재연결 지연(ms) — 토큰 만료/네트워크 끊김 시 정리 후 재시도
@@ -151,6 +194,8 @@ export const useNotificationStore = defineStore('notification', () => {
items.value.unshift(n)
total.value += 1
if (!n.read) unreadCount.value += 1
// OS 토스트(Level1) — 백그라운드/최소화 상태일 때만
showLocalNotification(toView(n))
} catch {
// 파싱 실패(하트비트 등 비정상 페이로드) 무시
}
+1
View File
@@ -46,6 +46,7 @@ export interface NotificationView {
read: boolean
tone: string // 좌측 점 색상(blue/amber/green/red/accent/'')
html: string // 조립된 문구(사용자 입력은 escapeHtml 적용)
text: string // 태그 없는 평문(OS 토스트 본문용)
time: string // 상대시간
to: string | null // 클릭 시 이동 경로
}