Files
sam-manage/public/js/fcm.js
hskwon f5ec9d502c feat: [fcm] Admin FCM API 추가 - MNG에서 API 호출로 FCM 발송
- AdminFcmController, AdminFcmService 추가
- FormRequest 검증 (AdminFcmSendRequest 등)
- Swagger 문서 추가 (AdminFcmApi.php)
- ApiKeyMiddleware: admin/fcm/* 화이트리스트 추가
- FCM 에러 메시지 i18n 추가
2025-12-23 12:43:36 +09:00

315 lines
10 KiB
JavaScript

/**
* FCM (Firebase Cloud Messaging) Push Notification Handler
* Capacitor 앱에서만 동작, 웹 브라우저에서는 무시됨
*
* Payload data 스키마:
* - type: 알림 타입 (invoice_failed, order_completed 등)
* - url: 클릭 시 이동 URL
* - sound_key: 사운드 파일 키 (sounds/{sound_key}.wav)
*/
(function () {
'use strict';
console.log('[FCM] fcm.js LOADED v2');
const CONFIG = {
apiBaseUrl: window.SAM_CONFIG?.apiBaseUrl || 'https://api.codebridge-x.com',
fcmTokenKey: 'fcm_token',
apiTokenKey: 'api_access_token',
apiKeyHeader: window.SAM_CONFIG?.apiKey || '',
soundBasePath: '/sounds/',
defaultSound: 'default',
};
// 앱 상태 (포그라운드 여부)
let isAppForeground = true;
// DOM 준비되면 실행 (이미 로드됐으면 즉시)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}
async function bootstrap() {
// Capacitor 네이티브 환경(ios, android)에서만 실행
const platform = window.Capacitor?.getPlatform?.();
if (platform !== 'ios' && platform !== 'android') {
console.log('[FCM] Not running in native app (platform:', platform || 'web', ')');
return;
}
if (!window.Capacitor?.Plugins?.PushNotifications) {
console.log('[FCM] PushNotifications plugin not available');
return;
}
// 앱 상태 리스너 (포그라운드/백그라운드)
if (window.Capacitor?.Plugins?.App) {
const { App } = Capacitor.Plugins;
App.addListener('appStateChange', ({ isActive }) => {
isAppForeground = isActive;
console.log('[FCM] App state:', isActive ? 'foreground' : 'background');
});
}
await initializeFCM();
}
async function initializeFCM() {
const { PushNotifications } = Capacitor.Plugins;
try {
// ✅ 1. 기존 리스너 제거
PushNotifications.removeAllListeners();
// ✅ 2. 리스너를 먼저 등록 (가장 중요)
PushNotifications.addListener('registration', async (token) => {
console.log('[FCM] 🔥 registration event fired');
console.log('[FCM] Token received:', token.value?.substring(0, 20) + '...');
await handleTokenRegistration(token.value);
});
PushNotifications.addListener('registrationError', (err) => {
console.error('[FCM] Registration error:', err);
});
PushNotifications.addListener('pushNotificationReceived', (notification) => {
console.log('[FCM] Push received (foreground):', notification);
handleForegroundNotification(notification);
});
PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
console.log('[FCM] Push action performed:', action);
const data = action.notification?.data;
if (data?.url) {
window.location.href = data.url;
}
});
// ✅ 3. 그 다음에 권한 요청
const perm = await PushNotifications.requestPermissions();
console.log('[FCM] Push permission:', perm.receive);
if (perm.receive !== 'granted') {
console.log('[FCM] Push permission not granted');
return;
}
// ✅ 4. 마지막에 register 호출
await PushNotifications.register();
} catch (error) {
console.error('[FCM] Initialization error:', error);
}
}
async function handleTokenRegistration(newToken) {
const oldToken = sessionStorage.getItem(CONFIG.fcmTokenKey);
if (oldToken === newToken) {
console.log('[FCM] Token unchanged, skip');
return;
}
const success = await registerTokenToServer(newToken);
if (success) {
sessionStorage.setItem(CONFIG.fcmTokenKey, newToken);
console.log('[FCM] Token saved to sessionStorage');
}
}
async function registerTokenToServer(token) {
const accessToken = sessionStorage.getItem(CONFIG.apiTokenKey);
if (!accessToken) {
console.warn('[FCM] No API access token');
return false;
}
try {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
};
if (CONFIG.apiKeyHeader) {
headers['X-API-KEY'] = CONFIG.apiKeyHeader;
}
const response = await fetch(`${CONFIG.apiBaseUrl}/api/v1/push/register-token`, {
method: 'POST',
headers,
body: JSON.stringify({
token,
platform: getDevicePlatform(),
device_name: getDeviceName(),
app_version: getAppVersion(),
}),
});
if (response.ok) {
console.log('[FCM] Token registered successfully');
return true;
}
console.error('[FCM] Token registration failed:', response.status);
return false;
} catch (error) {
console.error('[FCM] Failed to send token:', error);
return false;
}
}
async function unregisterToken() {
const token = sessionStorage.getItem(CONFIG.fcmTokenKey);
const accessToken = sessionStorage.getItem(CONFIG.apiTokenKey);
if (!token) return true;
try {
if (accessToken) {
await fetch(`${CONFIG.apiBaseUrl}/api/v1/push/unregister-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ token }),
});
}
} catch (e) {
console.warn('[FCM] Unregister failed, clearing local token');
}
sessionStorage.removeItem(CONFIG.fcmTokenKey);
return true;
}
function getDevicePlatform() {
if (window.Capacitor) {
const p = Capacitor.getPlatform();
if (p === 'ios' || p === 'android') return p;
}
return 'web';
}
function getDeviceName() {
return navigator.userAgent?.substring(0, 100) || null;
}
function getAppVersion() {
return window.SAM_CONFIG?.appVersion || null;
}
/**
* 포그라운드 알림 처리
*/
function handleForegroundNotification(notification) {
const data = notification.data || {};
const type = data.type || 'default';
const url = data.url;
const soundKey = data.sound_key;
console.log('[FCM] Notification data:', { type, url, soundKey });
// 1. 포그라운드에서만 사운드 재생 (백그라운드는 OS 채널 사운드)
if (isAppForeground && soundKey) {
playNotificationSound(soundKey);
}
// 2. 타입별 토스트 메시지 표시
const title = notification.title || '알림';
const body = notification.body || '';
const toastType = getToastTypeByNotificationType(type);
if (typeof showToast === 'function') {
showToast(`${title}: ${body}`, toastType);
}
// 3. 클릭 가능한 토스트 (URL이 있는 경우)
if (url && typeof showClickableToast === 'function') {
showClickableToast(title, body, () => {
window.location.href = url;
});
}
}
/**
* 알림 사운드 재생 (포그라운드 전용)
*/
function playNotificationSound(soundKey) {
try {
const soundPath = `${CONFIG.soundBasePath}${soundKey}.wav`;
const audio = new Audio(soundPath);
audio.volume = 0.5;
audio.play().catch(err => {
console.warn('[FCM] Sound play failed, trying default:', err.message);
// 기본 사운드 시도
if (soundKey !== CONFIG.defaultSound) {
const defaultAudio = new Audio(`${CONFIG.soundBasePath}${CONFIG.defaultSound}.wav`);
defaultAudio.volume = 0.5;
defaultAudio.play().catch(() => {});
}
});
} catch (err) {
console.warn('[FCM] Sound error:', err);
}
}
/**
* 알림 타입별 토스트 스타일 결정
*/
function getToastTypeByNotificationType(type) {
const typeMap = {
// 긴급/에러
'invoice_failed': 'error',
'payment_failed': 'error',
'order_cancelled': 'error',
// 경고
'approval_required': 'warning',
'stock_low': 'warning',
// 성공
'order_completed': 'success',
'payment_completed': 'success',
'approval_approved': 'success',
// 기본
'default': 'info',
};
return typeMap[type] || 'info';
}
/**
* 클릭 가능한 토스트 (URL 이동용)
* showClickableToast가 없으면 기본 토스트 사용
*/
if (typeof window.showClickableToast === 'undefined') {
window.showClickableToast = function(title, body, onClick) {
// 기본 구현: 일반 토스트 + 클릭 핸들러는 무시
if (typeof showToast === 'function') {
showToast(`${title}: ${body}`, 'info');
}
console.log('[FCM] showClickableToast not implemented, URL click ignored');
};
}
window.FCM = {
unregisterToken,
reinitialize: initializeFCM,
// 디버그/테스트용
playSound: playNotificationSound,
testForeground: (data) => handleForegroundNotification({ title: 'Test', body: 'Test notification', data }),
};
})();