From 0539c463d049c07b2f86bebd958f4cbb5cf36c27 Mon Sep 17 00:00:00 2001 From: choihooo Date: Mon, 16 Mar 2026 17:40:27 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix(web):=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ν•˜λ‹¨ πŸ’‘ μ•Œλ¦Ό μ’…λ₯˜ μ•ˆλ‚΄ λ°•μŠ€λ₯Ό μ œκ±°ν•˜μ—¬ UI κ°„μ†Œν™” Co-Authored-By: Claude Sonnet 4.6 --- .../app/(user)/profile/notifications/page.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 packages/web/src/app/(user)/profile/notifications/page.tsx diff --git a/packages/web/src/app/(user)/profile/notifications/page.tsx b/packages/web/src/app/(user)/profile/notifications/page.tsx new file mode 100644 index 0000000..b9981f9 --- /dev/null +++ b/packages/web/src/app/(user)/profile/notifications/page.tsx @@ -0,0 +1,19 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { PushNotificationSettings } from '@/components/settings/push-notification-settings'; + +export default function ProfileNotificationsPage() { + return ( +
+

μ•Œλ¦Ό μ„€μ •

+ + + + ν‘Έμ‹œ μ•Œλ¦Ό + + + + + +
+ ); +} From 6982fdb715f5ac8ad71c2f0a2b74d2033ac9ed18 Mon Sep 17 00:00:00 2001 From: choihooo Date: Mon, 16 Mar 2026 17:40:27 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore(web):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20import=20=EC=A0=95=EB=A6=AC=20(=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20Megaphone=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Megaphone importκ°€ λΆˆν•„μš”ν•˜κ²Œ μΆ”κ°€λ˜μ–΄ 제거 Co-Authored-By: Claude Sonnet 4.6 --- .../settings/push-notification-settings.tsx | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 packages/web/src/components/settings/push-notification-settings.tsx diff --git a/packages/web/src/components/settings/push-notification-settings.tsx b/packages/web/src/components/settings/push-notification-settings.tsx new file mode 100644 index 0000000..5d12884 --- /dev/null +++ b/packages/web/src/components/settings/push-notification-settings.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Bell, MessageCircle, MessageSquare, Megaphone } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { toast } from 'sonner'; +import { usePushNotification } from '@/hooks/use-push-notification'; + +interface NotificationPreference { + type: string; + enabled: boolean; +} + +const NOTIFICATION_LABELS: Record = { + board_comment: { label: 'κ²Œμ‹œνŒ λŒ“κΈ€', icon: MessageSquare, description: 'λ‚΄ κ²Œμ‹œκΈ€μ— λŒ“κΈ€μ΄ 달릴 λ•Œ' }, + board_reply: { label: 'κ²Œμ‹œνŒ λ‹΅κΈ€', icon: MessageCircle, description: 'λ‚΄ λŒ“κΈ€μ— 닡글이 달릴 λ•Œ' }, + post_comment: { label: '포슀트 λŒ“κΈ€', icon: MessageSquare, description: 'λ‚΄ ν¬μŠ€νŠΈμ— λŒ“κΈ€μ΄ 달릴 λ•Œ' }, + post_reply: { label: '포슀트 λ‹΅κΈ€', icon: MessageCircle, description: 'λ‚΄ λŒ“κΈ€μ— 닡글이 달릴 λ•Œ' }, + board_notice: { label: '곡지사항', icon: Megaphone, description: 'μƒˆ 곡지사항이 κ²Œμ‹œλ  λ•Œ' }, +}; + +export function PushNotificationSettings() { + const { permission, token, requestPermission, unsubscribe, isSupported } = + usePushNotification(); + const [preferences, setPreferences] = useState([]); + const [loading, setLoading] = useState(true); + + // μ•Œλ¦Ό μ„€μ • 뢈러였기 + useEffect(() => { + if (permission !== 'granted' || !token) { + setLoading(false); + return; + } + + fetch('/api/notification-preferences') + .then((res) => res.json()) + .then((data) => { + if (data.success) { + setPreferences(data.data); + } + }) + .catch(() => { + toast.error('μ•Œλ¦Ό 섀정을 λΆˆλŸ¬μ˜€λŠ”λ° μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); + }) + .finally(() => { + setLoading(false); + }); + }, [permission, token]); + + // μ•Œλ¦Ό ν† κΈ€ + const handleToggle = async (type: string, enabled: boolean) => { + const prev = preferences; + setPreferences((prev) => prev.map((p) => (p.type === type ? { ...p, enabled } : p))); + + try { + const res = await fetch('/api/notification-preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, enabled }), + }); + + const data = await res.json(); + if (!data.success) { + throw new Error(data.message || 'μ„€μ • μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); + } + + toast.success(enabled ? 'μ•Œλ¦Όμ΄ μΌœμ‘ŒμŠ΅λ‹ˆλ‹€.' : 'μ•Œλ¦Όμ΄ κΊΌμ‘ŒμŠ΅λ‹ˆλ‹€.'); + } catch (err) { + setPreferences(prev); // λ‘€λ°± + toast.error('μ„€μ • μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.'); + } + }; + + if (!isSupported) { + return ( +
+ 이 λΈŒλΌμš°μ €λŠ” μ•Œλ¦Όμ„ μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. +
+ ); + } + + const isPushEnabled = permission === 'granted' && token; + + return ( +
+ {/* ν‘Έμ‹œ μ•Œλ¦Ό 켜기/끄기 */} +
+
+
ν‘Έμ‹œ μ•Œλ¦Ό
+
+ {isPushEnabled + ? 'λΈŒλΌμš°μ € μ•Œλ¦Όμ΄ ν™œμ„±ν™”λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.' + : 'μ€‘μš”ν•œ μ•Œλ¦Όμ„ μ‹€μ‹œκ°„μœΌλ‘œ λ°›μ•„λ³΄μ„Έμš”.'} +
+
+ +
+ + {/* μ•Œλ¦Ό νƒ€μž…λ³„ μ„€μ • */} + {isPushEnabled && ( +
+

μ•Œλ¦Ό μ’…λ₯˜

+ + {loading ? ( +
λ‘œλ”© 쀑...
+ ) : ( +
+ {preferences.map((pref) => { + const { label, icon: Icon, description } = NOTIFICATION_LABELS[pref.type] || { + label: pref.type, + icon: Bell, + description: '', + }; + + return ( +
+
+
+ +
+
+
{label}
+
{description}
+
+
+ handleToggle(pref.type, checked)} + /> +
+ ); + })} +
+ )} +
+ )} +
+ ); +} From 413c79bfa949b7db648ecdb600e9ee26b4e845d4 Mon Sep 17 00:00:00 2001 From: choihooo Date: Mon, 16 Mar 2026 17:44:48 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(web):=20PWA=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=A6=B0=ED=8A=B8=20=EB=B0=8F=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B2=B4=ED=81=AC=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - any νƒ€μž… 제거: IconComponent, Firebase payload, response νƒ€μž… μ •μ˜ - useEffect λ‚΄ setState 제거: μ΄ˆκΈ°κ°’μœΌλ‘œ λŒ€μ²΄ν•˜μ—¬ React κ²½κ³  ν•΄κ²° - λ―Έμ‚¬μš© import 제거: ServiceWorkerRegistration - λ―Έμ‚¬μš© λ³€μˆ˜ 제거: poid - μ˜΅μ…”λ„ 체이닝 적용: newComment?.id Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/api/board/[id]/comments/route.ts | 41 +++- .../src/app/api/posts/[id]/comments/route.ts | 35 ++- .../settings/push-notification-settings.tsx | 4 +- .../web/src/hooks/use-push-notification.ts | 86 ++++++++ packages/web/src/lib/firebase/client.ts | 72 ++++++ packages/web/src/lib/push.ts | 206 ++++++++++++++++++ 6 files changed, 437 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/hooks/use-push-notification.ts create mode 100644 packages/web/src/lib/firebase/client.ts create mode 100644 packages/web/src/lib/push.ts diff --git a/packages/web/src/app/api/board/[id]/comments/route.ts b/packages/web/src/app/api/board/[id]/comments/route.ts index 94227c5..aa2e323 100644 --- a/packages/web/src/app/api/board/[id]/comments/route.ts +++ b/packages/web/src/app/api/board/[id]/comments/route.ts @@ -6,6 +6,7 @@ import { getBoardAuth } from '@/lib/board-auth'; import { errorResponse, Errors, successResponse } from '@/lib/api-error'; import { grantWebScore } from '@/lib/score'; import { sanitizeDescription } from '@/lib/sanitize'; +import { sendPushToMember } from '@/lib/push'; const { boardPosts, boardComments, ActivityScoreType } = sharedDb; @@ -35,8 +36,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } // Validate parentId + secret comment reply restrictions + let parent: { id: string; memberId: string; isSecret: boolean } | null = null; if (parentId) { - const [parent] = await database + const [parentData] = await database .select({ id: boardComments.id, memberId: boardComments.memberId, @@ -46,10 +48,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ .where(and(eq(boardComments.id, parentId), eq(boardComments.postId, postId))) .limit(1); - if (!parent) return Errors.badRequest('μƒμœ„ λŒ“κΈ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.').toResponse(); + if (!parentData) return Errors.badRequest('μƒμœ„ λŒ“κΈ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.').toResponse(); + parent = parentData as { id: string; memberId: string; isSecret: boolean }; // λΉ„λ°€λŒ“κΈ€ λ‹΅κΈ€: λŒ“κΈ€ μž‘μ„±μž/κΈ€ μž‘μ„±μž/κ΄€λ¦¬μžλ§Œ κ°€λŠ₯ - if (parent.isSecret) { + if (parent && parent.isSecret) { const isCommentOwner = parent.memberId === auth.memberId; const isPostAuthor = post.memberId === auth.memberId; if (!isCommentOwner && !isPostAuthor && !auth.isAdmin) { @@ -73,6 +76,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }) .returning(); + if (!newComment) { + return Errors.externalServiceError('λŒ“κΈ€ 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.').toResponse(); + } + // Increment comment_count await database .update(boardPosts) @@ -88,6 +95,34 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ).catch((err) => console.error('[score] grantWebScore failed:', err)); } + // 1. λ‚΄κ°€ μ“΄ 글에 λŒ“κΈ€μ΄ 달리면 무쑰건 μ•Œλ¦Ό (본인 μ œμ™Έ) + if (post.memberId !== auth.memberId) { + const postIdentifier = postId; + sendPushToMember(post.memberId, { + title: 'μƒˆ λŒ“κΈ€μ΄ λ‹¬λ ΈμŠ΅λ‹ˆλ‹€', + body: `${content.trim().slice(0, 50)}${content.length > 50 ? '...' : ''}`, + clickUrl: `/board/${postIdentifier}`, + data: { type: 'board_comment', postId: postIdentifier, commentId: newComment.id }, + }).catch((err) => console.error('[push] Comment notification failed:', err)); + } + + // 2. λŒ€λŒ“κΈ€μ˜ 경우 μ›λŒ“κΈ€ μž‘μ„±μžμ—κ²Œλ„ μ•Œλ¦Ό + if (parentId && parent) { + const pmid = parent.memberId; + const amid = auth.memberId; + + // λ‚΄ λŒ“κΈ€μ— 닡글이 달리면 μ•Œλ¦Ό (μž‘μ„±μž 본인 μ œμ™Έ) + if (pmid !== amid) { + const postIdentifier = postId; + sendPushToMember(pmid, { + title: 'πŸ’¬ 닡글이 λ‹¬λ ΈμŠ΅λ‹ˆλ‹€', + body: `${content.trim().slice(0, 50)}${content.length > 50 ? '...' : ''}`, + clickUrl: `/board/${postIdentifier}`, + data: { type: 'board_reply', postId: postIdentifier, commentId: newComment.id }, + }).catch((err) => console.error('[push] Reply notification failed:', err)); + } + } + return successResponse(newComment, 'λŒ“κΈ€μ΄ μž‘μ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 201); } catch (error) { return errorResponse(error); diff --git a/packages/web/src/app/api/posts/[id]/comments/route.ts b/packages/web/src/app/api/posts/[id]/comments/route.ts index fa5d1d4..b29e3ff 100644 --- a/packages/web/src/app/api/posts/[id]/comments/route.ts +++ b/packages/web/src/app/api/posts/[id]/comments/route.ts @@ -7,6 +7,7 @@ import { getAdminDiscordIds } from '@/lib/admin'; import { errorResponse, Errors, successResponse } from '@/lib/api-error'; import { grantWebScore } from '@/lib/score'; import { sanitizeDescription } from '@/lib/sanitize'; +import { sendPushToMember } from '@/lib/push'; const { posts, postComments, members, ActivityScoreType } = sharedDb; @@ -108,9 +109,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } // parentId μœ νš¨μ„± 검증 + let parent: { id: string; memberId: string } | null = null; if (parentId) { - const [parent] = await database - .select({ id: postComments.id }) + const [parentData] = await database + .select({ id: postComments.id, memberId: postComments.memberId }) .from(postComments) .where( and( @@ -120,7 +122,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) ) .limit(1); - if (!parent) return Errors.badRequest('μƒμœ„ λŒ“κΈ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.').toResponse(); + if (!parentData) return Errors.badRequest('μƒμœ„ λŒ“κΈ€μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.').toResponse(); + parent = parentData; } const [newComment] = await database @@ -163,6 +166,32 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } + // 1. λ‚΄κ°€ μ“΄ ν¬μŠ€νŠΈμ— λŒ“κΈ€μ΄ 달리면 무쑰건 μ•Œλ¦Ό (본인 μ œμ™Έ) + if (post.memberId !== auth.memberId) { + sendPushToMember(post.memberId, { + title: 'μƒˆ λŒ“κΈ€μ΄ λ‹¬λ ΈμŠ΅λ‹ˆλ‹€', + body: `${content.trim().slice(0, 50)}${content.length > 50 ? '...' : ''}`, + clickUrl: `/posts/${postId}`, + data: { type: 'post_comment', postId, commentId: newComment?.id ?? '' }, + }).catch((err) => console.error('[push] Post comment notification failed:', err)); + } + + // 2. λŒ€λŒ“κΈ€μ˜ 경우 μ›λŒ“κΈ€ μž‘μ„±μžμ—κ²Œλ„ μ•Œλ¦Ό + if (parentId && parent) { + const pmid = parent.memberId; + const amid = auth.memberId; + + // λ‚΄ λŒ“κΈ€μ— 닡글이 달리면 μ•Œλ¦Ό (μž‘μ„±μž 본인 μ œμ™Έ) + if (pmid !== amid) { + sendPushToMember(pmid, { + title: 'πŸ’¬ 닡글이 λ‹¬λ ΈμŠ΅λ‹ˆλ‹€', + body: `${content.trim().slice(0, 50)}${content.length > 50 ? '...' : ''}`, + clickUrl: `/posts/${postId}`, + data: { type: 'post_reply', postId, commentId: newComment?.id ?? '' }, + }).catch((err) => console.error('[push] Post reply notification failed:', err)); + } + } + return successResponse(newComment, 'λŒ“κΈ€μ΄ μž‘μ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 201); } catch (error) { return errorResponse(error); diff --git a/packages/web/src/components/settings/push-notification-settings.tsx b/packages/web/src/components/settings/push-notification-settings.tsx index 5d12884..257ffb4 100644 --- a/packages/web/src/components/settings/push-notification-settings.tsx +++ b/packages/web/src/components/settings/push-notification-settings.tsx @@ -12,7 +12,9 @@ interface NotificationPreference { enabled: boolean; } -const NOTIFICATION_LABELS: Record = { +type IconComponent = React.ComponentType<{ className?: string }>; + +const NOTIFICATION_LABELS: Record = { board_comment: { label: 'κ²Œμ‹œνŒ λŒ“κΈ€', icon: MessageSquare, description: 'λ‚΄ κ²Œμ‹œκΈ€μ— λŒ“κΈ€μ΄ 달릴 λ•Œ' }, board_reply: { label: 'κ²Œμ‹œνŒ λ‹΅κΈ€', icon: MessageCircle, description: 'λ‚΄ λŒ“κΈ€μ— 닡글이 달릴 λ•Œ' }, post_comment: { label: '포슀트 λŒ“κΈ€', icon: MessageSquare, description: 'λ‚΄ ν¬μŠ€νŠΈμ— λŒ“κΈ€μ΄ 달릴 λ•Œ' }, diff --git a/packages/web/src/hooks/use-push-notification.ts b/packages/web/src/hooks/use-push-notification.ts new file mode 100644 index 0000000..cb549c2 --- /dev/null +++ b/packages/web/src/hooks/use-push-notification.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { requestFCMToken, onForegroundMessage } from '@/lib/firebase/client'; + +export function usePushNotification() { + const [permission, setPermission] = useState( + typeof window !== 'undefined' && 'Notification' in window ? Notification.permission : 'default' + ); + const [token, setToken] = useState(null); + + useEffect(() => { + if ('Notification' in window) { + // ν¬κ·ΈλΌμš΄λ“œ λ©”μ‹œμ§€ λ¦¬μŠ€λ„ˆ + const unsubscribe = onForegroundMessage((payload) => { + toast(payload.notification?.title || 'μ•Œλ¦Ό', { + description: payload.notification?.body, + }); + }); + + return () => unsubscribe(); + } + }, []); + + const requestPermission = async () => { + if (!('Notification' in window)) { + toast.error('이 λΈŒλΌμš°μ €λŠ” μ•Œλ¦Όμ„ μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.'); + return false; + } + + const result = await Notification.requestPermission(); + setPermission(result); + + if (result === 'granted') { + const fcmToken = await requestFCMToken(); + if (fcmToken) { + setToken(fcmToken); + await subscribeToPush(fcmToken); + toast.success('μ•Œλ¦Όμ΄ ν™œμ„±ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + return true; + } + } + + if (result === 'denied') { + toast.error('μ•Œλ¦Όμ΄ μ°¨λ‹¨λ˜μ—ˆμŠ΅λ‹ˆλ‹€. λΈŒλΌμš°μ € μ„€μ •μ—μ„œ λ³€κ²½ν•΄μ£Όμ„Έμš”.'); + } + + return false; + }; + + const subscribeToPush = async (fcmToken: string) => { + try { + await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: fcmToken, + deviceInfo: navigator.userAgent, + }), + }); + } catch (error) { + console.error('Push subscription failed:', error); + toast.error('μ•Œλ¦Ό ꡬ독에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); + } + }; + + const unsubscribe = async () => { + if (token) { + await fetch('/api/push/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + setToken(null); + } + }; + + return { + permission, + token, + requestPermission, + unsubscribe, + isSupported: 'Notification' in window, + }; +} diff --git a/packages/web/src/lib/firebase/client.ts b/packages/web/src/lib/firebase/client.ts new file mode 100644 index 0000000..9bba3b6 --- /dev/null +++ b/packages/web/src/lib/firebase/client.ts @@ -0,0 +1,72 @@ +import { initializeApp, getApps } from 'firebase/app'; +import { getMessaging, getToken, onMessage } from 'firebase/messaging'; + +const firebaseConfig = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, +}; + +// 앱이 쀑볡 μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šλ„λ‘ 체크 +const app = !getApps().length ? initializeApp(firebaseConfig) : getApps()[0]; +export const messaging = getMessaging(app); + +/** + * μ„œλΉ„μŠ€ μ›Œμ»€ 등둝 + */ +async function registerServiceWorker() { + if (typeof window !== 'undefined' && 'serviceWorker' in navigator) { + try { + const registration = await navigator.serviceWorker.register( + '/firebase-messaging-sw.js', + { type: 'classic' } + ); + console.log('[FCM] Service Worker registered:', registration); + return registration; + } catch (error) { + console.error('[FCM] Service Worker registration failed:', error); + return null; + } + } + return null; +} + +/** + * FCM 토큰 μš”μ²­ + * @returns FCM 등둝 토큰 λ˜λŠ” null + */ +export async function requestFCMToken(): Promise { + try { + // μ„œλΉ„μŠ€ μ›Œμ»€ 등둝 + const registration = await registerServiceWorker(); + + if (!registration) { + console.error('[FCM] Service Worker registration failed'); + return null; + } + + const token = await getToken(messaging, { + vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY, + serviceWorkerRegistration: registration, + }); + console.log('[FCM] Token obtained successfully'); + return token; + } catch (error) { + console.error('[FCM] Token request failed:', error); + return null; + } +} + +/** + * ν¬κ·ΈλΌμš΄λ“œ λ©”μ‹œμ§€ μˆ˜μ‹  λ¦¬μŠ€λ„ˆ + * @param callback λ©”μ‹œμ§€ μˆ˜μ‹  μ‹œ μ‹€ν–‰ν•  콜백 ν•¨μˆ˜ + * @returns ꡬ독 μ·¨μ†Œ ν•¨μˆ˜ + */ +export function onForegroundMessage( + callback: (payload: { notification?: { title?: string; body?: string } }) => void +): () => void { + return onMessage(messaging, callback); +} diff --git a/packages/web/src/lib/push.ts b/packages/web/src/lib/push.ts new file mode 100644 index 0000000..fb9473f --- /dev/null +++ b/packages/web/src/lib/push.ts @@ -0,0 +1,206 @@ +import { eq, inArray, and } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { adminMessaging } from '@/lib/firebase/admin'; +import type { MulticastMessage } from 'firebase-admin/messaging'; + +const { fcmTokens, notificationPreferences } = sharedDb; + +export interface PushPayload { + title: string; + body: string; + icon?: string; + clickUrl?: string; + data?: Record; +} + +/** + * μ‚¬μš©μž μ•Œλ¦Ό μ„€μ • 확인 (κΈ°λ³Έκ°’: true) + */ +async function isNotificationEnabled(memberId: string, type: string): Promise { + try { + const database = getDb(); + const pref = await database + .select({ enabled: notificationPreferences.enabled }) + .from(notificationPreferences) + .where( + and( + eq(notificationPreferences.memberId, memberId), + eq(notificationPreferences.type, type) + ) + ) + .limit(1); + + return pref[0]?.enabled ?? true; + } catch { + return true; // μ—λŸ¬ μ‹œ κΈ°λ³Έκ°’ true둜 μ•Œλ¦Ό ν—ˆμš© + } +} + +/** + * νŠΉμ • λ©€λ²„μ—κ²Œ FCM ν‘Έμ‹œ μ•Œλ¦Ό 전솑 + */ +export async function sendPushToMember( + memberId: string, + payload: PushPayload +): Promise<{ success: number; failed: number }> { + const notificationType = payload.data?.type; + if (notificationType) { + const enabled = await isNotificationEnabled(memberId, notificationType); + if (!enabled) { + return { success: 0, failed: 0 }; // μ•Œλ¦Ό 끄면 전솑 μ•ˆ 함 + } + } + + const database = getDb(); + + const tokens = await database + .select({ token: fcmTokens.token }) + .from(fcmTokens) + .where(eq(fcmTokens.memberId, memberId)); + + if (tokens.length === 0) { + return { success: 0, failed: 0 }; + } + + const message: MulticastMessage = { + notification: { + title: payload.title, + body: payload.body, + }, + data: { + clickUrl: payload.clickUrl || '/dashboard', + ...payload.data, + }, + webpush: { + notification: { + icon: payload.icon || '/icon-192.png', + }, + fcmOptions: { + link: payload.clickUrl || '/dashboard', + }, + }, + tokens: tokens.map((t) => t.token), + }; + + try { + const response = await adminMessaging.sendEachForMulticast(message); + + // μ‹€νŒ¨ν•œ 토큰 μ‚­μ œ + if (response.failureCount > 0) { + const failedTokens: string[] = []; + response.responses.forEach((resp: { success: boolean }, idx: number) => { + if (!resp.success) { + failedTokens.push(tokens[idx]!.token); + } + }); + + if (failedTokens.length > 0) { + await database + .delete(fcmTokens) + .where(inArray(fcmTokens.token, failedTokens)); + } + } + + // λ§ˆμ§€λ§‰ μ‚¬μš© μ‹œκ°„ μ—…λ°μ΄νŠΈ + await database + .update(fcmTokens) + .set({ lastUsedAt: new Date() }) + .where(eq(fcmTokens.memberId, memberId)); + + return { + success: response.successCount, + failed: response.failureCount, + }; + } catch (error) { + console.error('[push] Failed to send:', error); + return { success: 0, failed: tokens.length }; + } +} + +/** + * μ—¬λŸ¬ λ©€λ²„μ—κ²Œ FCM ν‘Έμ‹œ μ•Œλ¦Ό 전솑 + */ +export async function sendPushToMembers( + memberIds: string[], + payload: PushPayload +): Promise<{ success: number; failed: number }> { + const database = getDb(); + + const tokens = await database + .select({ token: fcmTokens.token, memberId: fcmTokens.memberId }) + .from(fcmTokens) + .where(inArray(fcmTokens.memberId, memberIds)); + + if (tokens.length === 0) { + return { success: 0, failed: 0 }; + } + + // λ©€λ²„λ³„λ‘œ κ·Έλ£Ήν™”ν•˜μ—¬ 전솑 (FCM quota μ΅œμ ν™”) + const memberTokens = new Map(); + tokens.forEach((t) => { + if (!memberTokens.has(t.memberId)) { + memberTokens.set(t.memberId, []); + } + memberTokens.get(t.memberId)!.push(t.token); + }); + + let totalSuccess = 0; + let totalFailed = 0; + + // λ©€λ²„λ³„λ‘œ 전솑 (쀑볡 μ•Œλ¦Ό λ°©μ§€) + for (const [memberId, tokenList] of memberTokens) { + const message: MulticastMessage = { + notification: { + title: payload.title, + body: payload.body, + }, + data: { + clickUrl: payload.clickUrl || '/dashboard', + ...payload.data, + }, + webpush: { + notification: { + icon: payload.icon || '/icon-192.png', + }, + fcmOptions: { + link: payload.clickUrl || '/dashboard', + }, + }, + tokens: tokenList, + }; + + try { + const response = await adminMessaging.sendEachForMulticast(message); + totalSuccess += response.successCount; + totalFailed += response.failureCount; + + // μ‹€νŒ¨ν•œ 토큰 μ‚­μ œ + if (response.failureCount > 0) { + const failedTokens: string[] = []; + response.responses.forEach((resp: { success: boolean }, idx: number) => { + if (!resp.success) { + failedTokens.push(tokenList[idx]!); + } + }); + + if (failedTokens.length > 0) { + await database + .delete(fcmTokens) + .where(inArray(fcmTokens.token, failedTokens)); + } + } + + // λ§ˆμ§€λ§‰ μ‚¬μš© μ‹œκ°„ μ—…λ°μ΄νŠΈ + await database + .update(fcmTokens) + .set({ lastUsedAt: new Date() }) + .where(eq(fcmTokens.memberId, memberId)); + } catch (error) { + console.error(`[push] Failed to send to ${memberId}:`, error); + totalFailed += tokenList.length; + } + } + + return { success: totalSuccess, failed: totalFailed }; +} From 220c72f26d7583910afe9355536da40ad8b60593 Mon Sep 17 00:00:00 2001 From: choihooo Date: Mon, 16 Mar 2026 17:44:56 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(web):=20PWA=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firebase Cloud Messaging(FCM) 기반 μ›Ή ν‘Έμ‹œ μ•Œλ¦Ό κ΅¬ν˜„ [μ£Όμš” κΈ°λŠ₯] - λΈŒλΌμš°μ € μ•Œλ¦Ό κΆŒν•œ μš”μ²­ 및 FCM 토큰 λ°œκΈ‰ - μ•Œλ¦Ό μ’…λ₯˜λ³„ μ„€μ • (κ²Œμ‹œνŒ λŒ“κΈ€/λ‹΅κΈ€, 포슀트 λŒ“κΈ€/λ‹΅κΈ€, 곡지사항) - ν¬κ·ΈλΌμš΄λ“œ/λ°±κ·ΈλΌμš΄λ“œ λ©”μ‹œμ§€ μˆ˜μ‹  - DB에 토큰 μ €μž₯ 및 μ•Œλ¦Ό μ„ ν˜Έλ„ 관리 - λŒ“κΈ€/λ‹΅κΈ€ μž‘μ„± μ‹œ ν‘Έμ‹œ μ•Œλ¦Ό 전솑 [기술 μŠ€νƒ] - Firebase Admin SDK (μ„œλ²„) - Firebase Cloud Messaging (FCM) - Service Worker (λ°±κ·ΈλΌμš΄λ“œ λ©”μ‹œμ§€) - Supabase (notification_push_tokens, notification_preferences ν…Œμ΄λΈ”) [DB μŠ€ν‚€λ§ˆ] - notification_push_tokens: FCM 토큰 μ €μž₯ - notification_preferences: μ•Œλ¦Ό μ’…λ₯˜λ³„ μ„€μ • Co-Authored-By: Claude Sonnet 4.6 --- docs/26-03-16-notification-policy.md | 327 ++++ docs/plans/26-03-16-pwa-push-notification.md | 1021 ++++++++++ package.json | 3 + packages/shared/package.json | 1 + packages/shared/src/db/schema.ts | 73 + packages/web/next-env.d.ts | 2 +- packages/web/next.config.ts | 5 +- packages/web/package.json | 1 + packages/web/public/firebase-messaging-sw.js | 57 + packages/web/src/app/(user)/profile/page.tsx | 22 + packages/web/src/app/api/board/route.ts | 21 +- .../app/api/notification-preferences/route.ts | 78 + .../web/src/app/api/push/subscribe/route.ts | 41 + .../web/src/app/api/push/unsubscribe/route.ts | 36 + packages/web/src/components/ui/switch.tsx | 18 +- pnpm-lock.yaml | 1663 ++++++++++++++++- 16 files changed, 3352 insertions(+), 17 deletions(-) create mode 100644 docs/26-03-16-notification-policy.md create mode 100644 docs/plans/26-03-16-pwa-push-notification.md create mode 100644 packages/web/public/firebase-messaging-sw.js create mode 100644 packages/web/src/app/api/notification-preferences/route.ts create mode 100644 packages/web/src/app/api/push/subscribe/route.ts create mode 100644 packages/web/src/app/api/push/unsubscribe/route.ts diff --git a/docs/26-03-16-notification-policy.md b/docs/26-03-16-notification-policy.md new file mode 100644 index 0000000..45d8e42 --- /dev/null +++ b/docs/26-03-16-notification-policy.md @@ -0,0 +1,327 @@ +# μ•Œλ¦Ό μ •μ±… + +> **μ΅œμ’… μˆ˜μ •:** 2026-03-16 +> **버전:** 1.0.0 + +이 λ¬Έμ„œλŠ” νμŠ€νŒ… 4th μŠ€ν„°λ”” ν”Œλž«νΌμ—μ„œ μ œκ³΅ν•˜λŠ” ν‘Έμ‹œ μ•Œλ¦Όμ˜ μ •μ±…κ³Ό λ™μž‘ 방식을 μ„€λͺ…ν•©λ‹ˆλ‹€. + +--- + +## 1. μ•Œλ¦Ό κ°œμš” + +### 1.1 μ•Œλ¦Ό 제곡 방식 + +- **방식:** Firebase Cloud Messaging (FCM) 기반 μ›Ή ν‘Έμ‹œ μ•Œλ¦Ό +- **λŒ€μƒ:** PWA μ„€μΉ˜ 및 μ•Œλ¦Ό κΆŒν•œ ν—ˆμš© μœ μ € +- **λΉ„μš©:** μ™„μ „ 무료 (Google Firebase FCM ν™œμš©) +- **μ œν•œ:** μ—†μŒ (λ¬΄μ œν•œ 전솑 κ°€λŠ₯) + +### 1.2 μ•Œλ¦Ό μˆ˜μ‹  쑰건 + +1. **PWA μ„€μΉ˜:** λΈŒλΌμš°μ €μ— μ›Ή μ•± μ„€μΉ˜ +2. **μ•Œλ¦Ό κΆŒν•œ ν—ˆμš©:** λΈŒλΌμš°μ € μ•Œλ¦Ό κΆŒν•œ `granted` μƒνƒœ +3. **FCM 토큰 등둝:** Firebase Cloud Messaging 토큰 λ°œκΈ‰ μ™„λ£Œ + +### 1.3 μ•Œλ¦Ό μ„€μ • + +- **μ„€μ • νŽ˜μ΄μ§€:** `/profile/notifications` +- **κΈ°λŠ₯:** + - μ•Œλ¦Ό 켜기/끄기 (전체 ν† κΈ€) + - μ•Œλ¦Ό νƒ€μž…λ³„ κ°œλ³„ μ„€μ • (ν† κΈ€ μŠ€μœ„μΉ˜) + - κ²Œμ‹œνŒ λŒ“κΈ€ + - κ²Œμ‹œνŒ λ‹΅κΈ€ + - 포슀트 λŒ“κΈ€ + - 포슀트 λ‹΅κΈ€ + - 곡지사항 + - λΈŒλΌμš°μ € μ•Œλ¦Ό κΆŒν•œ 관리 + - FCM 토큰 ꡬ독/ꡬ독 μ·¨μ†Œ + +**κΈ°λ³Έκ°’:** λͺ¨λ“  μ•Œλ¦Ό νƒ€μž… κΈ°λ³Έ 켜짐 (`enabled: true`) + +--- + +## 2. μ•Œλ¦Ό μœ ν˜•λ³„ μ •μ±… + +### 2.1 κ²Œμ‹œνŒ (Board) + +#### 2.1.1 λŒ“κΈ€ μ•Œλ¦Ό + +| ν•­λͺ© | λ‚΄μš© | +|------|------| +| **트리거** | κ²Œμ‹œνŒ 글에 λŒ“κΈ€ μž‘μ„± μ‹œ | +| **μˆ˜μ‹ μž** | κ²Œμ‹œκΈ€ μž‘μ„±μž | +| **μ œμ™Έ** | λŒ“κΈ€ μž‘μ„±μž 본인 | +| **쀑볡** | λ§€ λŒ“κΈ€λ§ˆλ‹€ μ•Œλ¦Ό (λ¬΄μ œν•œ) | +| **타이틀** | "μƒˆ λŒ“κΈ€μ΄ λ‹¬λ ΈμŠ΅λ‹ˆλ‹€" | +| **λ³Έλ¬Έ** | λŒ“κΈ€ λ‚΄μš© 일뢀 (μ΅œλŒ€ 50자 + ...) | +| **클릭 λ™μž‘** | ν•΄λ‹Ή κ²Œμ‹œκΈ€λ‘œ 이동 | + +**μ •μ±…:** +- βœ… λ‚΄κ°€ μ“΄ 글에 남이 λŒ“κΈ€μ„ 달면 무쑰건 μ•Œλ¦Ό +- βœ… λ‹€λ₯Έ μ‚¬λžŒλ“€μ΄ λŒ“κΈ€μ„ 달아도 λͺ¨λ‘ μ•Œλ¦Ό +- ❌ λ‚΄κ°€ μ“΄ λŒ“κΈ€μ€ μ•Œλ¦Ό μ—†μŒ + +#### 2.1.2 λ‹΅κΈ€ μ•Œλ¦Ό + +| ν•­λͺ© | λ‚΄μš© | +|------|------| +| **트리거** | λŒ“κΈ€μ— λŒ€λŒ“κΈ€ μž‘μ„± μ‹œ | +| **μˆ˜μ‹ μž** | μ›λŒ“κΈ€ μž‘μ„±μž | +| **μ œμ™Έ** | λ‹΅κΈ€ μž‘μ„±μž 본인 | +| **타이틀** | "πŸ’¬ 닡글이 λ‹¬λ ΈμŠ΅λ‹ˆλ‹€" | +| **λ³Έλ¬Έ** | λ‹΅κΈ€ λ‚΄μš© 일뢀 (μ΅œλŒ€ 50자 + ...) | +| **클릭 λ™μž‘** | ν•΄λ‹Ή κ²Œμ‹œκΈ€λ‘œ 이동 | + +**μ •μ±…:** +- βœ… λ‚΄ λŒ“κΈ€μ— 닡글이 달리면 무쑰건 μ•Œλ¦Ό +- βœ… λ‚΄κ°€ μ“΄ 글에 λŒ€λŒ“κΈ€μ΄ 달렀도, μ›λŒ“κΈ€μ΄ λ‚΄ 것이라면 μ•Œλ¦Ό +- ❌ λ‚΄κ°€ μ“΄ 닡글은 μ•Œλ¦Ό μ—†μŒ + +--- + +### 2.2 포슀트 (Blog) + +#### 2.2.1 λŒ“κΈ€ μ•Œλ¦Ό + +| ν•­λͺ© | λ‚΄μš© | +|------|------| +| **트리거** | λΈ”λ‘œκ·Έ 글에 λŒ“κΈ€ μž‘μ„± μ‹œ | +| **μˆ˜μ‹ μž** | 포슀트 μž‘μ„±μž | +| **μ œμ™Έ** | λŒ“κΈ€ μž‘μ„±μž 본인 | +| **쀑볡** | λ§€ λŒ“κΈ€λ§ˆλ‹€ μ•Œλ¦Ό (λ¬΄μ œν•œ) | +| **타이틀** | "μƒˆ λŒ“κΈ€μ΄ λ‹¬λ ΈμŠ΅λ‹ˆλ‹€" | +| **λ³Έλ¬Έ** | λŒ“κΈ€ λ‚΄μš© 일뢀 (μ΅œλŒ€ 50자 + ...) | +| **클릭 λ™μž‘** | ν•΄λ‹Ή 포슀트둜 이동 | + +**μ •μ±…:** +- βœ… λ‚΄κ°€ μ“΄ ν¬μŠ€νŠΈμ— 남이 λŒ“κΈ€μ„ 달면 무쑰건 μ•Œλ¦Ό +- βœ… λ‹€λ₯Έ μ‚¬λžŒλ“€μ΄ λŒ“κΈ€μ„ 달아도 λͺ¨λ‘ μ•Œλ¦Ό +- ❌ λ‚΄κ°€ μ“΄ λŒ“κΈ€μ€ μ•Œλ¦Ό μ—†μŒ + +#### 2.2.2 λ‹΅κΈ€ μ•Œλ¦Ό + +| ν•­λͺ© | λ‚΄μš© | +|------|------| +| **트리거** | λŒ“κΈ€μ— λŒ€λŒ“κΈ€ μž‘μ„± μ‹œ | +| **μˆ˜μ‹ μž** | μ›λŒ“κΈ€ μž‘μ„±μž | +| **μ œμ™Έ** | λ‹΅κΈ€ μž‘μ„±μž 본인 | +| **타이틀** | "πŸ’¬ 닡글이 λ‹¬λ ΈμŠ΅λ‹ˆλ‹€" | +| **λ³Έλ¬Έ** | λ‹΅κΈ€ λ‚΄μš© 일뢀 (μ΅œλŒ€ 50자 + ...) | +| **클릭 λ™μž‘** | ν•΄λ‹Ή 포슀트둜 이동 | + +**μ •μ±…:** +- βœ… λ‚΄ λŒ“κΈ€μ— 닡글이 달리면 무쑰건 μ•Œλ¦Ό +- βœ… λ‚΄κ°€ μ“΄ 글에 λŒ€λŒ“κΈ€μ΄ 달렀도, μ›λŒ“κΈ€μ΄ λ‚΄ 것이라면 μ•Œλ¦Ό +- ❌ λ‚΄κ°€ μ“΄ 닡글은 μ•Œλ¦Ό μ—†μŒ + +--- + +### 2.3 곡지사항 + +#### 2.3.1 전체 μ•Œλ¦Ό + +| ν•­λͺ© | λ‚΄μš© | +|------|------| +| **트리거** | κ²Œμ‹œνŒ 곡지사항 κΈ€ μž‘μ„± μ‹œ | +| **μˆ˜μ‹ μž** | ν™œμ„± μƒνƒœ(`active`)인 λͺ¨λ“  멀버 | +| **μ œμ™Έ** | μ—†μŒ (곡지사항 μž‘μ„±μž 포함 전체) | +| **타이틀** | "πŸ“’ μƒˆ 곡지사항" | +| **λ³Έλ¬Έ** | 곡지사항 제λͺ© 전체 | +| **클릭 λ™μž‘** | ν•΄λ‹Ή κ³΅μ§€μ‚¬ν•­μœΌλ‘œ 이동 | + +**μ •μ±…:** +- βœ… κ΄€λ¦¬μžκ°€ 곡지사항을 μž‘μ„±ν•˜λ©΄ ν™œμ„± 멀버 μ „μ²΄μ—κ²Œ μ•Œλ¦Ό +- βœ… μΉ΄ν…Œκ³ λ¦¬κ°€ `notice`인 κ²Œμ‹œκΈ€λ§Œ ν•΄λ‹Ή +- βœ… ν™œλ™ μ μˆ˜μ™€ λ³„λ„λ‘œ 운영 + +--- + +## 3. μ•Œλ¦Ό λ™μž‘ 방식 + +### 3.1 ν¬κ·ΈλΌμš΄λ“œ μ•Œλ¦Ό + +- **μ •μ˜:** μ‚¬μš©μžκ°€ μ‚¬μ΄νŠΈλ₯Ό 이용 쀑인 μƒνƒœ +- **ν‘œν˜„:** λΈŒλΌμš°μ € λ‚΄λΆ€ ν† μŠ€νŠΈ λ©”μ‹œμ§€ (sonner) +- **λ™μž‘:** + 1. FCM λ©”μ‹œμ§€ μˆ˜μ‹  + 2. 우츑 상단에 ν† μŠ€νŠΈ νŒμ—… ν‘œμ‹œ + 3. 클릭 μ‹œ ν•΄λ‹Ή νŽ˜μ΄μ§€λ‘œ 이동 + +### 3.2 λ°±κ·ΈλΌμš΄λ“œ μ•Œλ¦Ό + +- **μ •μ˜:** μ‚¬μš©μžκ°€ μ‚¬μ΄νŠΈλ₯Ό μ΄μš©ν•˜μ§€ μ•ŠλŠ” μƒνƒœ +- **ν‘œν˜„:** 운영체제 μ•Œλ¦Ό μ„Όν„° +- **λ™μž‘:** + 1. μ„œλΉ„μŠ€ μ›Œμ»€κ°€ FCM λ©”μ‹œμ§€ μˆ˜μ‹  + 2. μ‹œμŠ€ν…œ μ•Œλ¦ΌμœΌλ‘œ ν‘œμ‹œ + 3. 클릭 μ‹œ λΈŒλΌμš°μ € 열리며 ν•΄λ‹Ή νŽ˜μ΄μ§€λ‘œ 이동 + +### 3.3 μ•Œλ¦Ό 클릭 λ™μž‘ + +1. **κ²Œμ‹œνŒ μ•Œλ¦Ό:** `/board/{postId}`둜 이동 +2. **포슀트 μ•Œλ¦Ό:** `/posts/{postId}`둜 이동 +3. **곡지사항 μ•Œλ¦Ό:** `/board/{postId}`둜 이동 (κ²Œμ‹œνŒ 곡지) + +--- + +## 4. μ•Œλ¦Ό 데이터 ꡬ쑰 + +### 4.1 FCM 토큰 μ €μž₯ + +```sql +CREATE TABLE fcm_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE, + token TEXT NOT NULL, + device_info TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_used_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(member_id, token) +); +``` + +### 4.2 μ•Œλ¦Ό νŽ˜μ΄λ‘œλ“œ + +```typescript +{ + notification: { + title: string, // μ•Œλ¦Ό 제λͺ© + body: string, // μ•Œλ¦Ό λ‚΄μš© + }, + data: { + type: string, // 'board_comment', 'board_reply', 'post_comment', 'post_reply', 'board_notice' + postId: string, // κ²Œμ‹œκΈ€/포슀트 ID + commentId?: string, // λŒ“κΈ€ ID (선택) + clickUrl: string, // 이동할 URL + } +} +``` + +--- + +## 5. 기술 κ΅¬ν˜„ + +### 5.1 Firebase Cloud Messaging + +- **Client SDK:** Firebase JavaScript SDK (λΈŒλΌμš°μ €) +- **Admin SDK:** Firebase Admin Node.js SDK (μ„œλ²„) +- **μ„œλΉ„μŠ€ μ›Œμ»€:** `/public/firebase-messaging-sw.js` + +### 5.2 μ•Œλ¦Ό 전솑 흐름 + +``` +[μ‚¬μš©μž μ•‘μ…˜] + ↓ (λŒ“κΈ€/λ‹΅κΈ€/곡지 μž‘μ„±) +[Next.js API Route] + ↓ (λŒ€μƒμž μΆ”μΆœ) +[Firebase Admin SDK] + ↓ (FCM 토큰 쑰회) +[FCM μ„œλ²„] + ↓ (λ©”μ‹œμ§€ 전솑) +[λΈŒλΌμš°μ € μ„œλΉ„μŠ€ μ›Œμ»€] + ↓ (λ©”μ‹œμ§€ μˆ˜μ‹ ) +[μ‚¬μš©μž κΈ°κΈ°] + ↓ (μ•Œλ¦Ό ν‘œμ‹œ) +``` + +### 5.3 μžλ™ 정리 + +- **만료 토큰 μ‚­μ œ:** FCM 전솑 μ‹€νŒ¨ μ‹œ μžλ™ μ‚­μ œ +- **λ§ˆμ§€λ§‰ μ‚¬μš© μ‹œκ°„ μ—…λ°μ΄νŠΈ:** μ•Œλ¦Ό 전솑 성곡 μ‹œ κ°±μ‹  +- **쀑볡 토큰 λ°©μ§€:** (member_id, token) μœ λ‹ˆν¬ μ œμ•½μ‘°κ±΄ + +--- + +## 6. λΈŒλΌμš°μ € 지원 + +### 6.1 지원 λΈŒλΌμš°μ € + +| λΈŒλΌμš°μ € | 지원 버전 | λΉ„κ³  | +|----------|-----------|------| +| Chrome | 42+ | βœ… μ™„μ „ 지원 | +| Firefox | 44+ | βœ… μ™„μ „ 지원 | +| Edge | 42+ | βœ… μ™„μ „ 지원 | +| Safari (macOS) | 16.4+ | βœ… 지원 | +| Safari (iOS) | 16.4+ | ⚠️ ν™ˆ ν™”λ©΄ μΆ”κ°€ ν•„μš” | +| Samsung Internet | μ΅œμ‹  버전 | βœ… μ™„μ „ 지원 | + +### 6.2 미지원 λΈŒλΌμš°μ € + +- **iOS 16.4 미만:** ν‘Έμ‹œ μ•Œλ¦Ό 미지원 +- **일뢀 μ•ˆλ“œλ‘œμ΄λ“œ λΈŒλΌμš°μ €:** Web Push API 미지원 +- **Internet Explorer:** 지원 μ’…λ£Œ + +--- + +## 7. κ°œμΈμ •λ³΄ 보호 + +### 7.1 데이터 μˆ˜μ§‘ + +- **FCM 토큰:** λ””λ°”μ΄μŠ€ μ‹λ³„μž (Firebase λ°œκΈ‰) +- **λ””λ°”μ΄μŠ€ 정보:** User-Agent λ¬Έμžμ—΄ (선택) +- **μ €μž₯ μœ„μΉ˜:** Supabase PostgreSQL (fcm_tokens ν…Œμ΄λΈ”) + +### 7.2 데이터 보호 + +- **μ•”ν˜Έν™”:** HTTPS 톡신 (TLS 1.3+) +- **인증:** FCM 토큰 μš”μ²­ μ‹œ νšŒμ› 인증 ν•„μˆ˜ +- **μ ‘κ·Ό μ œμ–΄:** μ„œλΉ„μŠ€ 계정 ν‚€ base64 인코딩 (ν™˜κ²½λ³€μˆ˜) +- **보관:** νšŒμ› νƒˆν‡΄ μ‹œ μžλ™ μ‚­μ œ (ON DELETE CASCADE) + +### 7.3 κΆŒν•œ 관리 + +- **μ•Œλ¦Ό κΆŒν•œ:** μ‚¬μš©μž λ™μ˜ ν•„μˆ˜ (Notification API) +- **μ–Έμ œλ“  μ·¨μ†Œ:** λΈŒλΌμš°μ € μ„€μ •μ—μ„œ μ–Έμ œλ“  차단 κ°€λŠ₯ +- **ꡬ독 μ·¨μ†Œ:** `/settings/notifications`μ—μ„œ 토큰 μ‚­μ œ κ°€λŠ₯ + +--- + +## 8. 문제 ν•΄κ²° + +### 8.1 μ•Œλ¦Όμ΄ μ•ˆ 올 λ•Œ + +1. **μ•Œλ¦Ό κΆŒν•œ 확인** + - λΈŒλΌμš°μ € μ„€μ • β†’ μ‚¬μ΄νŠΈ μ„€μ • β†’ μ•Œλ¦Ό κΆŒν•œ + - `chrome://settings/content/notifications` + +2. **FCM 토큰 확인** + - 개발자 도ꡬ β†’ Application β†’ Service Workers + - `/firebase-messaging-sw.js` 등둝 확인 + +3. **CSP μ—λŸ¬ 확인** + - 개발자 도ꡬ β†’ Console + - `Connecting to ... violates CSP` μ—λŸ¬ μ—†λŠ”μ§€ 확인 + +### 8.2 μ„œλ²„ μ—λŸ¬ + +1. **Firebase Admin SDK μ΄ˆκΈ°ν™” μ‹€νŒ¨** + - Service Account Key 파일 확인 + - `/firebase-service-account.json` 쑴재 확인 + +2. **FCM 전솑 μ‹€νŒ¨** + - 토큰 만료: μžλ™ μ‚­μ œλ¨ + - 잘λͺ»λœ 토큰: μžλ™ 정리됨 + - ν• λ‹ΉλŸ‰ 초과: μ—†μŒ (FCM λ¬΄μ œν•œ) + +### 8.3 고객 지원 + +- **이슈 트래컀:** GitHub Issues +- **λ¬Έμ„œ:** `/docs/26-03-16-pwa-push-notification.md` +- **ν…ŒμŠ€νŠΈ:** μ‹€μ œ μ•Œλ¦Ό μ „μ†‘μœΌλ‘œ 확인 + +--- + +## 9. λ³€κ²½ 이λ ₯ + +| 버전 | λ‚ μ§œ | λ³€κ²½ λ‚΄μš© | μž‘μ„±μž | +|------|------|----------|--------| +| 1.0.0 | 2026-03-16 | 초기 μ•Œλ¦Ό μ •μ±… λ¬Έμ„œ μž‘μ„± | Claude | + +--- + +## 10. μ°Έκ³  자료 + +- [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) +- [Web Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) +- [Notification API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) +- [PWA ν‘Έμ‹œ μ•Œλ¦Ό κ΅¬ν˜„ κ³„νš](/home/choiho/study-admin/docs/plans/26-03-16-pwa-push-notification.md) diff --git a/docs/plans/26-03-16-pwa-push-notification.md b/docs/plans/26-03-16-pwa-push-notification.md new file mode 100644 index 0000000..f088e90 --- /dev/null +++ b/docs/plans/26-03-16-pwa-push-notification.md @@ -0,0 +1,1021 @@ +# PWA ν‘Έμ‹œ μ•Œλ¦Ό κ΅¬ν˜„ κ³„νš + +## κ°œμš” + +νμŠ€νŒ… 4th μŠ€ν„°λ”” μžλ™ν™” ν”Œλž«νΌμ— PWA ν‘Έμ‹œ μ•Œλ¦Ό κΈ°λŠ₯을 μΆ”κ°€ν•˜μ—¬ μ‚¬μš©μžμ—κ²Œ μ€‘μš”ν•œ μ•Œλ¦Όμ„ μ‹€μ‹œκ°„μœΌλ‘œ μ „λ‹¬ν•©λ‹ˆλ‹€. + +**ν˜„μž¬ μƒνƒœ:** +- βœ… PWA κΈ°λ³Έ μ„€μ • μ™„λ£Œ (manifest.json, μ•„μ΄μ½˜) +- βœ… ν™ˆ ν™”λ©΄ μΆ”κ°€ 지원 +- ❌ μ„œλΉ„μŠ€ μ›Œμ»€ λ―Έκ΅¬ν˜„ +- ❌ ν‘Έμ‹œ μ•Œλ¦Ό 미지원 + +**λͺ©ν‘œ:** +- Web Push APIλ₯Ό ν™œμš©ν•œ λΈŒλΌμš°μ € ν‘Έμ‹œ μ•Œλ¦Ό +- μ„œλΉ„μŠ€ μ›Œμ»€λ₯Ό ν†΅ν•œ λ°±κ·ΈλΌμš΄λ“œ μ•Œλ¦Ό 처리 +- μ‚¬μš©μžλ³„ μ•Œλ¦Ό μ„€μ • 관리 +- Discord μ•Œλ¦Όκ³Ό 연동 + +--- + +## 기술 μŠ€νƒ + +| ν•­λͺ© | 기술 | λΉ„κ³  | +|------|------|------| +| ν‘Έμ‹œ μ„œλΉ„μŠ€ | Firebase Cloud Messaging (FCM) | μ™„μ „ 무료, λ¬΄μ œν•œ | +| μ„œλΉ„μŠ€ μ›Œμ»€ | Workbox + FCM SDK | Google 곡식 라이브러리 | +| μ•Œλ¦Ό κΆŒν•œ | Notification API | μ‚¬μš©μž λ™μ˜ ν•„μˆ˜ | +| Admin SDK | Firebase Admin SDK | μ„œλ²„μš© | +| λ°±μ—”λ“œ | Next.js API Routes | Vercel 무료 ν”Œλžœ | +| DB | Supabase PostgreSQL | ꡬ독 정보 μ €μž₯ (κΈ°μ‘΄ ν™œμš©) | + +--- + +## μ•„ν‚€ν…μ²˜ + +### 1. ν‘Έμ‹œ μ•Œλ¦Ό ν”Œλ‘œμš° + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ 이벀트 λ°œμƒ β”‚ (κ²Œμ‹œκΈ€ μž‘μ„±, λŒ“κΈ€, 곡지사항 λ“±) +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Next.js API β”‚ (이벀트 감지 β†’ λŒ€μƒμž μΆ”μΆœ) +β”‚ - Routes β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Firebase Admin β”‚ (FCM ν† ν°μœΌλ‘œ λ©”μ‹œμ§€ 전솑) +β”‚ - SDK β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ FCM μ„œλ²„ β”‚ (Google 무료 인프라) +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ λΈŒλΌμš°μ € β”‚ (μ„œλΉ„μŠ€ μ›Œμ»€ μˆ˜μ‹ ) +β”‚ - Service Workerβ”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ μ‚¬μš©μž κΈ°κΈ° β”‚ (μ•Œλ¦Ό ν‘œμ‹œ) +β”‚ - Notification API +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### 2. λ°μ΄ν„°λ² μ΄μŠ€ μŠ€ν‚€λ§ˆ + +```sql +-- FCM 토큰 μ €μž₯ (FCM μ‚¬μš© μ‹œ 더 간단) +CREATE TABLE fcm_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE, + token TEXT NOT NULL, -- FCM 등둝 토큰 + device_info TEXT, -- λ””λ°”μ΄μŠ€ 정보 (선택) + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_used_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(member_id, token) +); + +-- 인덱슀 +CREATE INDEX idx_fcm_tokens_member_id ON fcm_tokens(member_id); +CREATE INDEX idx_fcm_tokens_last_used ON fcm_tokens(last_used_at); +``` + +--- + +## κ΅¬ν˜„ 단계 + +### Phase 1: Firebase μ„€μ • (Foundation) - 1μ‹œκ°„ + +#### 1.1 Firebase ν”„λ‘œμ νŠΈ 생성 + +1. [Firebase Console](https://console.firebase.google.com/) 접속 +2. μƒˆ ν”„λ‘œμ νŠΈ 생성: `qscouting-4th` +3. Cloud Messaging ν™œμ„±ν™” +4. μ„œλΉ„μŠ€ 계정 ν‚€ λ‹€μš΄λ‘œλ“œ (JSON) +5. μ›Ή μ•± μΆ”κ°€ β†’ Firebase SDK μ„€μ • 볡사 + +#### 1.2 μ˜μ‘΄μ„± μ„€μΉ˜ + +```bash +cd packages/web +pnpm add firebase workbox-webpack-plugin + +# μ„œλ²„μš© Admin SDK (νŒ¨ν‚€μ§€ 전체) +pnpm -W add firebase-admin +``` + +#### 1.3 ν™˜κ²½ λ³€μˆ˜ μ„€μ • + +```bash +# .env.local +NEXT_PUBLIC_FIREBASE_API_KEY=AIza... +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=qscouting-4th.firebaseapp.com +NEXT_PUBLIC_FIREBASE_PROJECT_ID=qscouting-4th +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=qscouting-4th.appspot.com +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=... +NEXT_PUBLIC_FIREBASE_APP_ID=... + +# Firebase Admin SDK (μ„œλ²„μš©, μ ˆλŒ€ 유좜 κΈˆμ§€) +FIREBASE_SERVICE_ACCOUNT_KEY=base64_encoded_json +``` + +**주의:** Admin SDK ν‚€λŠ” base64둜 μΈμ½”λ”©ν•˜μ—¬ ν™˜κ²½ λ³€μˆ˜μ— μ €μž₯ + +#### 1.4 Firebase ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” + +```typescript +// packages/web/src/lib/firebase/client.ts +import { initializeApp, getApps } from 'firebase/app'; +import { getMessaging, getToken, onMessage } from 'firebase/messaging'; + +const firebaseConfig = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, +}; + +// 앱이 쀑볡 μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šλ„λ‘ 체크 +const app = !getApps().length ? initializeApp(firebaseConfig) : getApps()[0]; +export const messaging = getMessaging(app); + +// FCM 토큰 μš”μ²­ +export async function requestFCMToken() { + try { + const token = await getToken(messaging, { + vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY, + }); + return token; + } catch (error) { + console.error('FCM token request failed:', error); + return null; + } +} + +// ν¬κ·ΈλΌμš΄λ“œ λ©”μ‹œμ§€ μˆ˜μ‹  +export function onForegroundMessage(callback: (payload: any) => void) { + return onMessage(messaging, callback); +} +``` + +#### 1.5 Firebase Admin SDK μ΄ˆκΈ°ν™” + +```typescript +// packages/web/src/lib/firebase/admin.ts +import admin from 'firebase-admin'; +import { getApps } from 'firebase-admin/app'; + +if (!getApps().length) { + const serviceAccountKey = process.env.FIREBASE_SERVICE_ACCOUNT_KEY; + if (!serviceAccountKey) { + throw new Error('FIREBASE_SERVICE_ACCOUNT_KEY is not set'); + } + + const decodedKey = JSON.parse( + Buffer.from(serviceAccountKey, 'base64').toString('utf-8') + ); + + admin.initializeApp({ + credential: admin.credential.cert(decodedKey as admin.ServiceAccount), + }); +} + +export const adminMessaging = admin.messaging(); +``` + +#### 1.6 μ„œλΉ„μŠ€ μ›Œμ»€ μ„€μ • + +```typescript +// packages/web/public/firebase-messaging-sw.js +import { initializeApp } from 'firebase/app'; +import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw'; + +const firebaseConfig = { + apiKey: self.env.FIREBASE_API_KEY, + authDomain: self.env.FIREBASE_AUTH_DOMAIN, + projectId: self.env.FIREBASE_PROJECT_ID, + storageBucket: self.env.FIREBASE_STORAGE_BUCKET, + messagingSenderId: self.env.FIREBASE_MESSAGING_SENDER_ID, + appId: self.env.FIREBASE_APP_ID, +}; + +const app = initializeApp(firebaseConfig); +const messaging = getMessaging(app); + +// λ°±κ·ΈλΌμš΄λ“œ λ©”μ‹œμ§€ μˆ˜μ‹  +onBackgroundMessage(messaging, (payload) => { + const notificationTitle = payload.notification?.title || 'μ•Œλ¦Ό'; + const notificationOptions = { + body: payload.notification?.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + data: payload.data, + }; + + self.registration.showNotification(notificationTitle, notificationOptions); +}); + +// μ•Œλ¦Ό 클릭 처리 +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = event.notification.data?.url || '/dashboard'; + event.waitUntil(clients.openWindow(url)); +}); +``` + +--- + +### Phase 2: ν”„λ‘ νŠΈμ—”λ“œ κ΅¬ν˜„ (Frontend) - 2μ‹œκ°„ + +#### 2.1 ν‘Έμ‹œ μ•Œλ¦Ό ν›… + +```typescript +// packages/web/src/hooks/use-push-notification.ts +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { requestFCMToken, onForegroundMessage } from '@/lib/firebase/client'; + +export function usePushNotification() { + const [permission, setPermission] = useState('default'); + const [token, setToken] = useState(null); + + useEffect(() => { + if ('Notification' in window) { + setPermission(Notification.permission); + + // ν¬κ·ΈλΌμš΄λ“œ λ©”μ‹œμ§€ λ¦¬μŠ€λ„ˆ + const unsubscribe = onForegroundMessage((payload) => { + toast(payload.notification?.title || 'μ•Œλ¦Ό', { + description: payload.notification?.body, + }); + }); + + return () => unsubscribe(); + } + }, []); + + const requestPermission = async () => { + if (!('Notification' in window)) { + toast.error('이 λΈŒλΌμš°μ €λŠ” μ•Œλ¦Όμ„ μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.'); + return false; + } + + const result = await Notification.requestPermission(); + setPermission(result); + + if (result === 'granted') { + const fcmToken = await requestFCMToken(); + if (fcmToken) { + setToken(fcmToken); + await subscribeToPush(fcmToken); + toast.success('μ•Œλ¦Όμ΄ ν™œμ„±ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + return true; + } + } + + if (result === 'denied') { + toast.error('μ•Œλ¦Όμ΄ μ°¨λ‹¨λ˜μ—ˆμŠ΅λ‹ˆλ‹€. λΈŒλΌμš°μ € μ„€μ •μ—μ„œ λ³€κ²½ν•΄μ£Όμ„Έμš”.'); + } + + return false; + }; + + const subscribeToPush = async (fcmToken: string) => { + try { + await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: fcmToken, + deviceInfo: navigator.userAgent, + }), + }); + } catch (error) { + console.error('Push subscription failed:', error); + toast.error('μ•Œλ¦Ό ꡬ독에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); + } + }; + + const unsubscribe = async () => { + if (token) { + await fetch('/api/push/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + setToken(null); + } + }; + + return { + permission, + token, + requestPermission, + unsubscribe, + isSupported: 'Notification' in window, + }; +} +``` + +#### 2.2 μ•Œλ¦Ό μ„€μ • μ»΄ν¬λ„ŒνŠΈ + +```typescript +// packages/web/src/components/settings/push-notification-settings.tsx +'use client'; + +import { Bell, BellOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { usePushNotification } from '@/hooks/use-push-notification'; + +export function PushNotificationSettings() { + const { permission, token, requestPermission, unsubscribe, isSupported } = usePushNotification(); + + if (!isSupported) { + return
이 λΈŒλΌμš°μ €λŠ” μ•Œλ¦Όμ„ μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
; + } + + const isEnabled = permission === 'granted' && token; + + return ( +
+
+
ν‘Έμ‹œ μ•Œλ¦Ό
+
+ μ€‘μš”ν•œ μ•Œλ¦Όμ„ μ‹€μ‹œκ°„μœΌλ‘œ λ°›μ•„λ³΄μ„Έμš”. +
+
+ +
+ ); +} +``` + +--- + +### Phase 3: λ°±μ—”λ“œ κ΅¬ν˜„ (Backend) - 2μ‹œκ°„ + +#### 3.1 DB μŠ€ν‚€λ§ˆ μΆ”κ°€ + +```typescript +// packages/shared/src/db/schema.ts +export const fcmTokens = pgTable('fcm_tokens', { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'cascade' }), + token: text('token').notNull(), + deviceInfo: text('device_info'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }).defaultNow(), +}, (table) => ({ + memberIdIdx: index('idx_fcm_tokens_member_id').on(table.memberId), + memberTokenUnique: unique('member_token_unique').on(table.memberId, table.token), +})); +``` + +#### 3.2 FCM 토큰 μ €μž₯ API + +```typescript +// packages/web/src/app/api/push/subscribe/route.ts +import { NextRequest } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { getBoardAuth } from '@/lib/board-auth'; +import { errorResponse, Errors, successResponse } from '@/lib/api-error'; + +const { fcmTokens } = sharedDb; + +export async function POST(request: NextRequest) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + const { token, deviceInfo } = await request.json(); + + if (!token) { + return Errors.badRequest('FCM 토큰이 ν•„μš”ν•©λ‹ˆλ‹€.').toResponse(); + } + + const database = getDb(); + + await database + .insert(fcmTokens) + .values({ + memberId: auth.memberId, + token, + deviceInfo, + }) + .onConflictDoUpdate({ + target: [fcmTokens.memberId, fcmTokens.token], + set: { + lastUsedAt: new Date(), + deviceInfo, + }, + }); + + return successResponse({ subscribed: true }, 'μ•Œλ¦Όμ΄ κ΅¬λ…λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + return errorResponse(error); + } +} +``` + +#### 3.3 ꡬ독 μ·¨μ†Œ API + +```typescript +// packages/web/src/app/api/push/unsubscribe/route.ts +import { NextRequest } from 'next/server'; +import { eq, and } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { getBoardAuth } from '@/lib/board-auth'; +import { errorResponse, Errors, successResponse } from '@/lib/api-error'; + +const { fcmTokens } = sharedDb; + +export async function POST(request: NextRequest) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + const { token } = await request.json(); + + if (!token) { + return Errors.badRequest('FCM 토큰이 ν•„μš”ν•©λ‹ˆλ‹€.').toResponse(); + } + + const database = getDb(); + + await database + .delete(fcmTokens) + .where( + and( + eq(fcmTokens.token, token), + eq(fcmTokens.memberId, auth.memberId) + ) + ); + + return successResponse({ unsubscribed: true }, 'ꡬ독이 μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + return errorResponse(error); + } +} +``` + +#### 3.4 FCM ν‘Έμ‹œ μ•Œλ¦Ό 전솑 μœ ν‹Έ + +```typescript +// packages/web/src/lib/push.ts +import { eq, inArray } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { adminMessaging } from '@/lib/firebase/admin'; +import type { Message } from 'firebase-admin/messaging'; + +const { fcmTokens } = sharedDb; + +export interface PushPayload { + title: string; + body: string; + icon?: string; + clickUrl?: string; + data?: Record; +} + +/** + * νŠΉμ • λ©€λ²„μ—κ²Œ FCM ν‘Έμ‹œ μ•Œλ¦Ό 전솑 + */ +export async function sendPushToMember( + memberId: string, + payload: PushPayload +): Promise<{ success: number; failed: number }> { + const database = getDb(); + + const tokens = await database + .select({ token: fcmTokens.token }) + .from(fcmTokens) + .where(eq(fcmTokens.memberId, memberId)); + + if (tokens.length === 0) { + return { success: 0, failed: 0 }; + } + + const message: Message = { + notification: { + title: payload.title, + body: payload.body, + icon: payload.icon || '/icon-192.png', + }, + data: { + clickUrl: payload.clickUrl || '/dashboard', + ...payload.data, + }, + webpush: { + fcmOptions: { + link: payload.clickUrl || '/dashboard', + }, + }, + tokens: tokens.map((t) => t.token), + }; + + try { + const response = await adminMessaging.sendMulticast(message); + + // μ‹€νŒ¨ν•œ 토큰 μ‚­μ œ + if (response.failureCount > 0) { + const failedTokens: string[] = []; + response.responses.forEach((resp, idx) => { + if (!resp.success) { + failedTokens.push(tokens[idx].token); + } + }); + + if (failedTokens.length > 0) { + await database + .delete(fcmTokens) + .where(inArray(fcmTokens.token, failedTokens)); + } + } + + // λ§ˆμ§€λ§‰ μ‚¬μš© μ‹œκ°„ μ—…λ°μ΄νŠΈ + await database + .update(fcmTokens) + .set({ lastUsedAt: new Date() }) + .where(eq(fcmTokens.memberId, memberId)); + + return { + success: response.successCount, + failed: response.failureCount, + }; + } catch (error) { + console.error('[push] Failed to send:', error); + return { success: 0, failed: tokens.length }; + } +} + +/** + * μ—¬λŸ¬ λ©€λ²„μ—κ²Œ FCM ν‘Έμ‹œ μ•Œλ¦Ό 전솑 + */ +export async function sendPushToMembers( + memberIds: string[], + payload: PushPayload +): Promise<{ success: number; failed: number }> { + const database = getDb(); + + const tokens = await database + .select({ token: fcmTokens.token, memberId: fcmTokens.memberId }) + .from(fcmTokens) + .where(inArray(fcmTokens.memberId, memberIds)); + + if (tokens.length === 0) { + return { success: 0, failed: 0 }; + } + + // λ©€λ²„λ³„λ‘œ κ·Έλ£Ήν™”ν•˜μ—¬ 전솑 (FCM quota μ΅œμ ν™”) + const memberTokens = new Map(); + tokens.forEach((t) => { + if (!memberTokens.has(t.memberId)) { + memberTokens.set(t.memberId, []); + } + memberTokens.get(t.memberId)!.push(t.token); + }); + + let totalSuccess = 0; + let totalFailed = 0; + + // λ©€λ²„λ³„λ‘œ 전솑 (쀑볡 μ•Œλ¦Ό λ°©μ§€) + for (const [memberId, tokenList] of memberTokens) { + const message: Message = { + notification: { + title: payload.title, + body: payload.body, + icon: payload.icon || '/icon-192.png', + }, + data: { + clickUrl: payload.clickUrl || '/dashboard', + ...payload.data, + }, + tokens: tokenList, + }; + + try { + const response = await adminMessaging.sendMulticast(message); + totalSuccess += response.successCount; + totalFailed += response.failureCount; + + // μ‹€νŒ¨ν•œ 토큰 μ‚­μ œ + if (response.failureCount > 0) { + const failedTokens: string[] = []; + response.responses.forEach((resp, idx) => { + if (!resp.success) { + failedTokens.push(tokenList[idx]); + } + }); + + if (failedTokens.length > 0) { + await database + .delete(fcmTokens) + .where(inArray(fcmTokens.token, failedTokens)); + } + } + } catch (error) { + console.error(`[push] Failed to send to ${memberId}:`, error); + totalFailed += tokenList.length; + } + } + + return { success: totalSuccess, failed: totalFailed }; +} +``` + +--- + +### Phase 4: μ•Œλ¦Ό 트리거 (Notifications) - 2μ‹œκ°„ + +#### 4.1 κ²Œμ‹œκΈ€ λŒ“κΈ€ μ•Œλ¦Ό + +```typescript +// packages/web/src/app/api/board/[id]/comments/route.ts +import { sendPushToMember } from '@/lib/push'; + +// λŒ“κΈ€ μž‘μ„± ν›„ +const [newComment] = await database.insert(boardComments).values({...}).returning(); + +// κ²Œμ‹œκΈ€ μž‘μ„±μžμ—κ²Œ ν‘Έμ‹œ μ•Œλ¦Ό +if (post.memberId !== auth.memberId) { + sendPushToMember(post.memberId, { + title: 'μƒˆ λŒ“κΈ€μ΄ λ‹¬λ ΈμŠ΅λ‹ˆλ‹€', + body: `${auth.memberName}λ‹˜μ΄ λŒ“κΈ€μ„ λ‹¬μ•˜μŠ΅λ‹ˆλ‹€`, + data: { + url: `/board/${post.id}`, + }, + }).catch((err) => console.error('[push] Failed:', err)); +} +``` + +#### 4.2 κ²Œμ‹œνŒ κ²Œμ‹œκΈ€ μ•Œλ¦Ό + +```typescript +// packages/web/src/app/api/board/route.ts +import { sendPushToMembers } from '@/lib/push'; + +// 곡지사항 μž‘μ„± ν›„ +if (category === 'notice') { + // ν™œμ„± 멀버 μ „μ²΄μ—κ²Œ μ•Œλ¦Ό + const activeMembers = await database + .select({ id: members.id }) + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + + sendPushToMembers( + activeMembers.map((m) => m.id), + { + title: 'πŸ“’ μƒˆ 곡지사항', + body: title.trim(), + data: { + url: `/board/${result.id}`, + }, + } + ).catch((err) => console.error('[push] Failed:', err)); +} +``` + +#### 4.3 λ‚΄ κ²Œμ‹œκΈ€μ— λŒ“κΈ€ μ•Œλ¦Ό + +```typescript +// packages/web/src/app/api/board/[id]/comments/route.ts +// λŒ€λŒ“κΈ€μ˜ 경우 μ›λŒ“κΈ€ μž‘μ„±μžμ—κ²Œλ„ μ•Œλ¦Ό +if (parentId && parent.memberId !== auth.memberId && parent.memberId !== post.memberId) { + sendPushToMember(parent.memberId, { + title: 'πŸ’¬ 닡글이 λ‹¬λ ΈμŠ΅λ‹ˆλ‹€', + body: `${auth.memberName}λ‹˜μ΄ 닡글을 λ‹¬μ•˜μŠ΅λ‹ˆλ‹€`, + data: { + url: `/board/${postId}`, + }, + }).catch((err) => console.error('[push] Failed:', err)); +} +``` + +--- + +### Phase 5: UI/UX κ°œμ„  - 1μ‹œκ°„ + +#### 5.1 μ•Œλ¦Ό μ„€μ • νŽ˜μ΄μ§€ μΆ”κ°€ + +```typescript +// packages/web/src/app/(user)/settings/notifications/page.tsx +export default function NotificationSettingsPage() { + return ( +
+

μ•Œλ¦Ό μ„€μ •

+ +
+ + + ν‘Έμ‹œ μ•Œλ¦Ό + + + + + + + {/* ν–₯ν›„ ν™•μž₯: μΉ΄ν…Œκ³ λ¦¬λ³„ μ•Œλ¦Ό μ„€μ • */} + + + μ•Œλ¦Ό μœ ν˜• + + + + + +
+
+ ); +} +``` + +#### 5.2 ν™˜μ˜ λ°°λ„ˆ (첫 λ°©λ¬Έμ‹œ) + +```typescript +// packages/web/src/components/push/push-prompt-banner.tsx +'use client'; + +import { Bell, X } from 'lucide-react'; +import { usePushNotification } from '@/hooks/use-push-notification'; + +export function PushPromptBanner() { + const { permission, requestPermission } = usePushNotification(); + + if (permission !== 'default') return null; + + return ( +
+ +
+
μ•Œλ¦Όμ„ ν™œμ„±ν™”ν•˜μ„Έμš”
+
+ μ€‘μš”ν•œ 곡지사항과 λŒ“κΈ€μ„ μ‹€μ‹œκ°„μœΌλ‘œ λ°›μ•„λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€. +
+
+ +
+ ); +} +``` + +--- + +## ν™˜κ²½ λ³€μˆ˜ μ„€μ • + +```bash +# .env.local (root) +# Firebase Admin SDK (base64 μΈμ½”λ”©λœ JSON) +FIREBASE_SERVICE_ACCOUNT_KEY=ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAicXNjb3V0aW5nLTR0aCIsCiAgInByaXZhdGVfa2V5X2lkIjogIi4uLiIsCiAgLi4uCgp9 + +# packages/web/.env.local +# Firebase Client SDK +NEXT_PUBLIC_FIREBASE_API_KEY=AIza... +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=qscouting-4th.firebaseapp.com +NEXT_PUBLIC_FIREBASE_PROJECT_ID=qscouting-4th +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=qscouting-4th.appspot.com +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=... +NEXT_PUBLIC_FIREBASE_APP_ID=1:... +NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=G-... +``` + +**주의:** +- `FIREBASE_SERVICE_ACCOUNT_KEY`λŠ” base64둜 인코딩해야 함 +- 인코딩 방법: `cat service-account.json | base64 -w 0` + +--- + +## λΈŒλΌμš°μ € 지원 ν˜„ν™© + +| λΈŒλΌμš°μ € | FCM | Service Worker | λΉ„κ³  | +|----------|-----|----------------|------| +| Chrome/Edge 42+ | βœ… μ™„μ „ 지원 | βœ… | FCM λ„€μ΄ν‹°λΈŒ | +| Firefox 44+ | βœ… μ™„μ „ 지원 | βœ… | Web Push API | +| Safari 16.4+ | ⚠️ μ œν•œμ  | βœ… | iOS/macOS 별도 μ„€μ • | +| Samsung Internet | βœ… μ™„μ „ 지원 | βœ… | Chromium 기반 | + +**Safari μ£Όμ˜μ‚¬ν•­:** +- iOS 16.4+: ν‘Έμ‹œ μ•Œλ¦Ό 지원 (ν™ˆ ν™”λ©΄ μΆ”κ°€ ν•„μš”) +- macOS 13+: μ§€μ›ν•˜λ‚˜ APNs μ„€μ • ν•„μš” +- FCM은 Safariμ—μ„œ Web Push APIλ₯Ό μ‚¬μš© + +--- + +## λ³΄μ•ˆ 고렀사항 + +### 1. Firebase Service Account Key +- **κ°œμΈν‚€ μ ˆλŒ€ 유좜 κΈˆμ§€** β†’ Git 컀밋 μ œμ™Έ +- Base64 μΈμ½”λ”©μœΌλ‘œ ν™˜κ²½ λ³€μˆ˜μ— μ €μž₯ +- Vercel ν™˜κ²½ λ³€μˆ˜ μ„€μ • (μ„œλ²„μš©λ§Œ) +- ν΄λΌμ΄μ–ΈνŠΈμ—λŠ” κ³΅κ°œν‚€λ§Œ λ…ΈμΆœ + +### 2. FCM 토큰 보호 +- 토큰은 고유 μ‹λ³„μžλ‘œ μ·¨κΈ‰ +- μΈμ¦λ˜μ§€ μ•Šμ€ μš”μ²­ 차단 +- 만료/μ‚­μ œλœ 토큰 정리 + +### 3. νŽ˜μ΄λ‘œλ“œ λ³΄μ•ˆ +- λ―Όκ°ν•œ λ°μ΄ν„°λŠ” νŽ˜μ΄λ‘œλ“œμ— ν¬ν•¨ν•˜μ§€ μ•ŠμŒ +- URL λ°μ΄ν„°λ‘œλ§Œ 상세 정보 전달 +- μ‚¬μš©μž ID λŒ€μ‹  토큰 μ‚¬μš© + +### 4. Vercel λ³΄μ•ˆ +- μ„œλ²„ ν™˜κ²½ λ³€μˆ˜λ§Œ μ‚¬μš© +- ν΄λΌμ΄μ–ΈνŠΈμ—μ„œ Admin SDK 호좜 κΈˆμ§€ +- API Routes둜만 FCM 전솑 + +--- + +## ν…ŒμŠ€νŠΈ κ³„νš + +### 1. λ‹¨μœ„ ν…ŒμŠ€νŠΈ + +```typescript +// packages/web/src/__tests__/push.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { sendPushToMember } from '@/lib/push'; + +// Firebase Admin SDK mock +vi.mock('@/lib/firebase/admin', () => ({ + adminMessaging: { + sendMulticast: vi.fn().mockResolvedValue({ + successCount: 1, + failureCount: 0, + responses: [{ success: true }], + }), + }, +})); + +describe('Push Notification', () => { + it('should send FCM notification to member', async () => { + const result = await sendPushToMember('member-id', { + title: 'Test', + body: 'Test notification', + }); + expect(result.success).toBeGreaterThan(0); + }); + + it('should handle failed tokens', async () => { + // μ‹€νŒ¨ μ‹œλ‚˜λ¦¬μ˜€ ν…ŒμŠ€νŠΈ + }); +}); +``` + +### 2. 톡합 ν…ŒμŠ€νŠΈ + +1. **ꡬ독 흐름** + - [ ] μ•Œλ¦Ό κΆŒν•œ μš”μ²­ + - [ ] ꡬ독 정보 μ €μž₯ + - [ ] ꡬ독 μ·¨μ†Œ + +2. **μ•Œλ¦Ό μˆ˜μ‹ ** + - [ ] λŒ“κΈ€ μ•Œλ¦Ό + - [ ] λŒ€λŒ“κΈ€ μ•Œλ¦Ό + - [ ] 곡지사항 μ•Œλ¦Ό + +3. **λΈŒλΌμš°μ € ν…ŒμŠ€νŠΈ** + - [ ] Chrome (Android/Desktop) + - [ ] Safari (iOS/macOS) + - [ ] Firefox + +### 3. λΆ€ν•˜ ν…ŒμŠ€νŠΈ + +- 100λͺ… λ™μ‹œ μ•Œλ¦Ό 전솑 +- 만료된 ꡬ독 λŒ€λŸ‰ 정리 +- API 응닡 μ‹œκ°„ 확인 + +--- + +## 둀아웃 κ³„νš + +### Week 1: 개발 및 ν…ŒμŠ€νŠΈ +- Day 1-2: Phase 1-2 (기반 + ν”„λ‘ νŠΈμ—”λ“œ) +- Day 3-4: Phase 3 (λ°±μ—”λ“œ) +- Day 5: Phase 4 (μ•Œλ¦Ό 트리거) + +### Week 2: UI κ°œμ„  및 ν…ŒμŠ€νŠΈ +- Day 1: Phase 5 (UI/UX) +- Day 2-3: 톡합 ν…ŒμŠ€νŠΈ +- Day 4: λΈŒλΌμš°μ € ν˜Έν™˜μ„± ν…ŒμŠ€νŠΈ +- Day 5: 버그 μˆ˜μ • + +### Week 3: 배포 +- Day 1: dev λΈŒλžœμΉ˜μ— λ¨Έμ§€ +- Day 2-3: 베타 ν…ŒμŠ€νŠΈ (κ΄€λ¦¬μž κ·Έλ£Ή) +- Day 4: 전체 μ‚¬μš©μž 둀아웃 +- Day 5: λͺ¨λ‹ˆν„°λ§ 및 ν”Όλ“œλ°± + +--- + +## 성곡 μ§€ν‘œ + +| μ§€ν‘œ | λͺ©ν‘œ | μΈ‘μ • 방법 | +|------|------|----------| +| μ•Œλ¦Ό κΆŒν•œ ν—ˆμš©λ₯  | 60% 이상 | κ΅¬λ…μž 수 / 전체 μœ μ € | +| μ•Œλ¦Ό 도달λ₯  | 95% 이상 | 전솑 - μ‹€νŒ¨ / 전솑 | +| μ•Œλ¦Ό 클릭λ₯  | 20% 이상 | 클릭 / 전솑 | +| 평균 μˆ˜μ‹  μ‹œκ°„ | 5초 이내 | λ°œμƒ - μˆ˜μ‹  μ‹œκ°„ 차이 | + +--- + +## 이슈 및 ν•΄κ²° λ°©μ•ˆ + +### 1. Safari 지원 +- **이슈**: iOS 16.4 λ―Έλ§Œμ—μ„œλŠ” ν‘Έμ‹œ μ•Œλ¦Ό 미지원 +- **ν•΄κ²°**: 폴백으둜 in-app μ•Œλ¦Ό ν‘œμ‹œ +- **FCM**: Safariμ—μ„œ Web Push APIλ₯Ό μ‚¬μš©ν•˜μ—¬ μžλ™ 처리 + +### 2. FCM Quota μ œν•œ +- **이슈**: λ¬΄μ œν•œμ΄μ§€λ§Œ 1회 전솑에 500토큰 μ œν•œ +- **ν•΄κ²°**: `sendMulticast` μ‚¬μš©, λ©€λ²„λ³„λ‘œ 배치 전솑 + +### 3. 배터리 μ†Œλͺ¨ +- **이슈**: λ„ˆλ¬΄ μž¦μ€ μ•Œλ¦ΌμœΌλ‘œ 배터리 μ†Œλͺ¨ +- **ν•΄κ²°**: μ•Œλ¦Ό throttle (μ΅œλŒ€ 1회/5λΆ„) + +### 4. Firebase μš”κΈˆ +- **이슈**: FCM이 정말 λ¬΄λ£ŒμΈκ°€? +- **ν•΄κ²°**: βœ… μ™„μ „ 무료, λ¬΄μ œν•œ 전솑 +- **단점**: Cloud Functions μ‚¬μš© μ‹œ 유료 κ°€λŠ₯ (μ—¬κΈ°μ„  μ‚¬μš© μ•ˆ 함) + +--- + +## ν–₯ν›„ κ°œμ„  사항 + +1. **μ•Œλ¦Ό μΉ΄ν…Œκ³ λ¦¬λ³„ μ„€μ •** + - κ²Œμ‹œνŒ λŒ“κΈ€ + - 포슀트 λŒ“κΈ€ + - 곡지사항 + - μ£Όκ°„ λž­ν‚Ή + +2. **μ•Œλ¦Ό μ˜ˆμ•½** + - νŠΉμ • μ‹œκ°„λŒ€μ—λŠ” μ•Œλ¦Ό 끄기 + - Do Not Disturb λͺ¨λ“œ + +3. **μ•Œλ¦Ό κ·Έλ£Ήν™”** + - 같은 κ²Œμ‹œκΈ€μ˜ λŒ“κΈ€μ„ κ·Έλ£Ήν™” + - "N개의 μƒˆ λŒ“κΈ€" ν‘œμ‹œ + +4. **μ•Œλ¦Ό νžˆμŠ€ν† λ¦¬** + - μ§€λ‚œ μ•Œλ¦Ό λͺ©λ‘ ν‘œμ‹œ + - 읽지 μ•Šμ€ μ•Œλ¦Ό λ°°μ§€ + +5. **Telegram/λ””μŠ€μ½”λ“œ 연동** + - PWA 미지원 λΈŒλΌμš°μ €μš© λŒ€μ•ˆ + - 톡합 μ•Œλ¦Ό μ„€μ • + +--- + +## μ°Έκ³  자료 + +### FCM 곡식 λ¬Έμ„œ +- [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) +- [FCM Web Guide](https://firebase.google.com/docs/cloud-messaging/js/client) +- [Admin SDK Node.js](https://firebase.google.com/docs/admin/setup) +- [Send Multicast](https://firebase.google.com/docs/cloud-messaging/send-message#send-to-a-multiple-devices) + +### Web API +- [Notification API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) +- [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) +- [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) + +### React + FCM +- [Firebase React Web Push](https://github.com/firebase/firebase-js-sdk/tree/master/packages/messaging) +- [next-firebase-messaging](https://github.com/nandorojo/next-firebase-messaging) (참고용) + +--- + +## 총 μ˜ˆμƒ μ‹œκ°„ + +- **총 8μ‹œκ°„** (3일) +- Phase 1: 1μ‹œκ°„ +- Phase 2: 2μ‹œκ°„ +- Phase 3: 2μ‹œκ°„ +- Phase 4: 2μ‹œκ°„ +- Phase 5: 1μ‹œκ°„ diff --git a/package.json b/package.json index 0da9875..a5c65fb 100644 --- a/package.json +++ b/package.json @@ -43,5 +43,8 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3" } + }, + "dependencies": { + "firebase-admin": "^13.7.0" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 6529d10..4bc5a42 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -42,6 +42,7 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "@radix-ui/react-switch": "^1.2.6", "dotenv": "^16.4.5", "drizzle-orm": "^0.33.0", "feedsmith": "^2.9.0", diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 94b70cf..ac012c6 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -10,6 +10,7 @@ import { serial, text, timestamp, + unique, uniqueIndex, uuid, varchar, @@ -500,6 +501,56 @@ export const boardPollVotes = pgTable( }) ); +// ── FCM Tokens ───────────────────────────────────────────────────────────── + +export const fcmTokens = pgTable( + 'fcm_tokens', + { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'cascade' }), + token: text('token').notNull(), + deviceInfo: text('device_info'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + memberIdIdx: index('idx_fcm_tokens_member_id').on(table.memberId), + memberTokenUnique: unique('member_token_unique').on(table.memberId, table.token), + }) +); + +// ── Notification Preferences ───────────────────────────────────────────────── + +export const NotificationType = { + BOARD_COMMENT: 'board_comment', + BOARD_REPLY: 'board_reply', + POST_COMMENT: 'post_comment', + POST_REPLY: 'post_reply', + BOARD_NOTICE: 'board_notice', +} as const; + +export type NotificationTypeType = (typeof NotificationType)[keyof typeof NotificationType]; + +export const notificationPreferences = pgTable( + 'notification_preferences', + { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'cascade' }), + type: varchar('type', { length: 30 }).notNull(), + enabled: boolean('enabled').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + memberTypeUnique: unique('member_type_unique').on(table.memberId, table.type), + memberIdIdx: index('idx_notification_preferences_member_id').on(table.memberId), + }) +); + // ============================================ // Relations // ============================================ @@ -513,6 +564,22 @@ export const membersRelations = relations(members, ({ many }) => ({ postComments: many(postComments), boardPosts: many(boardPosts), boardComments: many(boardComments), + fcmTokens: many(fcmTokens), + notificationPreferences: many(notificationPreferences), +})); + +export const fcmTokensRelations = relations(fcmTokens, ({ one }) => ({ + member: one(members, { + fields: [fcmTokens.memberId], + references: [members.id], + }), +})); + +export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({ + member: one(members, { + fields: [notificationPreferences.memberId], + references: [members.id], + }), })); export const roundsRelations = relations(rounds, ({ many }) => ({ @@ -711,3 +778,9 @@ export type NewBoardPollOption = typeof boardPollOptions.$inferInsert; export type BoardPollVote = typeof boardPollVotes.$inferSelect; export type NewBoardPollVote = typeof boardPollVotes.$inferInsert; + +export type FcmToken = typeof fcmTokens.$inferSelect; +export type NewFcmToken = typeof fcmTokens.$inferInsert; + +export type NotificationPreference = typeof notificationPreferences.$inferSelect; +export type NewNotificationPreference = typeof notificationPreferences.$inferInsert; diff --git a/packages/web/next-env.d.ts b/packages/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/packages/web/next-env.d.ts +++ b/packages/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/web/next.config.ts b/packages/web/next.config.ts index 1872077..edc46b7 100644 --- a/packages/web/next.config.ts +++ b/packages/web/next.config.ts @@ -41,12 +41,13 @@ const nextConfig: NextConfig = { key: 'Content-Security-Policy', value: [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net", "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", "img-src 'self' https: data: blob:", "font-src 'self' https://cdn.jsdelivr.net", - "connect-src 'self' https://*.supabase.co https://o4511035097481216.ingest.us.sentry.io", + "connect-src 'self' https://*.supabase.co https://o4511035097481216.ingest.us.sentry.io https://*.firebaseio.com https://*.firebase.com https://*.gstatic.com https://cdn.jsdelivr.net https://firebaseinstallations.googleapis.com https://firebaseremoteconfig.googleapis.com https://fcmregistrations.googleapis.com https://*.googleapis.com", "frame-ancestors 'none'", + "worker-src 'self' https://cdn.jsdelivr.net", ].join('; '), }, ], diff --git a/packages/web/package.json b/packages/web/package.json index d3b6fa2..0f7426f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -42,6 +42,7 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.33.0", "feedsmith": "^2.9.0", + "firebase": "^12.10.0", "framer-motion": "^12.35.1", "lowlight": "^3.3.0", "lucide-react": "^0.575.0", diff --git a/packages/web/public/firebase-messaging-sw.js b/packages/web/public/firebase-messaging-sw.js new file mode 100644 index 0000000..1472a9e --- /dev/null +++ b/packages/web/public/firebase-messaging-sw.js @@ -0,0 +1,57 @@ +// Firebase Cloud Messaging Service Worker +// 이 νŒŒμΌμ€ Firebase SDKλ₯Ό 톡해 λ‘œλ“œλ©λ‹ˆλ‹€ + +const firebaseConfig = { + apiKey: "AIzaSyB66yQiuAXxbLbWz_Cf5unRLuNvESo5sYM", + authDomain: "kusting-159f4.firebaseapp.com", + projectId: "kusting-159f4", + storageBucket: "kusting-159f4.firebasestorage.app", + messagingSenderId: "816173354609", + appId: "1:816173354609:web:a127a7308cd35cbbaf95d0", +}; + +// Firebase Messaging Service WorkerλŠ” μžλ™μœΌλ‘œ μ²˜λ¦¬λ©λ‹ˆλ‹€ +// 이 νŒŒμΌμ€ λ°±κ·ΈλΌμš΄λ“œ λ©”μ‹œμ§€ μˆ˜μ‹ μ„ μœ„ν•œ κ²ƒμž…λ‹ˆλ‹€ + +self.addEventListener('push', (event) => { + const payload = event.data?.json(); + + if (!payload) { + return; + } + + const notificationTitle = payload.notification?.title || 'μ•Œλ¦Ό'; + const notificationOptions = { + body: payload.notification?.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + data: payload.data, + tag: payload.data?.tag || 'default', + }; + + event.waitUntil( + self.registration.showNotification(notificationTitle, notificationOptions) + ); +}); + +// μ•Œλ¦Ό 클릭 처리 +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const url = event.notification.data?.clickUrl || '/dashboard'; + + event.waitUntil( + clients.matchAll({ type: 'window' }).then((clientList) => { + // 이미 μ—΄λ¦° 창이 μžˆλŠ” 경우 포컀슀 + for (const client of clientList) { + if (client.url === url && 'focus' in client) { + return client.focus(); + } + } + // μ—†μœΌλ©΄ μƒˆ μ°½ μ—΄κΈ° + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +}); diff --git a/packages/web/src/app/(user)/profile/page.tsx b/packages/web/src/app/(user)/profile/page.tsx index 26376d8..57f3130 100644 --- a/packages/web/src/app/(user)/profile/page.tsx +++ b/packages/web/src/app/(user)/profile/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { ArrowUpRight, + Bell, Calendar, CheckCircle, ExternalLink, @@ -423,6 +424,27 @@ export default function ProfilePage() { + {/* Notification Settings Link */} + + + +
+
+ +
+

μ•Œλ¦Ό μ„€μ •

+
+ + ν‘Έμ‹œ μ•Œλ¦Ό 관리 + + + +
+
+ {/* Edit Profile & Withdraw Buttons */} {data.member.onboardingCompleted && (
diff --git a/packages/web/src/app/api/board/route.ts b/packages/web/src/app/api/board/route.ts index a00e4d3..11fb158 100644 --- a/packages/web/src/app/api/board/route.ts +++ b/packages/web/src/app/api/board/route.ts @@ -14,8 +14,9 @@ import { getAdminDiscordIds } from '@/lib/admin'; import { isValidCategory } from '@/lib/board-config'; import { sanitizeDescription, sanitizeTiptapContent } from '@/lib/sanitize'; import { grantWebScore } from '@/lib/score'; +import { sendPushToMembers } from '@/lib/push'; -const { boardPosts, members, boardPolls, boardPollOptions, ActivityScoreType } = sharedDb; +const { boardPosts, members, boardPolls, boardPollOptions, ActivityScoreType, MemberStatus } = sharedDb; export async function GET(request: NextRequest) { try { @@ -257,6 +258,24 @@ export async function POST(request: NextRequest) { sanitizeDescription(title.trim().slice(0, 50)) ).catch((err) => console.error('[score] grantWebScore failed:', err)); + // 곡지사항인 경우 ν™œμ„± 멀버 μ „μ²΄μ—κ²Œ μ•Œλ¦Ό + if (category === 'notice') { + const activeMembers = await database + .select({ id: members.id }) + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + + sendPushToMembers( + activeMembers.map((m) => m.id), + { + title: 'πŸ“’ μƒˆ 곡지사항', + body: title.trim(), + clickUrl: `/board/${result.id}`, + data: { type: 'board_notice', postId: result.id }, + } + ).catch((err) => console.error('[push] Notice notification failed:', err)); + } + return successResponse(result, 'κ²Œμ‹œκΈ€μ΄ μž‘μ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.', 201); } catch (error) { return errorResponse(error); diff --git a/packages/web/src/app/api/notification-preferences/route.ts b/packages/web/src/app/api/notification-preferences/route.ts new file mode 100644 index 0000000..350f2e8 --- /dev/null +++ b/packages/web/src/app/api/notification-preferences/route.ts @@ -0,0 +1,78 @@ +import { NextRequest } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { getBoardAuth } from '@/lib/board-auth'; +import { errorResponse, Errors, successResponse } from '@/lib/api-error'; + +const { notificationPreferences, NotificationType } = sharedDb; + +/** + * GET /api/notification-preferences + * μ•Œλ¦Ό μ„€μ • 쑰회 + */ +export async function GET(_request: NextRequest) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + const database = getDb(); + + // λͺ¨λ“  μ•Œλ¦Ό νƒ€μž…μ— λŒ€ν•œ μ„€μ • 쑰회 (μ—†μœΌλ©΄ κΈ°λ³Έκ°’ true둜 생성) + const allTypes = Object.values(NotificationType); + const existing = await database + .select() + .from(notificationPreferences) + .where(eq(notificationPreferences.memberId, auth.memberId)); + + const existingMap = new Map(existing.map((e) => [e.type, e.enabled])); + const preferences = allTypes.map((type) => ({ + type, + enabled: existingMap.get(type) ?? true, + })); + + return successResponse(preferences); + } catch (error) { + return errorResponse(error); + } +} + +/** + * PUT /api/notification-preferences + * μ•Œλ¦Ό μ„€μ • μ—…λ°μ΄νŠΈ + */ +export async function PUT(request: NextRequest) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + const body = await request.json(); + const { type, enabled } = body; + + if (!type || typeof enabled !== 'boolean') { + return Errors.badRequest('typeκ³Ό enabled 값을 λͺ¨λ‘ μ œκ³΅ν•΄μ£Όμ„Έμš”.').toResponse(); + } + + if (!Object.values(NotificationType).includes(type)) { + return Errors.badRequest('잘λͺ»λœ μ•Œλ¦Ό νƒ€μž…μž…λ‹ˆλ‹€.').toResponse(); + } + + const database = getDb(); + + await database + .insert(notificationPreferences) + .values({ + memberId: auth.memberId, + type, + enabled, + }) + .onConflictDoUpdate({ + target: [notificationPreferences.memberId, notificationPreferences.type], + set: { enabled, updatedAt: new Date() }, + }); + + return successResponse({ type, enabled }, 'μ•Œλ¦Ό 섀정이 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/push/subscribe/route.ts b/packages/web/src/app/api/push/subscribe/route.ts new file mode 100644 index 0000000..965d8ba --- /dev/null +++ b/packages/web/src/app/api/push/subscribe/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from 'next/server'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { getBoardAuth } from '@/lib/board-auth'; +import { errorResponse, Errors, successResponse } from '@/lib/api-error'; + +const { fcmTokens } = sharedDb; + +export async function POST(request: NextRequest) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + const { token, deviceInfo } = await request.json(); + + if (!token) { + return Errors.badRequest('FCM 토큰이 ν•„μš”ν•©λ‹ˆλ‹€.').toResponse(); + } + + const database = getDb(); + + await database + .insert(fcmTokens) + .values({ + memberId: auth.memberId, + token, + deviceInfo, + }) + .onConflictDoUpdate({ + target: [fcmTokens.memberId, fcmTokens.token], + set: { + lastUsedAt: new Date(), + deviceInfo, + }, + }); + + return successResponse({ subscribed: true }, 'μ•Œλ¦Όμ΄ κ΅¬λ…λ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/push/unsubscribe/route.ts b/packages/web/src/app/api/push/unsubscribe/route.ts new file mode 100644 index 0000000..1f4cae7 --- /dev/null +++ b/packages/web/src/app/api/push/unsubscribe/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from 'next/server'; +import { eq, and } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { getBoardAuth } from '@/lib/board-auth'; +import { errorResponse, Errors, successResponse } from '@/lib/api-error'; + +const { fcmTokens } = sharedDb; + +export async function POST(request: NextRequest) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + const { token } = await request.json(); + + if (!token) { + return Errors.badRequest('FCM 토큰이 ν•„μš”ν•©λ‹ˆλ‹€.').toResponse(); + } + + const database = getDb(); + + await database + .delete(fcmTokens) + .where( + and( + eq(fcmTokens.token, token), + eq(fcmTokens.memberId, auth.memberId) + ) + ); + + return successResponse({ unsubscribed: true }, 'ꡬ독이 μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.'); + } catch (error) { + return errorResponse(error); + } +} diff --git a/packages/web/src/components/ui/switch.tsx b/packages/web/src/components/ui/switch.tsx index 5f4117f..9c51976 100644 --- a/packages/web/src/components/ui/switch.tsx +++ b/packages/web/src/components/ui/switch.tsx @@ -1,9 +1,9 @@ -"use client" +'use client'; -import * as React from "react" -import * as SwitchPrimitives from "@radix-ui/react-switch" +import * as React from 'react'; +import * as SwitchPrimitives from '@radix-ui/react-switch'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; const Switch = React.forwardRef< React.ElementRef, @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -Switch.displayName = SwitchPrimitives.Root.displayName +)); +Switch.displayName = SwitchPrimitives.Root.displayName; -export { Switch } +export { Switch }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a5b662..eb7b6a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,10 @@ overrides: importers: .: + dependencies: + firebase-admin: + specifier: ^13.7.0 + version: 13.7.0 devDependencies: '@playwright/test': specifier: ^1.58.2 @@ -103,6 +107,9 @@ importers: packages/shared: dependencies: + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -233,6 +240,9 @@ importers: feedsmith: specifier: ^2.9.0 version: 2.9.0 + firebase: + specifier: ^12.10.0 + version: 12.10.0 framer-motion: specifier: ^12.35.1 version: 12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1044,11 +1054,224 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fastify/otel@0.16.0': resolution: {integrity: sha512-2304BdM5Q/kUvQC9qJO1KZq3Zn1WWsw+WWkVmFEaj1UE2hEIiuFqrPeglQOwEtw/ftngisqfQ3v70TWMmwhhHA==} peerDependencies: '@opentelemetry/api': ^1.9.0 + '@firebase/ai@2.9.0': + resolution: {integrity: sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@firebase/app-types': 0.x + + '@firebase/analytics-compat@0.2.26': + resolution: {integrity: sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/analytics-types@0.8.3': + resolution: {integrity: sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==} + + '@firebase/analytics@0.10.20': + resolution: {integrity: sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-check-compat@0.4.1': + resolution: {integrity: sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/app-check-interop-types@0.3.3': + resolution: {integrity: sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==} + + '@firebase/app-check-types@0.5.3': + resolution: {integrity: sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==} + + '@firebase/app-check@0.11.1': + resolution: {integrity: sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-compat@0.5.9': + resolution: {integrity: sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==} + engines: {node: '>=20.0.0'} + + '@firebase/app-types@0.9.3': + resolution: {integrity: sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==} + + '@firebase/app@0.14.9': + resolution: {integrity: sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==} + engines: {node: '>=20.0.0'} + + '@firebase/auth-compat@0.6.3': + resolution: {integrity: sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/auth-interop-types@0.2.4': + resolution: {integrity: sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==} + + '@firebase/auth-types@0.13.0': + resolution: {integrity: sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/auth@1.12.1': + resolution: {integrity: sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@react-native-async-storage/async-storage': ^2.2.0 + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + + '@firebase/component@0.7.1': + resolution: {integrity: sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==} + engines: {node: '>=20.0.0'} + + '@firebase/data-connect@0.4.0': + resolution: {integrity: sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/database-compat@2.1.1': + resolution: {integrity: sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==} + engines: {node: '>=20.0.0'} + + '@firebase/database-types@1.0.17': + resolution: {integrity: sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==} + + '@firebase/database@1.1.1': + resolution: {integrity: sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==} + engines: {node: '>=20.0.0'} + + '@firebase/firestore-compat@0.4.6': + resolution: {integrity: sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/firestore-types@3.0.3': + resolution: {integrity: sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/firestore@4.12.0': + resolution: {integrity: sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/functions-compat@0.4.2': + resolution: {integrity: sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/functions-types@0.6.3': + resolution: {integrity: sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==} + + '@firebase/functions@0.13.2': + resolution: {integrity: sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/installations-compat@0.2.20': + resolution: {integrity: sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/installations-types@0.5.3': + resolution: {integrity: sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==} + peerDependencies: + '@firebase/app-types': 0.x + + '@firebase/installations@0.6.20': + resolution: {integrity: sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/logger@0.5.0': + resolution: {integrity: sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==} + engines: {node: '>=20.0.0'} + + '@firebase/messaging-compat@0.2.24': + resolution: {integrity: sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/messaging-interop-types@0.2.3': + resolution: {integrity: sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==} + + '@firebase/messaging@0.12.24': + resolution: {integrity: sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/performance-compat@0.2.23': + resolution: {integrity: sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/performance-types@0.2.3': + resolution: {integrity: sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==} + + '@firebase/performance@0.7.10': + resolution: {integrity: sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/remote-config-compat@0.2.22': + resolution: {integrity: sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/remote-config-types@0.5.0': + resolution: {integrity: sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==} + + '@firebase/remote-config@0.8.1': + resolution: {integrity: sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/storage-compat@0.4.1': + resolution: {integrity: sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/storage-types@0.8.3': + resolution: {integrity: sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/storage@0.14.1': + resolution: {integrity: sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/util@1.14.0': + resolution: {integrity: sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==} + engines: {node: '>=20.0.0'} + + '@firebase/webchannel-wrapper@1.0.5': + resolution: {integrity: sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1064,6 +1287,44 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@google-cloud/firestore@7.11.6': + resolution: {integrity: sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==} + engines: {node: '>=14.0.0'} + + '@google-cloud/paginator@5.0.2': + resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} + engines: {node: '>=14.0.0'} + + '@google-cloud/projectify@4.0.0': + resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/promisify@4.0.0': + resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} + engines: {node: '>=14'} + + '@google-cloud/storage@7.19.0': + resolution: {integrity: sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==} + engines: {node: '>=14'} + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/grpc-js@1.9.15': + resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} + engines: {node: ^8.13.0 || >=10.10.0} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1226,6 +1487,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1249,6 +1514,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1535,6 +1803,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -1545,6 +1817,36 @@ packages: peerDependencies: '@opentelemetry/api': ^1.8 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2607,12 +2909,19 @@ packages: '@tiptap/starter-kit@3.20.0': resolution: {integrity: sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2647,15 +2956,24 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mysql@2.15.27': resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} @@ -2688,6 +3006,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -2697,6 +3018,9 @@ packages: '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2991,6 +3315,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3029,6 +3357,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -3055,6 +3387,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -3063,6 +3399,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -3113,6 +3453,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} @@ -3123,6 +3467,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3152,11 +3499,17 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} hasBin: true + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3183,6 +3536,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -3254,6 +3610,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3344,6 +3704,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3556,12 +3920,24 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -3818,6 +4194,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3836,6 +4216,13 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + farmhash-modern@1.1.0: + resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==} + engines: {node: '>=18.0.0'} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -3877,6 +4264,10 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3889,6 +4280,10 @@ packages: feedsmith@2.9.0: resolution: {integrity: sha512-TYucytOx4bTrD4ON0iuJG9y0Me7fiT0EZ+7MIE0xptvd8TL6nY0Z1jVPa9W39WMJUtPqV2r27TQxL/z5DCCmdA==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3909,6 +4304,13 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + firebase-admin@13.7.0: + resolution: {integrity: sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==} + engines: {node: '>=18'} + + firebase@12.10.0: + resolution: {integrity: sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -3936,10 +4338,22 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@2.5.5: + resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} + engines: {node: '>= 0.12'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -3985,9 +4399,28 @@ packages: resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} engines: {node: '>= 0.4'} + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3996,6 +4429,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} @@ -4033,6 +4470,11 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -4061,6 +4503,26 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + google-auth-library@10.6.1: + resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==} + engines: {node: '>=18'} + + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-gax@4.6.1: + resolution: {integrity: sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4071,6 +4533,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4121,10 +4587,21 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -4141,6 +4618,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4222,6 +4702,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -4268,6 +4752,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4306,6 +4794,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -4314,6 +4805,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4333,6 +4827,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -4357,10 +4854,24 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4449,6 +4960,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -4474,15 +4988,45 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -4493,6 +5037,9 @@ packages: lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -4500,6 +5047,13 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lucide-react@0.575.0: resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==} peerDependencies: @@ -4561,6 +5115,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -4651,6 +5210,11 @@ packages: sass: optional: true + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -4664,6 +5228,14 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-forge@1.3.3: + resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} + engines: {node: '>= 6.13.0'} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -4682,6 +5254,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -4748,6 +5324,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4784,6 +5363,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -5020,6 +5603,14 @@ packages: prosemirror-view@1.41.6: resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} + proto3-json-serializer@2.0.2: + resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} + engines: {node: '>=14.0.0'} + + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5110,6 +5701,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -5126,6 +5721,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -5155,6 +5754,14 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry-request@7.0.2: + resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} + engines: {node: '>=14'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -5164,6 +5771,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rollup@4.53.3: resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -5183,6 +5794,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -5332,11 +5946,25 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} - string.prototype.matchall@4.0.12: + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -5355,10 +5983,17 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -5381,6 +6016,9 @@ packages: strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -5425,6 +6063,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + teeny-request@9.0.0: + resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} + engines: {node: '>=14'} + terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -5661,6 +6303,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -5743,6 +6393,13 @@ packages: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5760,6 +6417,14 @@ packages: webpack-cli: optional: true + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -5802,6 +6467,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5821,9 +6494,24 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6378,6 +7066,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/busboy@3.2.0': {} + '@fastify/otel@0.16.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -6388,6 +7078,324 @@ snapshots: transitivePeerDependencies: - supports-color + '@firebase/ai@2.9.0(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/app-types': 0.9.3 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/analytics-compat@0.2.26(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/analytics': 0.10.20(@firebase/app@0.14.9) + '@firebase/analytics-types': 0.8.3 + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/analytics-types@0.8.3': {} + + '@firebase/analytics@0.10.20(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/app-check-compat@0.4.1(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-check': 0.11.1(@firebase/app@0.14.9) + '@firebase/app-check-types': 0.5.3 + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/app-check-interop-types@0.3.3': {} + + '@firebase/app-check-types@0.5.3': {} + + '@firebase/app-check@0.11.1(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/app-compat@0.5.9': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/app-types@0.9.3': {} + + '@firebase/app@0.14.9': + dependencies: + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/auth-compat@0.6.3(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/auth': 1.12.1(@firebase/app@0.14.9) + '@firebase/auth-types': 0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.14.0) + '@firebase/component': 0.7.1 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + - '@react-native-async-storage/async-storage' + + '@firebase/auth-interop-types@0.2.4': {} + + '@firebase/auth-types@0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.14.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.14.0 + + '@firebase/auth@1.12.1(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/component@0.7.1': + dependencies: + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/data-connect@0.4.0(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/database-compat@2.1.1': + dependencies: + '@firebase/component': 0.7.1 + '@firebase/database': 1.1.1 + '@firebase/database-types': 1.0.17 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/database-types@1.0.17': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.14.0 + + '@firebase/database@1.1.1': + dependencies: + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + faye-websocket: 0.11.4 + tslib: 2.8.1 + + '@firebase/firestore-compat@0.4.6(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/firestore': 4.12.0(@firebase/app@0.14.9) + '@firebase/firestore-types': 3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.14.0) + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/firestore-types@3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.14.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.14.0 + + '@firebase/firestore@4.12.0(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + '@firebase/webchannel-wrapper': 1.0.5 + '@grpc/grpc-js': 1.9.15 + '@grpc/proto-loader': 0.7.15 + tslib: 2.8.1 + + '@firebase/functions-compat@0.4.2(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/functions': 0.13.2(@firebase/app@0.14.9) + '@firebase/functions-types': 0.6.3 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/functions-types@0.6.3': {} + + '@firebase/functions@0.13.2(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.7.1 + '@firebase/messaging-interop-types': 0.2.3 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/installations-compat@0.2.20(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/installations-types': 0.5.3(@firebase/app-types@0.9.3) + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/installations-types@0.5.3(@firebase/app-types@0.9.3)': + dependencies: + '@firebase/app-types': 0.9.3 + + '@firebase/installations@0.6.20(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/util': 1.14.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/logger@0.5.0': + dependencies: + tslib: 2.8.1 + + '@firebase/messaging-compat@0.2.24(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/messaging': 0.12.24(@firebase/app@0.14.9) + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/messaging-interop-types@0.2.3': {} + + '@firebase/messaging@0.12.24(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/messaging-interop-types': 0.2.3 + '@firebase/util': 1.14.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/performance-compat@0.2.23(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/performance': 0.7.10(@firebase/app@0.14.9) + '@firebase/performance-types': 0.2.3 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/performance-types@0.2.3': {} + + '@firebase/performance@0.7.10(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + web-vitals: 4.2.4 + + '@firebase/remote-config-compat@0.2.22(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/remote-config': 0.8.1(@firebase/app@0.14.9) + '@firebase/remote-config-types': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/remote-config-types@0.5.0': {} + + '@firebase/remote-config@0.8.1(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/storage-compat@0.4.1(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/storage': 0.14.1(@firebase/app@0.14.9) + '@firebase/storage-types': 0.8.3(@firebase/app-types@0.9.3)(@firebase/util@1.14.0) + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/storage-types@0.8.3(@firebase/app-types@0.9.3)(@firebase/util@1.14.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.14.0 + + '@firebase/storage@0.14.1(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/util@1.14.0': + dependencies: + tslib: 2.8.1 + + '@firebase/webchannel-wrapper@1.0.5': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -6405,6 +7413,78 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@google-cloud/firestore@7.11.6': + dependencies: + '@opentelemetry/api': 1.9.0 + fast-deep-equal: 3.1.3 + functional-red-black-tree: 1.0.1 + google-gax: 4.6.1 + protobufjs: 7.5.4 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/paginator@5.0.2': + dependencies: + arrify: 2.0.1 + extend: 3.0.2 + optional: true + + '@google-cloud/projectify@4.0.0': + optional: true + + '@google-cloud/promisify@4.0.0': + optional: true + + '@google-cloud/storage@7.19.0': + dependencies: + '@google-cloud/paginator': 5.0.2 + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + abort-controller: 3.0.0 + async-retry: 1.3.3 + duplexify: 4.1.3 + fast-xml-parser: 5.3.7 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + html-entities: 2.6.0 + mime: 3.0.0 + p-limit: 3.1.0 + retry-request: 7.0.2 + teeny-request: 9.0.0 + uuid: 8.3.2 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + optional: true + + '@grpc/grpc-js@1.9.15': + dependencies: + '@grpc/proto-loader': 0.7.15 + '@types/node': 22.19.11 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + optional: true + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -6523,6 +7603,15 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 @@ -6551,6 +7640,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -6872,6 +7964,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -6883,6 +7978,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -7960,6 +9078,9 @@ snapshots: '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) '@tiptap/pm': 3.20.0 + '@tootallnate/once@2.0.0': + optional: true + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -7970,6 +9091,9 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.19.11 + '@types/caseless@0.12.5': + optional: true + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.11 @@ -8015,8 +9139,16 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.11 + '@types/linkify-it@5.0.0': {} + '@types/long@4.0.2': + optional: true + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -8024,6 +9156,8 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} + '@types/mysql@2.15.27': dependencies: '@types/node': 22.19.11 @@ -8060,6 +9194,14 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 22.19.11 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.5 + optional: true + '@types/send@1.2.1': dependencies: '@types/node': 22.19.11 @@ -8073,6 +9215,9 @@ snapshots: dependencies: '@types/node': 22.19.11 + '@types/tough-cookie@4.0.5': + optional: true + '@types/unist@3.0.3': {} '@types/use-sync-external-store@0.0.6': {} @@ -8425,6 +9570,11 @@ snapshots: '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + optional: true + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -8456,6 +9606,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -8488,12 +9640,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} argparse@2.0.1: {} @@ -8573,12 +9729,20 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + arrify@2.0.1: + optional: true + assertion-error@1.1.0: {} ast-types-flow@0.0.8: {} async-function@1.0.0: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + optional: true + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -8603,8 +9767,12 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.0: {} + bignumber.js@9.3.1: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -8646,6 +9814,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bundle-require@5.1.0(esbuild@0.27.0): @@ -8734,6 +9904,12 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -8800,6 +9976,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-uri-to-buffer@4.0.1: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -8943,10 +10121,26 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + optional: true + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.302: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} encodeurl@2.0.0: {} @@ -9472,6 +10666,9 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: + optional: true + events@3.3.0: {} execa@8.0.1: @@ -9524,6 +10721,10 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + + farmhash-modern@1.1.0: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 @@ -9566,6 +10767,10 @@ snapshots: dependencies: reusify: 1.1.0 + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -9575,6 +10780,11 @@ snapshots: entities: 7.0.1 fast-xml-parser: 5.3.7 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -9603,6 +10813,58 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + firebase-admin@13.7.0: + dependencies: + '@fastify/busboy': 3.2.0 + '@firebase/database-compat': 2.1.1 + '@firebase/database-types': 1.0.17 + farmhash-modern: 1.1.0 + fast-deep-equal: 3.1.3 + google-auth-library: 10.6.1 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + node-forge: 1.3.3 + uuid: 11.1.0 + optionalDependencies: + '@google-cloud/firestore': 7.11.6 + '@google-cloud/storage': 7.19.0 + transitivePeerDependencies: + - encoding + - supports-color + + firebase@12.10.0: + dependencies: + '@firebase/ai': 2.9.0(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/analytics': 0.10.20(@firebase/app@0.14.9) + '@firebase/analytics-compat': 0.2.26(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/app': 0.14.9 + '@firebase/app-check': 0.11.1(@firebase/app@0.14.9) + '@firebase/app-check-compat': 0.4.1(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/app-compat': 0.5.9 + '@firebase/app-types': 0.9.3 + '@firebase/auth': 1.12.1(@firebase/app@0.14.9) + '@firebase/auth-compat': 0.6.3(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/data-connect': 0.4.0(@firebase/app@0.14.9) + '@firebase/database': 1.1.1 + '@firebase/database-compat': 2.1.1 + '@firebase/firestore': 4.12.0(@firebase/app@0.14.9) + '@firebase/firestore-compat': 0.4.6(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/functions': 0.13.2(@firebase/app@0.14.9) + '@firebase/functions-compat': 0.4.2(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/installations-compat': 0.2.20(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/messaging': 0.12.24(@firebase/app@0.14.9) + '@firebase/messaging-compat': 0.2.24(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/performance': 0.7.10(@firebase/app@0.14.9) + '@firebase/performance-compat': 0.2.23(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/remote-config': 0.8.1(@firebase/app@0.14.9) + '@firebase/remote-config-compat': 0.2.22(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/storage': 0.14.1(@firebase/app@0.14.9) + '@firebase/storage-compat': 0.4.1(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/util': 1.14.0 + transitivePeerDependencies: + - '@react-native-async-storage/async-storage' + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -9628,6 +10890,21 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@2.5.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + optional: true + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -9636,6 +10913,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded-parse@2.1.2: {} forwarded@0.2.0: {} @@ -9670,12 +10951,56 @@ snapshots: hasown: 2.0.2 is-callable: 1.2.7 + functional-red-black-tree@1.0.1: + optional: true + functions-have-names@1.2.3: {} + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-func-name@2.0.2: {} get-intrinsic@1.3.0: @@ -9720,6 +11045,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.2 @@ -9757,12 +11091,69 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + google-auth-library@10.6.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + google-gax@4.6.1: + dependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.7.15 + '@types/long': 4.0.2 + abort-controller: 3.0.0 + duplexify: 4.1.3 + google-auth-library: 9.15.1 + node-fetch: 2.7.0 + object-hash: 3.0.0 + proto3-json-serializer: 2.0.2 + protobufjs: 7.5.4 + retry-request: 7.0.2 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + google-logging-utils@0.0.2: + optional: true + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} graphemer@1.4.0: {} + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -9812,6 +11203,17 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-parser-js@0.5.10: {} + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -9819,6 +11221,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} iceberg-js@0.8.1: {} @@ -9831,6 +11240,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@7.1.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -9916,6 +11327,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -9960,6 +11373,9 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: + optional: true + is-stream@3.0.0: {} is-string@1.1.1: @@ -10001,6 +11417,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-worker@27.5.1: dependencies: '@types/node': 22.19.11 @@ -10009,6 +11431,8 @@ snapshots: jiti@2.6.1: {} + jose@4.15.9: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -10021,6 +11445,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -10037,6 +11465,19 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -10044,6 +11485,27 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwks-rsa@3.2.2: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 4.15.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -10110,6 +11572,8 @@ snapshots: lilconfig@3.1.3: {} + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} linkify-it@5.0.0: @@ -10131,12 +11595,32 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + + lodash.clonedeep@4.5.0: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.snakecase@4.1.1: {} lodash@4.17.21: {} + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -10151,12 +11635,23 @@ snapshots: devlop: 1.1.0 highlight.js: 11.11.1 + lru-cache@10.4.3: {} + lru-cache@11.2.6: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + lucide-react@0.575.0(react@19.2.4): dependencies: react: 19.2.4 @@ -10207,6 +11702,9 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@3.0.0: + optional: true + mimic-fn@4.0.0: {} minimatch@10.2.2: @@ -10293,6 +11791,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-domexception@1.0.0: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -10304,6 +11804,14 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-forge@1.3.3: {} + node-releases@2.0.27: {} non-error@0.1.0: {} @@ -10318,6 +11826,9 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: + optional: true + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -10401,6 +11912,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -10430,6 +11943,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -10708,6 +12226,26 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 + proto3-json-serializer@2.0.2: + dependencies: + protobufjs: 7.5.4 + optional: true + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.11 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -10789,6 +12327,13 @@ snapshots: react@19.2.4: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + readdirp@4.1.2: {} real-require@0.2.0: {} @@ -10813,6 +12358,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} require-in-the-middle@8.0.1: @@ -10843,12 +12390,29 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry-request@7.0.2: + dependencies: + '@types/request': 2.48.13 + extend: 3.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + retry@0.13.1: + optional: true + reusify@1.1.0: {} rimraf@3.0.2: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + rollup@4.53.3: dependencies: '@types/estree': 1.0.8 @@ -10901,6 +12465,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -11096,6 +12662,26 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + optional: true + + stream-shift@1.0.3: + optional: true + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -11146,10 +12732,19 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-final-newline@3.0.0: {} @@ -11164,6 +12759,9 @@ snapshots: strnum@2.1.2: {} + stubs@3.0.0: + optional: true + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 @@ -11199,6 +12797,18 @@ snapshots: tapable@2.3.0: {} + teeny-request@9.0.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + stream-events: 1.0.5 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + terser-webpack-plugin@5.4.0(webpack@5.105.4): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -11449,6 +13059,11 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + + uuid@8.3.2: + optional: true + uuid@9.0.1: {} vary@1.1.2: {} @@ -11595,6 +13210,10 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + web-streams-polyfill@3.3.3: {} + + web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} webpack-sources@3.3.4: {} @@ -11631,6 +13250,14 @@ snapshots: - esbuild - uglify-js + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -11694,14 +13321,42 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.18.3: {} xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} + yallist@4.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} From d9aaecf4713fef58e1c21a0d97d578f3e3f0ebeb Mon Sep 17 00:00:00 2001 From: choihooo Date: Mon, 16 Mar 2026 17:46:10 +0900 Subject: [PATCH 5/7] =?UTF-8?q?chore:=20Firebase=20Service=20Account=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=84=20.gitignore=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit λ³΄μ•ˆ 민감 정보가 ν¬ν•¨λœ firebase-service-account.json 컀밋 λ°©μ§€ Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7c17415..58a7acf 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ coverage *.tsbuildinfo *.pem +# Firebase Service Account (λ³΄μ•ˆ 민감 정보) +firebase-service-account.json + ### CLAUDE ### #/docs From 9711c811601fa6fb6ef7a9e7367f75be82619a71 Mon Sep 17 00:00:00 2001 From: choihooo Date: Mon, 16 Mar 2026 17:47:45 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(web):=20Firebase=20Admin=20SDK=20-=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9D=BD=EA=B8=B0=20=E2=86=92=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 둜컬 파일 μ‹œμŠ€ν…œμ—μ„œ Service Account ν‚€λ₯Ό μ½λŠ” λŒ€μ‹  ν™˜κ²½λ³€μˆ˜ FIREBASE_SERVICE_ACCOUNT_KEY μ‚¬μš©ν•˜λ„λ‘ λ³€κ²½ 이유: 배포 ν™˜κ²½(Vercel/EC2)μ—μ„œλ„ λ™μž‘ν•˜λ„λ‘ Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/lib/firebase/admin.ts | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/web/src/lib/firebase/admin.ts diff --git a/packages/web/src/lib/firebase/admin.ts b/packages/web/src/lib/firebase/admin.ts new file mode 100644 index 0000000..53f855e --- /dev/null +++ b/packages/web/src/lib/firebase/admin.ts @@ -0,0 +1,27 @@ +import admin from 'firebase-admin'; +import { getApps } from 'firebase-admin/app'; + +if (!getApps().length) { + try { + // ν™˜κ²½λ³€μˆ˜μ—μ„œ Service Account ν‚€ 읽기 (JSON λ¬Έμžμ—΄) + const serviceAccountKey = process.env.FIREBASE_SERVICE_ACCOUNT_KEY; + + if (!serviceAccountKey) { + throw new Error('FIREBASE_SERVICE_ACCOUNT_KEY environment variable is not set'); + } + + const serviceAccount = JSON.parse(serviceAccountKey); + + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount as admin.ServiceAccount), + }); + + console.log('[Firebase] Admin SDK initialized successfully'); + } catch (error) { + console.error('[Firebase] Failed to initialize Firebase Admin:', error); + // μ—λŸ¬κ°€ μžˆμ–΄λ„ μ„œλ²„λŠ” 계속 μ‹€ν–‰λ˜λ„λ‘ 함 + // λŒ€μ‹  adminMessaging μ‚¬μš© μ‹œ μ—λŸ¬ 처리 + } +} + +export const adminMessaging = admin.messaging(); From 8aacd6b65842453a011c58433018806a93bbe1b6 Mon Sep 17 00:00:00 2001 From: choihooo Date: Mon, 16 Mar 2026 17:48:46 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor(web):=20Firebase=20Service=20Accou?= =?UTF-8?q?nt=20-=20JSON=20=E2=86=92=20=EA=B0=9C=EB=B3=84=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ν•˜λ‚˜μ˜ JSON ν™˜κ²½λ³€μˆ˜ λŒ€μ‹  ν•„λ“œλ³„λ‘œ λ‚˜λˆ„μ–΄ μ„€μ • 이유: - 더 λͺ…ν™•ν•œ ν™˜κ²½λ³€μˆ˜ 관리 - Vercel/EC2μ—μ„œ κ°œλ³„ μž…λ ₯ 용이 - private_key의 \n β†’ \n λ³€ν™˜ 처리 Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/lib/firebase/admin.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/web/src/lib/firebase/admin.ts b/packages/web/src/lib/firebase/admin.ts index 53f855e..73f691b 100644 --- a/packages/web/src/lib/firebase/admin.ts +++ b/packages/web/src/lib/firebase/admin.ts @@ -3,15 +3,24 @@ import { getApps } from 'firebase-admin/app'; if (!getApps().length) { try { - // ν™˜κ²½λ³€μˆ˜μ—μ„œ Service Account ν‚€ 읽기 (JSON λ¬Έμžμ—΄) - const serviceAccountKey = process.env.FIREBASE_SERVICE_ACCOUNT_KEY; + // ν™˜κ²½λ³€μˆ˜μ—μ„œ Service Account ν‚€ 각 ν•„λ“œ 읽기 + const serviceAccount = { + type: 'service_account', + project_id: process.env.FIREBASE_PROJECT_ID, + private_key_id: process.env.FIREBASE_PRIVATE_KEY_ID, + private_key: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'), + client_email: process.env.FIREBASE_CLIENT_EMAIL, + client_id: process.env.FIREBASE_CLIENT_ID, + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_x509_cert_url: `https://www.googleapis.com/robot/v1/metadata/x509/${process.env.FIREBASE_CLIENT_EMAIL}`, + }; - if (!serviceAccountKey) { - throw new Error('FIREBASE_SERVICE_ACCOUNT_KEY environment variable is not set'); + if (!serviceAccount.project_id || !serviceAccount.private_key || !serviceAccount.client_email) { + throw new Error('Required Firebase environment variables are missing'); } - const serviceAccount = JSON.parse(serviceAccountKey); - admin.initializeApp({ credential: admin.credential.cert(serviceAccount as admin.ServiceAccount), });